Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions components/FileViewer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,79 @@ test("partly loaded HTML previews up to the HTML cap; partly loaded markdown doe
assert.equal(hasPreview(partial, false, true, false, cap), false);
assert.equal(hasPreview({ ...partial, size: cap + 1 }, false, false, true, cap), true);
});

test("the line-number gutter is pinned only in views of at most SOURCE_HIGHLIGHT_MAX_LINES rows", () => {
// Evaluate the module's own style constants (the numbers in the highlighted,
// plain and diff views all come from these), the highlighted and lightweight
// source elements and the Diff view's rows, instead of matching the
// component's text.
const file = ts.createSourceFile("FileViewer.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const constants = (...names) => {
const statements = names.map((name) => {
const statement = file.statements.find((node) =>
ts.isVariableStatement(node) && node.declarationList.declarations.some((declaration) =>
ts.isIdentifier(declaration.name) && declaration.name.text === name,
),
);
assert.ok(statement, `${name} is declared in the module`);
return statement.getText(file);
});
const { outputText } = ts.transpileModule(`${statements.join("\n")}\nreturn [${names.join(", ")}];`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } });
return new Function(outputText)();
};
const [codeStyle, gutterStyle, pinnedStyle, maxRows] = constants("FILE_CODE_STYLE", "FILE_LINE_NUMBER_STYLE", "FILE_PINNED_LINE_NUMBER_STYLE", "SOURCE_HIGHLIGHT_MAX_LINES");

// Build the source views without mounting the file-fetching component, with
// the real gutter styles in place.
const calculations = file.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "TextFileViewer").body.statements.filter((node) =>
ts.isVariableStatement(node) && node.declarationList.declarations.some((declaration) =>
["isDelimitedTable", "viewerContent", "sourceLines", "language", "isHtml", "isMarkdown", "hasPreview", "effectiveDisplayMode", "useLightweightSource", "lightweightSourceLines", "prismReady", "highlightedSource"].includes(declaration.name.getText(file)),
),
).map((node) => node.getText(file)).join("\n");
const { outputText } = ts.transpileModule(`
return (data, displayMode, hasGitDiff = false, isDeletedDiff = false, wrapLines = false, filePath = "/tmp/file.txt") => {
const isDelimitedTablePath = (path) => /\.(csv|tsv)$/i.test(path);
const SOURCE_HIGHLIGHT_MAX_LINES = 1_000;
const HTML_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
const FILE_LINE_NUMBER_STYLE = gutterStyle;
const FILE_PINNED_LINE_NUMBER_STYLE = pinnedStyle;
// Stand-ins for what the highlighted element only passes on.
const [usePrismLanguages, SyntaxHighlighter, SourceCodeRenderer, isDark, vs, fileViewerDarkTheme, FILE_CODE_STYLE] = [() => true, "pre", "span", false, {}, {}, {}];
${calculations}
return { lightweightSourceLines, highlightedSource };
};
`, { compilerOptions: { jsx: ts.JsxEmit.React } });
const render = new Function("React", "useMemo", "gutterStyle", "pinnedStyle", outputText)(React, (calculate) => calculate(), gutterStyle, pinnedStyle);
const { lightweightSourceLines: rows, highlightedSource } = render({ content: "line\n".repeat(1_000), language: "text" }, "source");
const numberStyle = rows[0].props.children[0].props.style;
// The Diff view's first row number, with every row an added line.
const diffView = file.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "DiffView");
const { outputText: diffOutput } = ts.transpileModule(`return ${diffView.getText(file)}`, { compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022 } });
const diffNumberStyle = (rowCount) => {
const added = Array.from({ length: rowCount }, (_, index) => ({ type: "added", text: "line", oldLineNo: null, newLineNo: index + 1 }));
const DiffView = new Function("React", "useI18n", "diffLines", "SOURCE_HIGHLIGHT_MAX_LINES", "FILE_CODE_STYLE", "FILE_LINE_NUMBER_STYLE", "FILE_PINNED_LINE_NUMBER_STYLE", diffOutput)(
React, () => ({ t: (key) => key }), () => added, maxRows, codeStyle, gutterStyle, pinnedStyle,
);
return DiffView({ patch: "" }).props.children[0].props.children[0].props.children[0].props.style;
};

// The pinned gutter is the same column, pinned to the left edge of the
// viewer's `overflow: auto` content box above the tokens that slide behind it.
for (const [key, value] of Object.entries(gutterStyle)) assert.equal(pinnedStyle[key], value, key);
assert.equal(pinnedStyle.position, "sticky");
assert.equal(pinnedStyle.left, 0);
assert.ok(pinnedStyle.zIndex >= 1, "the pinned gutter must stack above the scrolling text");
// Views of at most SOURCE_HIGHLIGHT_MAX_LINES rows pin it: the highlighted
// Source view (never longer) and a diff up to that many rows.
assert.deepEqual(highlightedSource.props.lineNumberStyle, pinnedStyle);
assert.equal(diffNumberStyle(maxRows), pinnedStyle);
assert.equal(diffNumberStyle(maxRows + 1), gutterStyle);
// The plain fallback has more rows than that by definition, and the browser
// repositions every sticky cell on each scroll: its numbers stay unpinned.
assert.equal(numberStyle, gutterStyle);
assert.equal(numberStyle.position, undefined);

// The gutter repeats the code line's height in px, so a code-font change
// without it puts the numbers off the line boxes they belong to.
assert.equal(parseFloat(numberStyle.lineHeight), Number((codeStyle.fontSize * codeStyle.lineHeight).toFixed(1)));
});
27 changes: 25 additions & 2 deletions components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ const fileViewerDarkTheme = {
};
delete fileViewerDarkTheme['pre[class*="language-"]'].background;

// `lineHeight` is FILE_CODE_STYLE's 13px * 1.6, so both columns share one line
// box; a code-font change must move it with fontSize.
const FILE_LINE_NUMBER_STYLE: CSSProperties = {
width: 48,
minWidth: 48,
Expand All @@ -108,6 +110,24 @@ const FILE_LINE_NUMBER_STYLE: CSSProperties = {
verticalAlign: "top",
};

// The gutter stays put while a wide line scrolls sideways: `position: sticky`
// against the viewer's own scrolling box (`.file-viewer-content`; the
// highlighted <pre> sets `overflow: visible`, so it is the nearest scroller in
// every view). It has room to travel because every row spans the widest line
// (the <pre> or wrapper is `width: max-content` and a row is `min-width:
// 100%`), so no line's number scrolls away behind its text.
// The browser repositions every sticky number on each scroll, vertical too, so
// only views of at most SOURCE_HIGHLIGHT_MAX_LINES rows pin it: the highlighted
// Source view and a diff that short. Pinned, a 24,000-line log in the plain
// fallback scrolled with 50-67 ms frames; it and longer diffs keep the plain
// gutter.
const FILE_PINNED_LINE_NUMBER_STYLE: CSSProperties = {
...FILE_LINE_NUMBER_STYLE,
position: "sticky",
left: 0,
zIndex: 1,
};

type SourceCodeRendererProps = Parameters<NonNullable<SyntaxHighlighterProps["renderer"]>>[0] & {
wrapLines: boolean;
};
Expand Down Expand Up @@ -332,6 +352,9 @@ function DiffView({ patch }: { patch: string }) {
visible.add(j);
}
}
const lineNumberStyle = visible.size <= SOURCE_HIGHLIGHT_MAX_LINES
? FILE_PINNED_LINE_NUMBER_STYLE
: FILE_LINE_NUMBER_STYLE;

const segments: Array<{ hidden: true; count: number } | { hidden: false; lines: DiffLine[] }> = [];
let i = 0;
Expand Down Expand Up @@ -409,7 +432,7 @@ function DiffView({ patch }: { patch: string }) {
}}
>
<span
style={FILE_LINE_NUMBER_STYLE}
style={lineNumberStyle}
>
{line.type === "removed" ? line.oldLineNo : line.newLineNo}
</span>
Expand Down Expand Up @@ -1445,7 +1468,7 @@ function TextFileViewer({
style={isDark ? fileViewerDarkTheme : vs}
showLineNumbers
lineNumberStyle={{
...FILE_LINE_NUMBER_STYLE,
...FILE_PINNED_LINE_NUMBER_STYLE,
}}
customStyle={{
margin: 0,
Expand Down
Loading