fix(diff-view): restore scroll position and fix tab handling on save/deny#589
fix(diff-view): restore scroll position and fix tab handling on save/deny#589awschmeder wants to merge 2 commits into
Conversation
Manual Test Plan: Diff View Scroll Position + Tab/Focus HandlingCovers the fixes in Run this matrix manually in the Extension Development Host. Bugs this plan must catchThese are the concrete defects found and fixed. Each has dedicated coverage below.
Prerequisites
What "correct" looks like
Matrix 1 -- Diff opens on the first change (scroll-to-first-diff)For each combination below, trigger the edit and observe ONLY the diff-open scroll Edit positions to test: line 0 / top, midpoint of the file, end of the file. For the "Already open" rows, first scroll the file to the middle before prompting, so
Pay special attention to 1.2-1.7, 1.11, 1.12 -- these are the regressions where an 1.S -- "Stuck scroll" sequential check (bug 4)
Matrix 2 -- Save (accept) behaviorSet up a known pre-edit scroll position before each run by manually scrolling the file, so
Matrix 3 -- Deny (reject) behavior
Matrix 4 -- Auto-approve late-timer race (bug 5)Enable auto-approve writes for these rows so save runs immediately after the diff
Watch for ~100ms-late movement: the buggy behavior was a brief correct restore followed Matrix 5 -- Preview-tab eviction (bug 6)A preview tab is the italicized, single reusable tab VS Code shows when you open a file
Matrix 6 -- Focus preservation on keep-open (bug 7)
Regression smoke checks
|
📝 WalkthroughWalkthroughDiffViewProvider refactored to preserve VS Code editor context across diff sessions: captures pre-edit scroll position and pin state on open, snapshots unrelated preview tabs, detects user activation, restores scroll/pin state on save/revert, scrolls to first changed line with clamping and guarded deferred reveals, and manages transient tabs without disrupting focus. ChangesDiff View Scroll Position and Tab State Preservation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Videos from Manual Testing: Target file open - scrolled to top - diff scrolls to edit at top, middle, bottom: Target file open - scrolled to middle - diff scrolls to edit and restores viewport after accept: Target file not open - diff scrolls to edit - tab is cleaned up after accept or reject: Stress test multiple sequential writes & scrolling with apply_diff - no race conditions or errors: Preview tab open with file 1, diff targets unopened file 2, after accept, preview tab is restored: Permanent tab open with file 1, diff targets unopened file 2, user changes tab then accepts - no jump back: Pinned file targeted with diff - stays pinned correctly: |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/integrations/editor/DiffViewProvider.ts`:
- Around line 615-633: captureUnrelatedPreviewTabs currently returns only { uri,
scrollLine } so restorePreviewTabs reopens previews in the active group; include
the originating group/viewColumn in the snapshot (e.g., add tab.group.viewColumn
or tab.group.id to the captured object inside captureUnrelatedPreviewTabs) and
update restorePreviewTabs to call vscode.window.showTextDocument(snapshot.uri, {
preview: true, preserveFocus: true, viewColumn: snapshot.viewColumn }) (or open
in the original group via its id) and honor the saved scrollLine after opening;
update types/usages of the snapshot array and any related helper functions to
handle the new viewColumn field.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 82610a67-0e7a-4e94-8022-c666f20722a9
📒 Files selected for processing (3)
.changeset/fix-diff-scroll-position.mdsrc/integrations/editor/DiffViewProvider.tssrc/integrations/editor/__tests__/DiffViewProvider.spec.ts
Related GitHub Issue
Closes: #586
Description
Fixes the diff-view scroll and tab-handling problems described in #586.
All changes are confined to
DiffViewProvider.ts;no tool files needed to change because the default diff path already calls
open()/update()/scrollToFirstDiff()/saveChanges()in the right order.The "how", broken down by the three symptoms in the issue:
Diff opens at the wrong scroll position.
scrollEditorToLine(0)inopen(). It fired after thediff rendered and overrode whatever position should have been shown, leaving
off-screen edits out of view.
scrollToFirstDiff()is refactored into three private helpers:findFirstDiffLine()walksdiff.diffLines()to compute the first-change line andclamps it to a valid index so pure end-of-file removals (where the computed index
lands past
document.lineCount - 1) are still brought into view.resolveLiveEditor()re-resolves the modified-side editor fromvscode.window.visibleTextEditorsat scroll time, replacing any stale/detachedactiveDiffEditorreference whosevisibleRangesno longer reflect the on-screendiff widget. A
revealRangeon a stale reference is a silent no-op; this was theroot cause of "scrolls to top" when the file was already open.
revealDiffLine()anchors the editor selection on the target line before callingrevealRange. The selection nudge is necessary when the target is already insidethe current viewport -- a bare
revealRangeon a visible line is a no-op.vscode.diffcommand re-lays-out the diff widget after a short delay; the deferred reveal ensures
the scroll sticks after that layout pass. It is gated on
activeDiffEditor === editorso a stale timer from a previous edit never acts on a later edit's diff widget.
Viewport jumped to the top after Save/Deny.
open()now capturespreEditScrollLine(the first visible line) BEFORE thetab-closing loop -- after that loop the editor is no longer in
visibleTextEditorsand the capture would always be
undefined.showEditedFileWithoutDisruptingFocus(),which calls
revealRange(..., AtTop)to restore the pre-edit scroll position.cancelDeferredScroll()is called at the start of bothsaveChanges()andrevertChanges()before any programmatic editor activation. With auto-approve writes,saveChanges()runs in the tight window right afterscrollToFirstDiff()'s deferredtimer is set; the cancellation prevents the late reveal from firing after the viewport
is restored and yanking the user back to the diff target.
Tabs left open for files that were not previously open.
saveChanges()previously re-showed the file unconditionally;closeAllDiffViews()only closes diff-scheme tabs, so the plain file tab leaked. The re-show is now gated.
closeFileTab()to close the transiently opened plain file tab when the filewas not open before the edit AND the user did not interact with it.
userTouchedDocumentsignal: while the diff is open for anot-previously-open file, an
onDidChangeActiveTextEditorlistener marks the file as"touched" if the user activates its own (non-diff) editor tab. Touched files are kept
open; untouched ones are closed. The listener is disposed before any programmatic
showTextDocumentso our own re-show never counts as a touch, and is cleared inreset().Additional fixes discovered during implementation:
Focus disruption on keep-open. After Save/Deny, showing the edited file previously
stole the foreground even when the user had navigated to a different file.
showEditedFileWithoutDisruptingFocus()captures the user's active editor before there-show, uses
preserveFocus: true, and re-activates the user's prior editor afterwardwhen they were viewing something else.
Pin state lost. Closing the target file's tab to open the diff dropped VS Code's pin
state.
open()now recordsdocumentWasPinned; after the diff closes the pin isrestored via
workbench.action.pinEditor.Preview-tab eviction. Opening the diff reuses the editor group's single preview slot
and permanently evicts unrelated preview tabs.
open()now snapshots all unrelatedpreview tabs (with their scroll positions) before the diff opens;
restorePreviewTabs()re-opens any that were evicted after the diff closes. Files that no longer exist on disk
are skipped gracefully.
Reviewers should pay attention to: the ordering of
disposeActiveEditorListener()->cancelDeferredScroll()->closeAllDiffViews()->keepOrCloseEditedFile()->restorePreviewTabs()in bothsaveChanges()andrevertChanges(), the clamp logic infindFirstDiffLine()and the second clamp inrevealDiffLine(), and the stale-editorguard in
resolveLiveEditor().Incidental change (discovered during this work): the diff tab label constant
DIFF_VIEW_LABEL_CHANGESstill read "Original <-> Roo's Changes". Since this is auser-visible label in the rebranded fork, it was corrected to "Original <-> Zoo's
Changes". It is a one-line string change referenced only via the constant (tests use the
constant, not the literal), so it is behavior-neutral for tab matching. Called out here for
transparency since it is outside the strict scope of the scroll fix.
Test Procedure
Automated:
44 tests pass, covering:
scrollToFirstDiff(): first-change line for addition-only, deletion-only, mixed, andend-of-file removal (clamped); selection anchor before reveal (in-viewport fix); deferred
re-reveal fires at 100 ms; live editor resolved at reveal time (stale reference fix);
stale timer from a previous edit does not act on a new diff editor; no-op when no editor.
preEditScrollLine: captured fromvisibleRanges[0].start.lineinopen();undefinedwhen no
visibleRanges;saveChanges()andrevertChanges()callrevealRange(AtTop)when set; no
revealRangewhenundefined; deferred scroll cancelled so it cannot fightthe scroll restore (auto-approve race).
userTouchedDocument:saveChanges()andrevertChanges()close the tab when untouched;keep it open when touched; activation listener fires correctly; listener ignored for diff
tab activations.
showEditedFileWithoutDisruptingFocus(): re-activates user's prior editor when theynavigated away; does not re-activate when already on the edited file; re-pins and focuses
correctly; does not pin when not originally pinned.
diff target; restores evicted tabs in preview mode with scroll; skips still-open tabs;
skips files that no longer exist.
Manual (Extension Development Host, F5): unit tests cannot confirm the composite diff
widget visibly scrolls or that focus/tabs behave correctly across real VS Code sessions.
Full manual matrix is documented in a comment attached to this PR.
Summary: for a 100+ line file, verify the diff opens on the first changed line
and the viewport is restored / the tab is closed appropriately after Save and after Deny,
for both already-open and not-open files, across pure-add, pure-delete, and mixed edits,
including diffs at the top, midpoint, and end of the file. Confirm no focus steal, no
preview-tab loss, and no stuck-scroll across sequential edits.
Pre-Submission Checklist
Screenshots / Videos
Attached in a follow-up comments.
Documentation Updates
Additional Notes
The fix targets the DEFAULT (diff-visible) path only. When the
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTIONexperiment is enabled, tools callsaveDirectly()instead, which intentionally avoids any diff view, scroll-to-change, orviewport restoration -- none of the code in this PR runs on that path.
Commentary / Opinion
Automatically closing an un-touched tab after applying an edit or creating a new file could be somewhat confusing to novice coders.
On the other hand, not closing un-touched files after edits creates significant tab pollution in long sessions, leading to dozens of tabs left open.
The unclosed-tab pollution problem also burdens the context window in an uncontrolled manner and drives up inference cost, as Zoo's default includes 20 open tabs (probably set too high, by the way... 1 is a better default).
On balance, I think the best solution is to automatically close files that the user did not either explicitly open or have some affirmative interaction with by viewing / focusing on or editing.