diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index b36940287c2c7..82fcd5fcbca3a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -1,7 +1,7 @@ import { DEFAULT_THEMES, preloadHighlighter } from "@pierre/diffs"; import { EnvironmentId, ThreadId, type ProjectReadFileResult } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import type { ReactNode } from "react"; +import { StrictMode, type ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; import { act, fakeTimers, render } from "../../../test/render"; @@ -268,6 +268,41 @@ describe("source file focus", () => { }); }); +describe("source file scroll", () => { + it("returns a file tab to where it was scrolled after another file was shown", async () => { + using _settings = setup(); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + using _style = { [Symbol.dispose]: () => style.remove() }; + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); + const sourceFile = (relativePath: string, lines: number) => { + const contents = Array.from({ length: lines }, (_, i) => `const line${i} = ${i};`).join("\n"); + state.file = { relativePath, contents, byteLength: contents.length, truncated: false }; + // The app mounts under StrictMode, whose remount must not wipe the saved offset. + return ( + + + {filePreview(relativePath)} + + + ); + }; + const scroller = () => document.querySelector(".file-preview-virtualizer"); + + await using screen = await render(sourceFile("a.ts", 2000)); + await vi.waitFor(() => expect(scroller()?.scrollHeight).toBeGreaterThan(5000)); + scroller()!.scrollTop = 4200; + await vi.waitFor(() => expect(scroller()?.scrollTop).toBe(4200)); + + await screen.rerender(sourceFile("b.ts", 1500)); + await vi.waitFor(() => expect(scroller()?.scrollTop).toBe(0)); + await screen.rerender(sourceFile("a.ts", 2000)); + + await vi.waitFor(() => expect(scroller()?.scrollTop).toBe(4200)); + }); +}); + describe("rendered Markdown task saves", () => { it("keeps a truncated read read-only without queuing a replacement", async () => { using _settings = setup(); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index f21183011f99c..57b93a76353d2 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -503,6 +503,10 @@ interface FileRevealState { cancelGuard: (() => void) | null; handledRequestId: number | null; latestRequestId: number | null; + /** The path's code view offset when it last unmounted, restored when it is shown again. */ + scrollTop: number; + scrollContainer: HTMLElement | null; + restoreFrameId: number | null; } function useFileLineReveal( @@ -521,6 +525,9 @@ function useFileLineReveal( cancelGuard: null, handledRequestId: null, latestRequestId: null, + scrollTop: 0, + scrollContainer: null, + restoreFrameId: null, }; if (!existingState) revealStatesByPath.set(relativePath, state); @@ -530,10 +537,20 @@ function useFileLineReveal( state.frameId = null; } state.cancelGuard?.(); + if (state.restoreFrameId !== null) { + cancelAnimationFrame(state.restoreFrameId); + state.restoreFrameId = null; + } }; + const scrollContainer = fileContainer.closest(".file-preview-virtualizer"); + if (phase === "unmount") { + // A view torn down before its restore ran never showed the saved offset, so keep it. + const restorePending = state.restoreFrameId !== null; cancelPendingReveal(); + if (scrollContainer && !restorePending) state.scrollTop = scrollContainer.scrollTop; + state.scrollContainer = null; return; } @@ -550,12 +567,37 @@ function useFileLineReveal( state.handledRequestId = null; } + if (scrollContainer && state.scrollContainer !== scrollContainer) { + state.scrollContainer = scrollContainer; + const savedScrollTop = state.scrollTop; + // A pending line reveal owns the scroll position. + if ( + savedScrollTop > 0 && + (revealLine === null || state.handledRequestId === revealRequestId) + ) { + const restoreScroll = (attempt: number) => { + state.restoreFrameId = requestAnimationFrame(() => { + state.restoreFrameId = null; + if (!scrollContainer.isConnected || scrollContainer.scrollTop !== 0) return; + // Wait for the virtualizer to size the file so the offset is not clamped. + const fits = + scrollContainer.scrollHeight - scrollContainer.clientHeight >= savedScrollTop; + if (fits || attempt >= REVEAL_MAX_ATTEMPTS) { + scrollContainer.scrollTop = savedScrollTop; + return; + } + restoreScroll(attempt + 1); + }); + }; + restoreScroll(0); + } + } + if (revealLine === null) { fileContainer.style.minHeight = ""; return; } - const scrollContainer = fileContainer.closest(".file-preview-virtualizer"); if (!scrollContainer) return; fileContainer.style.minHeight = `${Math.ceil( Math.max(instance.height, scrollContainer.clientHeight),