InodeLabs/fern
1
14
Fork
You've already forked fern
2

Fix View mode scroll lagging #8

Closed
opened 2026年03月26日 01:55:30 +01:00 by bugwhisperer · 0 comments

Summary

View mode rendering has major inefficiencies in how it handles fold regions for headings folding. Work that only needs to happen once per document render is being repeated per-frame or per-fold.

  1. every render frame calls findFoldAtLine once per visible line, and that function does a full linear scan over all folds in the document. This is a direct contributor to the lag users experience when jumping to the top or bottom of a long document with gg or G.
  2. whenever content or pane width changes, matchFoldsToRendered uses a regex-based ANSI stripper on every rendered line for every fold it needs to match. While this does not fire every frame, it fires on every resize and every content change in view mode, contributing to the stutter seen when resizing panes or switching tabs.

Proposed Fix

The fix is to:

  • use a foldByLine lookup map when folds are matched, making per-frame lookups O(1)
  • pre-compute ANSI-stripped lines in renderTab so matchFoldsToRendered has zero regex work to do

Conditions of Satisfaction

  • Add foldByLine map[int]int and strippedLines []string fields to tabShared in viewer.go
    • foldByLine maps a rendered line index to the index of the corresponding fold in the folds slice, enabling O(1) lookup instead of a linear scan
    • strippedLines holds ANSI-stripped, lowercased versions of lines[], computed once in renderTab and reused by matchFoldsToRendered so it never calls the stripANSI regex directly
    • Both fields are owned by tabShared because they are derived from shared render state (lines and folds), not per-pane view state
  • Populate strippedLines in renderTab (render.go) immediately after tab.lines is set
    • Iterate tab.lines once, applying strings.ToLower(stripANSI(l)) to each entry and storing into tab.strippedLines
    • This single O(lines) pass replaces the O(lines x folds) repeated stripping inside matchFoldsToRendered
    • strippedLines is naturally invalidated whenever renderTab runs, since it replaces the slice in the same call that replaces tab.lines
  • Update matchFoldsToRendered in fold.go to consume tab.strippedLines instead of calling stripANSI inline
    • The search loop replaces strings.ToLower(stripANSI(tab.lines[j])) with tab.strippedLines[j], which is a plain slice read
    • At the end of the function, after all lineIdx values are resolved, build the foldByLine map by iterating tab.folds and inserting entries for all folds with a non-negative lineIdx
    • Re-creating the map each call (rather than clearing and repopulating) is sufficient since matchFoldsToRendered only runs when rendered output changes
  • Rewrite findFoldAtLine in fold.go to use the foldByLine map
    • The function signature and return type remain unchanged so no call sites in renderViewContent or toggleFoldAtCursor need updating
    • Guard against a nil map (e.g. on first access before any render) by returning nil immediately, matching the current behavior for a fold-less document
  • Confirm toggleFoldAtCursor remains correct after the change
    • toggleFoldAtCursor calls buildDisplayLines after toggling a fold but does not call matchFoldsToRendered, so foldByLine is not rebuilt on toggle
    • Verify that findFoldAtLine results are still correct after a toggle since lineIdx values in folds[] do not change during a toggle, only the folded flag does -- the map remains valid

External Resources

N/A

## Summary View mode rendering has major inefficiencies in how it handles fold regions for headings folding. Work that only needs to happen once per document render is being repeated per-frame or per-fold. 1. every render frame calls `findFoldAtLine` once per visible line, and that function does a full linear scan over all folds in the document. This is a direct contributor to the lag users experience when jumping to the top or bottom of a long document with `gg` or `G`. 2. whenever content or pane width changes, `matchFoldsToRendered` uses a regex-based ANSI stripper on _every_ rendered line for _every_ fold it needs to match. While this does not fire every frame, it fires on every resize and every content change in view mode, contributing to the stutter seen when resizing panes or switching tabs. ## Proposed Fix The fix is to: - use a `foldByLine` lookup map when folds are matched, making per-frame lookups O(1) - pre-compute ANSI-stripped lines in `renderTab` so `matchFoldsToRendered` has zero regex work to do ## Conditions of Satisfaction - [ ] Add `foldByLine map[int]int` and `strippedLines []string` fields to `tabShared` in `viewer.go` - `foldByLine` maps a rendered line index to the index of the corresponding fold in the `folds` slice, enabling O(1) lookup instead of a linear scan - `strippedLines` holds ANSI-stripped, lowercased versions of `lines[]`, computed once in `renderTab` and reused by `matchFoldsToRendered` so it never calls the `stripANSI` regex directly - Both fields are owned by `tabShared` because they are derived from shared render state (`lines` and `folds`), not per-pane view state - [ ] Populate `strippedLines` in `renderTab` (`render.go`) immediately after `tab.lines` is set - Iterate `tab.lines` once, applying `strings.ToLower(stripANSI(l))` to each entry and storing into `tab.strippedLines` - This single O(lines) pass replaces the O(lines x folds) repeated stripping inside `matchFoldsToRendered` - `strippedLines` is naturally invalidated whenever `renderTab` runs, since it replaces the slice in the same call that replaces `tab.lines` - [ ] Update `matchFoldsToRendered` in `fold.go` to consume `tab.strippedLines` instead of calling `stripANSI` inline - The search loop replaces `strings.ToLower(stripANSI(tab.lines[j]))` with `tab.strippedLines[j]`, which is a plain slice read - At the end of the function, after all `lineIdx` values are resolved, build the `foldByLine` map by iterating `tab.folds` and inserting entries for all folds with a non-negative `lineIdx` - Re-creating the map each call (rather than clearing and repopulating) is sufficient since `matchFoldsToRendered` only runs when rendered output changes - [ ] Rewrite `findFoldAtLine` in `fold.go` to use the `foldByLine` map - The function signature and return type remain unchanged so no call sites in `renderViewContent` or `toggleFoldAtCursor` need updating - Guard against a nil map (e.g. on first access before any render) by returning nil immediately, matching the current behavior for a fold-less document - [ ] Confirm `toggleFoldAtCursor` remains correct after the change - `toggleFoldAtCursor` calls `buildDisplayLines` after toggling a fold but does not call `matchFoldsToRendered`, so `foldByLine` is not rebuilt on toggle - Verify that `findFoldAtLine` results are still correct after a toggle since `lineIdx` values in `folds[]` do not change during a toggle, only the `folded` flag does -- the map remains valid ## External Resources N/A
Sign in to join this conversation.
No Branch/Tag specified
main
v1.5.6
v1.5.5
v1.5.4
v1.5.3
v1.5.2
v1.5.1
v1.5.0
v1.4.1
v1.4.0
v1.3.9
v1.3.8
v1.3.7
v1.3.6
v1.3.5
v1.3.4
v1.3.3
v1.3.2
v1.3.1
v1.3.0
v1.2.1
v1.2.0
v1.1.0-beta
v1.0.3-beta
v1.0.2-beta
v1.0.1-beta
v1.0.0-beta
v0.9.3
v0.9.2
v0.9.1
v0.9.0
v0.8.3
v0.8.2
v0.8.1
v0.8.0
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
InodeLabs/fern#8
Reference in a new issue
InodeLabs/fern
No description provided.
Delete branch "%!s()"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?