Tom
2014-10-26 df0ba7f7ff02ed02c0ba7714ae928a79d932baef
Improve the commitdiff.

* Optimize CSS: simplify selectors. That alone cuts rendering time in
half!
* Adapt HTML generation accordingly.
* Change line number generation so that one can select only code lines.
Also move the +/- out of the code column; it also gets in the way
when selecting.
* Omit long diffs altogether.
* Omit diff lines for deleted files, they're not particularly
interesting.
* Introduce a global limit on the maximum number of diff lines to show.
* Supply translations for the languages I speak for the new messages.

https://code.google.com/p/gitblit/issues/detail?id=450 was about a diff
with nearly 300k changed lines (with more then 3000 files deleted). But
one doesn't have to have such a monster commit to run into problems. My
FF 32 become unresponsive for the 30+ seconds it takes it to render a
commitdiff with some 30000 changed lines. (90% of which are in two
generated files; the whole commit has just 20 files.) GitHub has no
problems showing a commitdiff for this commit, but omits the two large
generated files, which makes sense.

This change implements a similar thing. Files with too many diff lines
get omitted from the output, only the header and a message that the
diff is too large remains. Additionally, there's a global limit on
the length of a commitdiff; if we exceed that, the whole diff is
truncated and the files not shown are listed.

The CSS change improves performance by not using descendant selectors
for all these table cells. Instead, we assign them precise classes and
just use that in the CSS.

The line number generation thing using data attributes and a :before
selector in the CSS, which enables text selections only in the code
column, is not strictly XHTML 1.0. (Data attributes are a feature of
HTML 5.) However, reasonably modern browsers also handle this correctly
if the page claims to be XHTML 1.0. Besides, the commitdiff page isn't
XHTML compliant anyway; I don't think a pre-element may contain divs
or even tables.

(Note that this technique could be used on other diff pages, too. For
instance on the blame page.)
1 files added
6 files modified
870 ■■■■■ changed files
src/main/java/com/gitblit/utils/DiffUtils.java 7 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/utils/GitBlitDiffFormatter.java 709 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/utils/ResettableByteArrayOutputStream.java 42 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties 9 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp_de.properties 7 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp_fr.properties 7 ●●●●● patch | view | raw | blame | history
src/main/resources/gitblit.css 89 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/utils/DiffUtils.java
@@ -228,15 +228,16 @@
        DiffStat stat = null;
        String diff = null;
        try {
            final ByteArrayOutputStream os = new ByteArrayOutputStream();
            ByteArrayOutputStream os = null;
            RawTextComparator cmp = RawTextComparator.DEFAULT;
            DiffFormatter df;
            switch (outputType) {
            case HTML:
                df = new GitBlitDiffFormatter(os, commit.getName());
                df = new GitBlitDiffFormatter(commit.getName(), path);
                break;
            case PLAIN:
            default:
                os = new ByteArrayOutputStream();
                df = new DiffFormatter(os);
                break;
            }
@@ -271,6 +272,7 @@
            } else {
                df.format(diffEntries);
            }
            df.flush();
            if (df instanceof GitBlitDiffFormatter) {
                // workaround for complex private methods in DiffFormatter
                diff = ((GitBlitDiffFormatter) df).getHtml();
@@ -278,7 +280,6 @@
            } else {
                diff = os.toString();
            }
            df.flush();
        } catch (Throwable t) {
            LOGGER.error("failed to generate commit diff!", t);
        }
src/main/java/com/gitblit/utils/GitBlitDiffFormatter.java
@@ -1,236 +1,473 @@
/*
 * Copyright 2011 gitblit.com.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.gitblit.utils;
import static org.eclipse.jgit.lib.Constants.encode;
import static org.eclipse.jgit.lib.Constants.encodeASCII;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.text.MessageFormat;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.util.RawParseUtils;
import com.gitblit.models.PathModel.PathChangeModel;
import com.gitblit.utils.DiffUtils.DiffStat;
/**
 * Generates an html snippet of a diff in Gitblit's style, tracks changed paths,
 * and calculates diff stats.
 *
 * @author James Moger
 *
 */
public class GitBlitDiffFormatter extends DiffFormatter {
    private final OutputStream os;
    private final DiffStat diffStat;
    private PathChangeModel currentPath;
    private int left, right;
    public GitBlitDiffFormatter(OutputStream os, String commitId) {
        super(os);
        this.os = os;
        this.diffStat = new DiffStat(commitId);
    }
    @Override
    public void format(DiffEntry ent) throws IOException {
        currentPath = diffStat.addPath(ent);
        super.format(ent);
    }
    /**
     * Output a hunk header
     *
     * @param aStartLine
     *            within first source
     * @param aEndLine
     *            within first source
     * @param bStartLine
     *            within second source
     * @param bEndLine
     *            within second source
     * @throws IOException
     */
    @Override
    protected void writeHunkHeader(int aStartLine, int aEndLine, int bStartLine, int bEndLine)
            throws IOException {
        os.write("<tr><th>..</th><th>..</th><td class='hunk_header'>".getBytes());
        os.write('@');
        os.write('@');
        writeRange('-', aStartLine + 1, aEndLine - aStartLine);
        writeRange('+', bStartLine + 1, bEndLine - bStartLine);
        os.write(' ');
        os.write('@');
        os.write('@');
        os.write("</td></tr>\n".getBytes());
        left = aStartLine + 1;
        right = bStartLine + 1;
    }
    protected void writeRange(final char prefix, final int begin, final int cnt) throws IOException {
        os.write(' ');
        os.write(prefix);
        switch (cnt) {
        case 0:
            // If the range is empty, its beginning number must
            // be the
            // line just before the range, or 0 if the range is
            // at the
            // start of the file stream. Here, begin is always 1
            // based,
            // so an empty file would produce "0,0".
            //
            os.write(encodeASCII(begin - 1));
            os.write(',');
            os.write('0');
            break;
        case 1:
            // If the range is exactly one line, produce only
            // the number.
            //
            os.write(encodeASCII(begin));
            break;
        default:
            os.write(encodeASCII(begin));
            os.write(',');
            os.write(encodeASCII(cnt));
            break;
        }
    }
    @Override
    protected void writeLine(final char prefix, final RawText text, final int cur)
            throws IOException {
        // update entry diffstat
        currentPath.update(prefix);
        // output diff
        os.write("<tr>".getBytes());
        switch (prefix) {
        case '+':
            os.write(("<th></th><th>" + (right++) + "</th>").getBytes());
            os.write("<td><div class=\"diff add2\">".getBytes());
            break;
        case '-':
            os.write(("<th>" + (left++) + "</th><th></th>").getBytes());
            os.write("<td><div class=\"diff remove2\">".getBytes());
            break;
        default:
            os.write(("<th>" + (left++) + "</th><th>" + (right++) + "</th>").getBytes());
            os.write("<td>".getBytes());
            break;
        }
        os.write(prefix);
        String line = text.getString(cur);
        line = StringUtils.escapeForHtml(line, false);
        os.write(encode(line));
        switch (prefix) {
        case '+':
        case '-':
            os.write("</div>".getBytes());
            break;
        default:
            os.write("</td>".getBytes());
        }
        os.write("</tr>\n".getBytes());
    }
    /**
     * Workaround function for complex private methods in DiffFormatter. This
     * sets the html for the diff headers.
     *
     * @return
     */
    public String getHtml() {
        ByteArrayOutputStream bos = (ByteArrayOutputStream) os;
        String html = RawParseUtils.decode(bos.toByteArray());
        String[] lines = html.split("\n");
        StringBuilder sb = new StringBuilder();
        boolean inFile = false;
        String oldnull = "a/dev/null";
        for (String line : lines) {
            if (line.startsWith("index")) {
                // skip index lines
            } else if (line.startsWith("new file")) {
                // skip new file lines
            } else if (line.startsWith("\\ No newline")) {
                // skip no new line
            } else if (line.startsWith("---") || line.startsWith("+++")) {
                // skip --- +++ lines
            } else if (line.startsWith("diff")) {
                line = StringUtils.convertOctal(line);
                if (line.indexOf(oldnull) > -1) {
                    // a is null, use b
                    line = line.substring(("diff --git " + oldnull).length()).trim();
                    // trim b/
                    line = line.substring(2).trim();
                } else {
                    // use a
                    line = line.substring("diff --git ".length()).trim();
                    line = line.substring(line.startsWith("\"a/") ? 3 : 2);
                    line = line.substring(0, line.indexOf(" b/") > -1 ? line.indexOf(" b/") : line.indexOf("\"b/")).trim();
                }
                if (line.charAt(0) == '"') {
                    line = line.substring(1);
                }
                if (line.charAt(line.length() - 1) == '"') {
                    line = line.substring(0, line.length() - 1);
                }
                if (inFile) {
                    sb.append("</tbody></table></div>\n");
                    inFile = false;
                }
                sb.append(MessageFormat.format("<div class='header'><div class=\"diffHeader\" id=\"{0}\"><i class=\"icon-file\"></i> ", line)).append(line).append("</div></div>");
                sb.append("<div class=\"diff\">");
                sb.append("<table><tbody>");
                inFile = true;
            } else {
                boolean gitLinkDiff = line.length() > 0 && line.substring(1).startsWith("Subproject commit");
                if (gitLinkDiff) {
                    sb.append("<tr><th></th><th></th>");
                    if (line.charAt(0) == '+') {
                        sb.append("<td><div class=\"diff add2\">");
                    } else {
                        sb.append("<td><div class=\"diff remove2\">");
                    }
                }
                sb.append(line);
                if (gitLinkDiff) {
                    sb.append("</div></td></tr>");
                }
            }
        }
        sb.append("</table></div>");
        return sb.toString();
    }
    public DiffStat getDiffStat() {
        return diffStat;
    }
}
/*
 * Copyright 2011 gitblit.com.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.gitblit.utils;
import static org.eclipse.jgit.lib.Constants.encode;
import static org.eclipse.jgit.lib.Constants.encodeASCII;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Map;
import org.apache.wicket.Application;
import org.apache.wicket.Localizer;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.util.RawParseUtils;
import com.gitblit.models.PathModel.PathChangeModel;
import com.gitblit.utils.DiffUtils.DiffStat;
import com.gitblit.wicket.GitBlitWebApp;
/**
 * Generates an html snippet of a diff in Gitblit's style, tracks changed paths, and calculates diff stats.
 *
 * @author James Moger
 * @author Tom <tw201207@gmail.com>
 *
 */
public class GitBlitDiffFormatter extends DiffFormatter {
    /**
     * gitblit.properties key for the per-file limit on the number of diff lines.
     */
    private static final String DIFF_LIMIT_PER_FILE_KEY = "web.maxDiffLinesPerFile";
    /**
     * gitblit.properties key for the global limit on the number of diff lines in a commitdiff.
     */
    private static final String GLOBAL_DIFF_LIMIT_KEY = "web.maxDiffLines";
    /**
     * Diffs with more lines are not shown in commitdiffs. (Similar to what GitHub does.) Can be reduced
     * (but not increased) through gitblit.properties key {@link #DIFF_LIMIT_PER_FILE_KEY}.
     */
    private static final int DIFF_LIMIT_PER_FILE = 4000;
    /**
     * Global diff limit. Commitdiffs with more lines are truncated. Can be reduced (but not increased)
     * through gitblit.properties key {@link #GLOBAL_DIFF_LIMIT_KEY}.
     */
    private static final int GLOBAL_DIFF_LIMIT = 20000;
    private final ResettableByteArrayOutputStream os;
    private final DiffStat diffStat;
    private PathChangeModel currentPath;
    private int left, right;
    /**
     * If a single file diff in a commitdiff produces more than this number of lines, we don't display
     * the diff. First, it's too taxing on the browser: it'll spend an awful lot of time applying the
     * CSS rules (despite my having optimized them). And second, no human can read a diff with thousands
     * of lines and make sense of it.
     * <p>
     * Set to {@link #DIFF_LIMIT_PER_FILE} for commitdiffs, and to -1 (switches off the limit) for
     * single-file diffs.
     * </p>
     */
    private final int maxDiffLinesPerFile;
    /**
     * Global limit on the number of diff lines. Set to {@link #GLOBAL_DIFF_LIMIT} for commitdiffs, and
     * to -1 (switched off the limit) for single-file diffs.
     */
    private final int globalDiffLimit;
    /** Number of lines for the current file diff. Set to zero when a new DiffEntry is started. */
    private int nofLinesCurrent;
    /**
     * Position in the stream when we try to write the first line. Used to rewind when we detect that
     * the diff is too large.
     */
    private int startCurrent;
    /** Flag set to true when we rewind. Reset to false when we start a new DiffEntry. */
    private boolean isOff;
    /** The current diff entry. */
    private DiffEntry entry;
    // Global limit stuff.
    /** Total number of lines written before the current diff entry. */
    private int totalNofLinesPrevious;
    /** Running total of the number of diff lines written. Updated until we exceed the global limit. */
    private int totalNofLinesCurrent;
    /** Stream position to reset to if we decided to truncate the commitdiff. */
    private int truncateTo;
    /** Whether we decided to truncate the commitdiff. */
    private boolean truncated;
    /** If {@link #truncated}, contains all files skipped,possibly with a suffix message as value to be displayed. */
    private final Map<String, String> skipped = new HashMap<String, String>();
    public GitBlitDiffFormatter(String commitId, String path) {
        super(new ResettableByteArrayOutputStream());
        this.os = (ResettableByteArrayOutputStream) getOutputStream();
        this.diffStat = new DiffStat(commitId);
        // If we have a full commitdiff, install maxima to avoid generating a super-long diff listing that
        // will only tax the browser too much.
        maxDiffLinesPerFile = path != null ? -1 : getLimit(DIFF_LIMIT_PER_FILE_KEY, 500, DIFF_LIMIT_PER_FILE);
        globalDiffLimit = path != null ? -1 : getLimit(GLOBAL_DIFF_LIMIT_KEY, 1000, GLOBAL_DIFF_LIMIT);
    }
    /**
     * Determines a limit to use for HTML diff output.
     *
     * @param key
     *            to use to read the value from the GitBlit settings, if available.
     * @param minimum
     *            minimum value to enforce
     * @param maximum
     *            maximum (and default) value to enforce
     * @return the limit
     */
    private int getLimit(String key, int minimum, int maximum) {
        if (Application.exists()) {
            Application application = Application.get();
            if (application instanceof GitBlitWebApp) {
                GitBlitWebApp webApp = (GitBlitWebApp) application;
                int configValue = webApp.settings().getInteger(key, maximum);
                if (configValue < minimum) {
                    return minimum;
                } else if (configValue < maximum) {
                    return configValue;
                }
            }
        }
        return maximum;
    }
    /**
     * Returns a localized message string, if there is a localization; otherwise the given default value.
     *
     * @param key
     *            message key for the message
     * @param defaultValue
     *            to use if no localization for the message can be found
     * @return the possibly localized message
     */
    private String getMsg(String key, String defaultValue) {
        if (Application.exists()) {
            Localizer localizer = Application.get().getResourceSettings().getLocalizer();
            if (localizer != null) {
                // Use getStringIgnoreSettings because we don't want exceptions here if the key is missing!
                return localizer.getStringIgnoreSettings(key, null, null, defaultValue);
            }
        }
        return defaultValue;
    }
    @Override
    public void format(DiffEntry ent) throws IOException {
        currentPath = diffStat.addPath(ent);
        nofLinesCurrent = 0;
        isOff = false;
        entry = ent;
        if (!truncated) {
            totalNofLinesPrevious = totalNofLinesCurrent;
            if (globalDiffLimit > 0 && totalNofLinesPrevious > globalDiffLimit) {
                truncated = true;
                isOff = true;
            }
            truncateTo = os.size();
        } else {
            isOff = true;
        }
        if (isOff) {
            if (ent.getChangeType().equals(ChangeType.DELETE)) {
                skipped.put(ent.getOldPath(), getMsg("gb.diffDeletedFileSkipped", "(deleted file)"));
            } else {
                skipped.put(ent.getNewPath(), null);
            }
        }
        // Keep formatting, but if off, don't produce anything anymore. We just keep on counting.
        super.format(ent);
    }
    @Override
    public void flush() throws IOException {
        if (truncated) {
            os.resetTo(truncateTo);
        }
        super.flush();
    }
    /**
     * Rewind and issue a message that the diff is too large.
     */
    private void reset() {
        if (!isOff) {
            os.resetTo(startCurrent);
            try {
                os.write("<tr><td class='diff-cell' colspan='4'>".getBytes());
                os.write(StringUtils.escapeForHtml(getMsg("gb.diffFileDiffTooLarge", "Diff too large"), false).getBytes());
                os.write("</td></tr>\n".getBytes());
            } catch (IOException ex) {
                // Cannot happen with a ByteArrayOutputStream
            }
            totalNofLinesCurrent = totalNofLinesPrevious;
            isOff = true;
        }
    }
    /**
     * Writes an initial table row containing information about added/removed/renamed/copied files. In case
     * of a deletion, we also suppress generating the diff; it's not interesting. (All lines removed.)
     */
    private void handleChange() {
        // XXX Would be nice if we could generate blob links for the cases handled here. Alas, we lack the repo
        // name, and cannot reliably determine it here. We could get the .git directory of a Repository, if we
        // passed in the repo, and then take the name of the parent directory, but that'd fail for repos nested
        // in GitBlit projects. And we don't know if the repo is inside a project or is a top-level repo.
        //
        // That's certainly solvable (just pass along more information), but would require a larger rewrite than
        // I'm prepared to do now.
        String message;
        switch (entry.getChangeType()) {
        case ADD:
            message = getMsg("gb.diffNewFile", "New file");
            break;
        case DELETE:
            message = getMsg("gb.diffDeletedFile", "File was deleted");
            isOff = true;
            break;
        case RENAME:
            message = MessageFormat.format(getMsg("gb.diffRenamedFile", "File was renamed from {0}"), entry.getOldPath());
            break;
        case COPY:
            message = MessageFormat.format(getMsg("gb.diffCopiedFile", "File was copied from {0}"), entry.getOldPath());
            break;
        default:
            return;
        }
        try {
            os.write("<tr><td class='diff-cell' colspan='4'>".getBytes());
            os.write(StringUtils.escapeForHtml(message, false).getBytes());
            os.write("</td></tr>\n".getBytes());
        } catch (IOException ex) {
            // Cannot happen with a ByteArrayOutputStream
        }
    }
    /**
     * Output a hunk header
     *
     * @param aStartLine
     *            within first source
     * @param aEndLine
     *            within first source
     * @param bStartLine
     *            within second source
     * @param bEndLine
     *            within second source
     * @throws IOException
     */
    @Override
    protected void writeHunkHeader(int aStartLine, int aEndLine, int bStartLine, int bEndLine) throws IOException {
        if (nofLinesCurrent++ == 0) {
            handleChange();
            startCurrent = os.size();
        }
        if (!isOff) {
            totalNofLinesCurrent++;
            if (nofLinesCurrent > maxDiffLinesPerFile && maxDiffLinesPerFile > 0) {
                reset();
            } else {
                os.write("<tr><th class='diff-line' data-lineno='..'></th><th class='diff-line' data-lineno='..'></th><th class='diff-state'></th><td class='hunk_header'>"
                        .getBytes());
                os.write('@');
                os.write('@');
                writeRange('-', aStartLine + 1, aEndLine - aStartLine);
                writeRange('+', bStartLine + 1, bEndLine - bStartLine);
                os.write(' ');
                os.write('@');
                os.write('@');
                os.write("</td></tr>\n".getBytes());
            }
        }
        left = aStartLine + 1;
        right = bStartLine + 1;
    }
    protected void writeRange(final char prefix, final int begin, final int cnt) throws IOException {
        os.write(' ');
        os.write(prefix);
        switch (cnt) {
        case 0:
            // If the range is empty, its beginning number must be the
            // line just before the range, or 0 if the range is at the
            // start of the file stream. Here, begin is always 1 based,
            // so an empty file would produce "0,0".
            //
            os.write(encodeASCII(begin - 1));
            os.write(',');
            os.write('0');
            break;
        case 1:
            // If the range is exactly one line, produce only the number.
            //
            os.write(encodeASCII(begin));
            break;
        default:
            os.write(encodeASCII(begin));
            os.write(',');
            os.write(encodeASCII(cnt));
            break;
        }
    }
    @Override
    protected void writeLine(final char prefix, final RawText text, final int cur) throws IOException {
        if (nofLinesCurrent++ == 0) {
            handleChange();
            startCurrent = os.size();
        }
        // update entry diffstat
        currentPath.update(prefix);
        if (isOff) {
            return;
        }
        totalNofLinesCurrent++;
        if (nofLinesCurrent > maxDiffLinesPerFile && maxDiffLinesPerFile > 0) {
            reset();
        } else {
            // output diff
            os.write("<tr>".getBytes());
            switch (prefix) {
            case '+':
                os.write(("<th class='diff-line'></th><th class='diff-line' data-lineno='" + (right++) + "'></th>").getBytes());
                os.write("<th class='diff-state diff-state-add'></th>".getBytes());
                os.write("<td class='diff-cell add2'>".getBytes());
                break;
            case '-':
                os.write(("<th class='diff-line' data-lineno='" + (left++) + "'></th><th class='diff-line'></th>").getBytes());
                os.write("<th class='diff-state diff-state-sub'></th>".getBytes());
                os.write("<td class='diff-cell remove2'>".getBytes());
                break;
            default:
                os.write(("<th class='diff-line' data-lineno='" + (left++) + "'></th><th class='diff-line' data-lineno='" + (right++) + "'></th>").getBytes());
                os.write("<th class='diff-state'></th>".getBytes());
                os.write("<td class='diff-cell context2'>".getBytes());
                break;
            }
            String line = text.getString(cur);
            line = StringUtils.escapeForHtml(line, false);
            os.write(encode(line));
            os.write("</td></tr>\n".getBytes());
        }
    }
    /**
     * Workaround function for complex private methods in DiffFormatter. This sets the html for the diff headers.
     *
     * @return
     */
    public String getHtml() {
        String html = RawParseUtils.decode(os.toByteArray());
        String[] lines = html.split("\n");
        StringBuilder sb = new StringBuilder();
        boolean inFile = false;
        String oldnull = "a/dev/null";
        for (String line : lines) {
            if (line.startsWith("index")) {
                // skip index lines
            } else if (line.startsWith("new file") || line.startsWith("deleted file")) {
                // skip new file lines
            } else if (line.startsWith("\\ No newline")) {
                // skip no new line
            } else if (line.startsWith("---") || line.startsWith("+++")) {
                // skip --- +++ lines
            } else if (line.startsWith("diff")) {
                line = StringUtils.convertOctal(line);
                if (line.indexOf(oldnull) > -1) {
                    // a is null, use b
                    line = line.substring(("diff --git " + oldnull).length()).trim();
                    // trim b/
                    line = line.substring(2).trim();
                } else {
                    // use a
                    line = line.substring("diff --git ".length()).trim();
                    line = line.substring(line.startsWith("\"a/") ? 3 : 2);
                    line = line.substring(0, line.indexOf(" b/") > -1 ? line.indexOf(" b/") : line.indexOf("\"b/")).trim();
                }
                if (line.charAt(0) == '"') {
                    line = line.substring(1);
                }
                if (line.charAt(line.length() - 1) == '"') {
                    line = line.substring(0, line.length() - 1);
                }
                if (inFile) {
                    sb.append("</tbody></table></div>\n");
                    inFile = false;
                }
                sb.append(MessageFormat.format("<div class='header'><div class=\"diffHeader\" id=\"{0}\"><i class=\"icon-file\"></i> ", line)).append(line)
                        .append("</div></div>");
                sb.append("<div class=\"diff\">");
                sb.append("<table><tbody>");
                inFile = true;
            } else {
                boolean gitLinkDiff = line.length() > 0 && line.substring(1).startsWith("Subproject commit");
                if (gitLinkDiff) {
                    sb.append("<tr><th class='diff-line'></th><th class='diff-line'></th>");
                    if (line.charAt(0) == '+') {
                        sb.append("<th class='diff-state diff-state-add'></th><td class=\"diff-cell add2\">");
                    } else {
                        sb.append("<th class='diff-state diff-state-sub'></th><td class=\"diff-cell remove2\">");
                    }
                }
                sb.append(line);
                if (gitLinkDiff) {
                    sb.append("</td></tr>");
                }
            }
        }
        sb.append("</tbody></table></div>");
        if (truncated) {
            sb.append(MessageFormat.format("<div class='header'><div class='diffHeader'>{0}</div></div>",
                    StringUtils.escapeForHtml(getMsg("gb.diffTruncated", "Diff truncated after the above file"), false)));
            // List all files not shown. We can be sure we do have at least one path in skipped.
            sb.append("<div class='diff'><table><tbody><tr><td class='diff-cell' colspan='4'>");
            boolean first = true;
            for (Map.Entry<String, String> s : skipped.entrySet()) {
                if (!first) {
                    sb.append('\n');
                }
                String path = StringUtils.escapeForHtml(s.getKey(), false);
                String comment = s.getValue();
                if (comment != null) {
                    sb.append("<span id='" + path + "'>" + path + ' ' + StringUtils.escapeForHtml(comment, false) + "</span>");
                } else {
                    sb.append("<span id='" + path + "'>" + path + "</span>");
                }
                first = false;
            }
            sb.append("</td></tr></tbody></table></div>");
        }
        return sb.toString();
    }
    public DiffStat getDiffStat() {
        return diffStat;
    }
}
src/main/java/com/gitblit/utils/ResettableByteArrayOutputStream.java
New file
@@ -0,0 +1,42 @@
// Copyright (C) 2014 Tom <tw201207@gmail.com>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.package com.gitblit.utils;
package com.gitblit.utils;
import java.io.ByteArrayOutputStream;
/**
 * A {@link ByteArrayOutputStream} that can be reset to a specified position.
 *
 * @author Tom <tw201207@gmail.com>
 */
public class ResettableByteArrayOutputStream extends ByteArrayOutputStream {
    /**
     * Reset the stream to the given position. If {@code mark} is <= 0, see {@link #reset()}.
     * A no-op if the stream contains less than {@code mark} bytes. Otherwise, resets the
     * current writing position to {@code mark}. Previously allocated buffer space will be
     * reused in subsequent writes.
     *
     * @param mark
     *            to set the current writing position to.
     */
    public synchronized void resetTo(int mark) {
        if (mark <= 0) {
            reset();
        } else if (mark < count) {
            count = mark;
        }
    }
}
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
@@ -749,4 +749,11 @@
gb.sortLowestPriority = lowest priority
gb.sortHighestSeverity = highest severity
gb.sortLowestSeverity = lowest severity
gb.missingIntegrationBranchMore = The target integration branch does not exist in the repository!
gb.missingIntegrationBranchMore = The target integration branch does not exist in the repository!
gb.diffDeletedFileSkipped = (deleted file)
gb.diffFileDiffTooLarge = Diff too large
gb.diffNewFile = New file
gb.diffDeletedFile = File was deleted
gb.diffRenamedFile = File was renamed from {0}
gb.diffCopiedFile = File was copied from {0}
gb.diffTruncated = Diff truncated after the above file
src/main/java/com/gitblit/wicket/GitBlitWebApp_de.properties
@@ -743,3 +743,10 @@
gb.sshKeyPermissionDescription = Geben Sie die Zugriffberechtigung f\u00fcr den SSH Key an
gb.transportPreference = \u00dcbertragungseinstellungen
gb.transportPreferenceDescription = Geben Sie die \u00dcbertragungsart an, die Sie f\u00fcr das Klonen bevorzugen
gb.diffDeletedFileSkipped = (gel\u00f6schte Datei)
gb.diffFileDiffTooLarge = Zu viele \u00c4nderungen; Diff wird nicht angezeigt
gb.diffNewFile = Neue Datei
gb.diffDeletedFile = Datei wurde gel\u00f6scht
gb.diffRenamedFile = Datei umbenannt von {0}
gb.diffCopiedFile = Datei kopiert von {0}
gb.diffTruncated = Diff nach obiger Datei abgeschnitten
src/main/java/com/gitblit/wicket/GitBlitWebApp_fr.properties
@@ -672,3 +672,10 @@
gb.mergeToDescription = branche d'int\u00e9gration par d\u00e9faut pour fusionner les correctifs li\u00e9s aux tickets
gb.myTickets = mes tickets
gb.yourAssignedTickets = dont vous \u00eates responsable
gb.diffDeletedFileSkipped = (fichier effac\u00e9)
gb.diffFileDiffTooLarge = Trop de diff\u00e9rences, affichage supprim\u00e9e
gb.diffNewFile = Nouveau fichier
gb.diffDeletedFile = Fichier a \u00e9t\u00e9 effac\u00e9
gb.diffRenamedFile = Fichier renomm\u00e9 de {0}
gb.diffCopiedFile = Fichier copi\u00e9 de {0}
gb.diffTruncated = Affichage de diff\u00e9rences supprim\u00e9e apr\u00e8s le fichier ci-dessus
src/main/resources/gitblit.css
@@ -1350,19 +1350,6 @@
    font-family: inherit;
}
div.diff.hunk_header {
    -moz-border-bottom-colors: none;
    -moz-border-image: none;
    -moz-border-left-colors: none;
    -moz-border-right-colors: none;
    -moz-border-top-colors: none;
    border-color: #FFE0FF;
    border-style: dotted;
    border-width: 1px 0 0;
    margin-top: 2px;
    font-family: inherit;
}
span.diff.hunk_info {
    background-color: #FFEEFF;    
    color: #990099;
@@ -1374,60 +1361,72 @@
    font-family: inherit;
}
div.diff.add2 {
    background-color: #DDFFDD;
    font-family: inherit;
.diff-cell {
    margin: 0px;
    padding: 0px;
    border: 0;
    border-left: 1px solid #bbb;
}
div.diff.remove2 {
.add2 {
    background-color: #DDFFDD;
}
.remove2 {
    background-color: #FFDDDD;
    font-family: inherit;
}
div.diff table {
.context2 {
    background-color: #fbfbfb;
}
div.diff > table {
    border-radius: 0;
    border-right: 1px solid #bbb;
    border-bottom: 1px solid #bbb;
    width: 100%;
}
div.diff table th, div.diff table td {
    margin: 0px;
    padding: 0px;
    font-family: monospace;
    border: 0;
}
div.diff table th {
.diff-line {
    background-color: #f0f0f0;
    text-align: center;
    color: #999;
    padding-left: 5px;
    padding-right: 5px;
    width: 30px;
    padding-left: 2px;
    padding-right: 2px;
    width: 3em; /* Font-size relative! */
}
div.diff table th.header {
    background-color: #D2C3AF;
    border-right: 0px;
    border-bottom: 1px solid #808080;
    font-family: inherit;
    font-size:0.9em;
    color: black;
    padding: 2px;
    text-align: left;
.diff-line:before {
    content: attr(data-lineno);
}
div.diff table td.hunk_header {
.diff-state {
    background-color: #f0f0f0;
    text-align: center;
    color: #999;
    padding-left: 2px;
    padding-right: 2px;
    width: 0.5em; /* Font-size relative! */
}
.diff-state-add:before {
    color: green;
    font-weight: bold;
    content: '+';
}
.diff-state-sub:before {
    color: red;
    font-weight: bold;
    content: '-';
}
.hunk_header {
    background-color: #dAe2e5 !important;
    border-left: 1px solid #bbb;
    border-top: 1px solid #bac2c5;    
    border-bottom: 1px solid #bac2c5;
    color: #555;
}
div.diff table td {
    border-left: 1px solid #bbb;
    background-color: #fbfbfb;
}
td.changeType {