Skip to content

fix(file-viewer): keep the line-number gutter pinned and ease the code font - #1200

Merged
agegr merged 4 commits into
agegr:mainfrom
brynne8:fix/file-viewer-pinned-gutter
Oct 15, 2026
Merged

agegr merged 4 commits into
agegr:mainfrom
brynne8:fix/file-viewer-pinned-gutter

Conversation

@brynne8

@brynne8 brynne8 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

What the reader sees today

Scroll a wide line sideways in the right panel's Source view and the line
number scrolls out of view with the text — a few hundred pixels in, there is
no way to tell which line you are on. Same in the Diff view.

Cause

The gutter is an ordinary flex child of the scrolling row. Nothing pins it to
the scroller's left edge.

Change

The fix — FILE_LINE_NUMBER_STYLE gets position: sticky; left: 0; z-index: 1. All three gutters take this one constant, so they move together:

  • highlighted Source view (lineNumberStyle; react-syntax-highlighter puts
    the numbered span as the first child of each .file-source-line row),
  • the past-the-limit plain fallback source view,
  • the Diff view.

Sticky needs a scroll container and room to travel; both exist upstream
today, verified by reading:

  • The scroller is .file-viewer-content (overflow: auto), which wraps
    source, diff and preview alike.
  • In the highlighted view the nearest other candidate is the Prism <pre>,
    and its theme overflow: auto is overridden by the viewer's own
    customStyle overflow: "visible", so the content box stays the scroller.
  • Room: each view's <pre>/wrapper is width: max-content and every row is
    min-width: 100%, so each row spans the widest line and the pinned number
    slides inside it; the gutter already has an opaque var(--bg-panel)
    background, so text slides behind it, not through it.

The preview modes render no gutter, so they are unaffected. With wrap-lines
on there is no sideways scroll; sticky is then inert, not wrong.

The taste call, stated openly — FILE_CODE_STYLE 13px → 12px. It is not
needed by the fix and rides along for one structural reason: the gutter
repeats the code line's height in px, so lineHeight (20.8px) must equal
fontSize × 1.6, and any font change moves it in the same commit. Landing
the font alone would ship numbers off their line boxes; splitting would be
two PRs for one coupled constant. Reverting the two font-touched lines
(fontSize: 12, lineHeight: "19.2px", back to 13 / 20.8px) leaves the fix
standing on its own — the test pins the equality and the pin, never a
particular size.

Test

One test in components/FileViewer.test.mjs, no source-text matching: it
evaluates the module's own FILE_CODE_STYLE / FILE_LINE_NUMBER_STYLE and
renders the lightweight source rows with the real gutter style, then asserts
on the rendered row — gutter position: sticky, left: 0, zIndex ≥ 1,
row min-width: 100%, and gutter px lineHeight === fontSize × lineHeight
(the coupling nothing else would catch).

Fails before / passes after, both runners:

# against main, fix reverted:
(fail) the line-number gutter stays pinned while a wide line scrolls sideways
  actual: undefined, expected: 'sticky'
# with the fix:
(pass) the line-number gutter stays pinned while a wide line scrolls sideways
bun  test components/FileViewer.test.mjs          → 3 pass, 0 fail
node --experimental-strip-types --test <file>     → 3 pass, 0 fail
node --test "components/**/*.test.mjs"            → 553 pass, 0 fail
bun x tsc --noEmit / bun run lint                 → clean

…e font

Scrolling a wide source line sideways took its line number with it: the
gutter was an ordinary column of the scrolling row, so past a few
hundred pixels the reader loses which line they are on.

- `FILE_LINE_NUMBER_STYLE`: `position: sticky; left: 0; z-index: 1`
  pins the gutter to the viewer's own `overflow: auto` content box.
  All three gutters take this one constant — the highlighted Source view
  (`lineNumberStyle`, whose <pre> sets `overflow: visible` so the content
  box is the scroller), the past-the-limit plain fallback, and the Diff
  view — and each wraps its rows in a `width: max-content` box with
  `min-width: 100%` rows, which is the room sticky travels in.
- `FILE_CODE_STYLE`: 13px -> 12px, a taste call. It rides along because
  the gutter repeats the code line's height in px: the constant is now
  `fontSize * 1.6`, so the font and `lineHeight` (20.8px -> 19.2px) must
  move in one commit or the numbers sit off their line boxes. Splitting
  it would only ship the broken intermediate state.
- A test renders the source rows with the module's real style
  constants: the sticky pin, and the gutter px height = code fontSize *
  lineHeight, a coupling nothing else visible would catch.

Both runners: `bun test components/FileViewer.test.mjs` and
`node --experimental-strip-types --test` on the file; the components
suite passes 553. tsc and eslint unchanged.
@brynne8
brynne8 force-pushed the fix/file-viewer-pinned-gutter branch from cff75b4 to 7ec03b6 Compare October 12, 2026 03:11
agegr and others added 3 commits October 14, 2026 18:51
The pinned gutter does not need a smaller code font, and a font size is a
separate call from that fix: restore FILE_CODE_STYLE's 13px and the
gutter's matching 20.8px line height (13 * 1.6), so this change only pins
the line numbers. The test checks that the two stay equal, not a size, so
it is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sticky cell costs the browser work on every scroll, vertical too, and
the plain fallback is not virtualized: with every number pinned, a
24,000-line log scrolled with 50-67 ms frames in headless Chrome (main:
17 ms), and a 7,000-line lockfile did the same at 4x CPU throttling.

Keep the pin where the row count is bounded by SOURCE_HIGHLIGHT_MAX_LINES:
the highlighted Source view (never longer) and a diff that short, through
FILE_PINNED_LINE_NUMBER_STYLE. The plain fallback and longer diffs keep
the unpinned gutter, as before this pull request. The test now checks
the pinned style and that the fallback's rows stay unpinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since the pin moved to its own style, the test checks that style and the
unpinned plain fallback, but no view that keeps the pin: switching the
highlighted Source view back to FILE_LINE_NUMBER_STYLE, or the Diff view's
`<=` to `<`, still passed it, and lint too, as the other view keeps the
pinned style in use. It now builds the highlighted element and the Diff
view with the module's constants: the highlighted Source view and a diff
of SOURCE_HIGHLIGHT_MAX_LINES rows are pinned, a diff one row longer is
not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@agegr

agegr commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Thanks @brynne8! I reproduced the bug on main. After a 300px sideways scroll the line numbers sit at -300px in the highlighted Source view and in the plain fallback past 1,000 lines; in Diff they sit at -297px, because its rows have a 3px change border. With this PR they stay at the left edge of the content.

I pushed three commits on top:

  • 56c6cc9 fix(file-viewer): keep the 13px code font: the smaller font is a separate call from pinning the gutter, and I'd like to keep this PR to one change. This restores 13px and the gutter's matching 20.8px line height; your test checks that the two stay equal, not a size.
  • 6078e06 fix(file-viewer): pin the gutter only in views of up to 1,000 rows: Chrome repositions every sticky number on every scroll, vertical scrolls too, and the plain fallback isn't virtualized. On a production build in headless Chrome, with every number pinned, a 24,000-line log scrolled with 67 ms frames against 17 ms on main, with about 8x the main-thread work; at 4x CPU throttling a 7,000-line lockfile and a 4,000-row diff dropped frames too. So the pin stays where the view has at most 1,000 rows: the highlighted Source view, which never has more, and diffs up to 1,000 rows. Those stayed at 17 ms frames even at 4x. The plain fallback and longer diffs keep the unpinned gutter they had before.
  • 5a8b3c6 test(file-viewer): check the views that keep the pinned gutter: the test now checks that the highlighted Source view and a 1,000-row diff are pinned and a 1,001-row diff is not. Before, unpinning the highlighted view still passed.

Checked on a merge with main: tsc, lint and the full unit suite pass. In headless Chrome, against both a dev and a production build, I checked where the gutter sits after a sideways scroll (light, dark and phone layouts) and the scroll frame times.

@agegr
agegr merged commit c713fca into agegr:main Oct 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants