Skip to content

perf: streaming highlight + perceived-latency fixes for large diffs - #288

Open
ptoussai wants to merge 4 commits into
agavra:mainfrom
ptoussai:perf/scroll-and-priority-followups
Open

perf: streaming highlight + perceived-latency fixes for large diffs#288
ptoussai wants to merge 4 commits into
agavra:mainfrom
ptoussai:perf/scroll-and-priority-followups

Conversation

@ptoussai

@ptoussai ptoussai commented May 13, 2026

Copy link
Copy Markdown
Contributor

Four perf/UX fixes that surfaced together while reviewing a 565-file commit (~47k lines, mostly JS/Vue/TS) in a large private repo. tuicr was slow to load, sluggish when switching files, and scrolling was effectively unresponsive (had to kill the process).

Each commit handles one concern. Per-commit messages have the details; quick recap:

  • perf(syntax): stream syntax highlighting on a background worker — parse emits jobs, a background worker highlights them in parallel and streams spans via mpsc; the main loop patches diff_files between frames. File list + first diff structure appear after parse alone instead of parse + full sync highlight. Worker spawns lazily on the first drain so App::new's post-install_diff reorderings (commit-message insert, directory sort) settle before jobs are resolved against diff_files by syntax_path.

  • perf(syntax): prioritize highlight jobs around the focused file — pending jobs live in an Arc<Mutex<VecDeque>>. When the user jumps files, the App reorders the remaining queue by file-index distance so visible work runs next instead of grinding files near index 0 that aren't on screen.

  • perf(input): drain queued events before each redraw — the loop used to read one event then redraw; on a heavy diff, holding j or wheel-scrolling queued 200-600ms of input lag, worst case on direction reversals. Now drains via event::poll(Duration::ZERO) after the blocking poll (64-event cap) then redraws once. Atomic-action keys (Ctrl+C, ZQ, dd, ;X) keep their per-event continue 'main shortcut.

  • fix(ui): preserve manual file-list scroll when diff panel is focused — wheel-scrolling the file list was snapping back because the render-time "sync selection to diff's current file" path re-selected each frame and ratatui's List auto-adjusted offset to keep selection visible. Track a manual_file_list_scroll flag, set on wheel and cleared when current_file_idx changes for any other reason. Pre-existing; visible only once the rest of the loop is fast enough.

ptoussai added 4 commits May 13, 2026 12:38
The parse phase now emits highlight jobs instead of highlighting inline.
A background worker drains them in parallel and streams results back to
the main thread via an mpsc channel; the main loop patches spans into
diff_files in place between frames.

Effect: the file list and diff structure appear after the parse cost
alone, instead of after parse + full synchronous highlight. Total work
is unchanged (slightly more, from thread + channel overhead) but the
visible-frame wait shrinks on big diffs.

Notes worth recording:

* git2 / hg / jj state is mostly !Send. Container-grammar full-file
  content is fetched on the main thread during parse and embedded into
  jobs as owned Strings; the worker never touches VCS state.
* Workers use thread::scope + an atomic-cursor pulled from a shared
  Vec<HighlightJob>. Beats static chunking when one giant file sits
  alongside many small ones.
* Per-hunk jobs and full-file (Vue/Svelte/...) jobs share a single
  HighlightJob enum and a single dispatch path.
* HighlightSession bundles the receiver and a cancel AtomicBool. On
  diff swap (commit reselection, :e reload), the old session is
  dropped and its cancel flag set; the worker exits at its next
  channel send or cancel check.
* Adaptive event-poll cadence: 30ms while a session is live so
  streamed spans land within a frame, 100ms idle to keep wakeups low.

Tests use a synchronous streaming::run_blocking helper so spans are
present deterministically; production code path is the worker.
Pending jobs now live in an Arc<Mutex<VecDeque<HighlightJob>>> instead
of an Arc<Vec> with an atomic cursor. The worker still pops jobs in
queue order, but the App can reorder the remaining queue by file-index
distance whenever the user moves between files.

Why: when the user clicks the last file in a 565-file commit, the
worker is still grinding through files near index 0 that aren't on
screen. The visible diff sits unhighlighted for several seconds while
the worker walks the queue. Sorting pending jobs by abs_diff from the
current file_idx puts visible work first.

Called from:
* jump_to_file (file list click, } / { navigation)
* update_current_file_from_cursor (cursor / scroll moved into a new file)

The sort is O(n log n) on the remaining queue, run under a mutex held
only long enough to reorder. Workers contend on the same mutex per
pop, but the lock is held for microseconds and navigation events are
human-paced, so contention is negligible in practice.
The main loop used to read one event and redraw, read one event and
redraw. On a big diff where a single render takes ~10 ms, holding j
or wheel-scrolling fast queues events faster than the loop can consume
them, and the backlog shows up as 200-600 ms of input lag - the worst
case being a direction reversal, where the user has to wait for the
Up backlog to drain before the first Down is even read.

Now after the blocking event::poll(poll_timeout), the loop keeps
pulling events with event::poll(Duration::ZERO) until the queue is
empty (or a 64-event cap is hit, in case of a paste or stuck key),
then redraws once. The cap is a safety; in practice almost all bursts
drain in well under 64 events.

The existing 'continue' shortcuts inside the match (Ctrl+C, ZQ, dd,
;X, pending state setters) now use 'continue 'main' so they short-
circuit straight to the outer loop's render path. Those keys aren't
the high-throughput case batching is meant to help with, and keeping
them per-event means each one renders before the next is read - which
is what the user expects for atomic-action keys.
When the user wheel-scrolls the file list while the diff panel has
focus, the render-time 'sync selection to diff's current file' path
selects the diff's file, and ratatui's List widget then auto-adjusts
the offset on render to keep that selection visible - undoing the
wheel. Net effect: the file list stays glued to whichever file the
diff cursor is on, even when the user is trying to scroll past it.

Track an 'I have manually scrolled the file list' flag, set by
file_list_viewport_scroll_up/down. While the flag is set, skip the
render-time sync entirely, so the user's offset stays put. The flag
is cleared whenever the diff's current_file_idx changes for a reason
other than file-list wheel (jump_to_file, cursor scrolling into a
different file via update_current_file_from_cursor), which is the
natural moment to resume tracking.

Pre-existing bug, surfaced now that the rest of the loop is fast
enough that wheel-scrolling the file list is actually responsive
enough to expose the snap-back.
@ptoussai
ptoussai marked this pull request as ready for review May 13, 2026 11:41
@agavra

agavra commented May 13, 2026

Copy link
Copy Markdown
Owner

@ptoussai I'm not sure I want to add concurrency to tuicr in order to serve use cases with 47k line PR reviews. I'm highly suspect anyone is reviewing a PR that large by hand anyway, and this makes the potential for bugs significant.

In my experience, loading even 10-20k line rust change is pretty speedy. Maybe because we don't need the full file highlighter the way vue does after the previous change.

I'd rather just default to collapsing large file diffs the way github does at some point.

@ptoussai

Copy link
Copy Markdown
Contributor Author

The change in question did not have any vue files actually.
I did review this PR and yes, it’s unusual, but I think it’s still reasonable for the tool to not hang on it.
I see your point about the concurrency your comment on my last PR seemed to indicate that you wanted to go that way? Cf #280 (review)
This PR does this and other fixes.
There are alternatives ways (like lazy/smart loading) if you think concurrency would introduce too much problems down the line.

@ptoussai

Copy link
Copy Markdown
Contributor Author

Btw, I’m only using this change as an easy way to test the perf myself, as it’s not easily tested in a headless way. A worst case scenario for sure, but also indicative that smaller changes will have less than ideal performance

@agavra

agavra commented May 13, 2026

Copy link
Copy Markdown
Owner

I did review this PR and yes, it’s unusual, but I think it’s still reasonable for the tool to not hang on it.

I agree, which is why I suggested things like collapsing large file diffs by default (like GitHub behavior).

I'm curious if were you running it in release mode or debug mode? It makes a big difference when you run with --release. I haven't had any experience, even with very large PRs, of the tool hanging. Though again, I don't have code that requires the "full file context" so I'm guessing that was part of the perf issue.

I see your point about the concurrency your comment on my last PR seemed to indicate that you wanted to go that way?

I admit I didn't fully think that through, what got me thinking about that was testing this PR locally and noticing it "flash" as things were being processed. So the initial load looked different than the final load.

There are alternatives ways (like lazy/smart loading) if you think concurrency would introduce too much problems down the line.

I'd rather start with the simplest things just to make sure the tool doesn't crash before we get carried away with fancy perf optimizations. Not sure lazy/smart loading is any better than concurrency.

I think if we show that there really is a need for faster loading then I'm ok with having a concurrent highlighting job but I'd rather it be blocking on render instead of streaming results back.

@ptoussai

Copy link
Copy Markdown
Contributor Author

That seems reasonable. The perf issue was not due to debug mode or container (vue etc) files but maybe something else is going on with that change.
The 2 other fixes also really help so I could start with this in place.
I will try to rework it next week.

@agavra

agavra commented May 14, 2026

Copy link
Copy Markdown
Owner

@ptoussai looks like for #125 I'm going to need to add a bunch of async loading stuff anyway, so I'm more open to adding concurrent loading for the main path as well. thanks for your patience with my back and forth here! I'll ping you back when that line of work is done since it's really invasive

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