Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2484418d7 | |||
| c1ed4f54a9 | |||
| e89bba83a4 | |||
| a02892e951 |
@@ -0,0 +1,165 @@
|
||||
"""E2E: the Files panel keeps its scroll position across session switches.
|
||||
|
||||
The panel's scroll container clamps to the top while a newly-selected
|
||||
session's file queries load; ``FilesPanel`` caches ``scrollTop`` per
|
||||
conversation and restores it once the content is tall enough again. This
|
||||
drives the real flow — scroll in session A, switch to session B via the
|
||||
sidebar (client-side navigation, NOT ``page.goto``: a reload would reset
|
||||
the module-level cache and dissolve the scenario), switch back — and
|
||||
asserts the position survives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Enough files that the tree overflows the rail and can scroll well past 0.
|
||||
_FILE_COUNT = 80
|
||||
|
||||
|
||||
def _seed_file(base_url: str, session_id: str, path: str) -> None:
|
||||
resp = httpx.put(
|
||||
f"{base_url}/v1/sessions/{session_id}/resources/environments/default/filesystem/{path}",
|
||||
json={"content": f"contents of {path}\n", "encoding": "utf-8"},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_scroll_sessions(
|
||||
seeded_session_pair: tuple[str, str, str],
|
||||
) -> Iterator[tuple[str, str, str]]:
|
||||
base_url, session_a, session_b = seeded_session_pair
|
||||
for i in range(_FILE_COUNT):
|
||||
_seed_file(base_url, session_a, f"scroll_file_{i:03d}.txt")
|
||||
_seed_file(base_url, session_b, "other_session_file.txt")
|
||||
try:
|
||||
yield (base_url, session_a, session_b)
|
||||
finally:
|
||||
for session_id in (session_a, session_b):
|
||||
shutil.rmtree(_REPO_ROOT / session_id, ignore_errors=True)
|
||||
|
||||
|
||||
def test_files_panel_scroll_survives_session_switch(
|
||||
page: Page,
|
||||
seeded_scroll_sessions: tuple[str, str, str],
|
||||
) -> None:
|
||||
"""Scroll in A, switch to B and back via the sidebar: position restored."""
|
||||
base_url, session_a, session_b = seeded_scroll_sessions
|
||||
page.goto(f"{base_url}/c/{session_a}?view=explore")
|
||||
|
||||
rail = page.get_by_role("complementary", name="Workspace")
|
||||
expect(rail.get_by_text("scroll_file_000.txt")).to_be_visible(timeout=30_000)
|
||||
# Both sessions must be in the sidebar for the SPA switch.
|
||||
expect(page.locator(f'a[href="/c/{session_b}"]')).to_be_visible(timeout=30_000)
|
||||
|
||||
section = rail.locator("section")
|
||||
# Scroll partway down; a real scroll event fires and the panel saves it.
|
||||
section.evaluate("el => { el.scrollTop = 300; }")
|
||||
expect(section).to_have_js_property("scrollTop", 300)
|
||||
|
||||
# Switch to session B in the SPA and let its (near-empty) panel render.
|
||||
page.locator(f'a[href="/c/{session_b}"]').click()
|
||||
expect(page).to_have_url(f"{base_url}/c/{session_b}", timeout=15_000)
|
||||
expect(rail.get_by_text("other_session_file.txt")).to_be_visible(timeout=30_000)
|
||||
|
||||
# Back to session A: once its tree is tall again, the position returns.
|
||||
page.locator(f'a[href="/c/{session_a}"]').click()
|
||||
expect(page).to_have_url(f"{base_url}/c/{session_a}", timeout=15_000)
|
||||
expect(rail.get_by_text("scroll_file_000.txt")).to_be_visible(timeout=30_000)
|
||||
expect(section).to_have_js_property("scrollTop", 300, timeout=10_000)
|
||||
|
||||
|
||||
# Climb from the rendered markdown editor to whichever ancestor actually
|
||||
# scrolls (the ref'd container in MarkdownRichTextViewer), then set / read
|
||||
# its scrollTop. Re-run after a session round-trip: the viewer remounts, so
|
||||
# the element must be re-found each time.
|
||||
_FIND_EDITOR_SCROLLER_JS = """
|
||||
const findScroller = () => {
|
||||
for (const pm of document.querySelectorAll('.ProseMirror')) {
|
||||
let el = pm;
|
||||
while (el && el.scrollHeight <= el.clientHeight + 1) el = el.parentElement;
|
||||
if (el && el !== document.documentElement && el.clientHeight > 0) return el;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
"""
|
||||
|
||||
_SET_EDITOR_SCROLL_JS = f"""
|
||||
(target) => {{
|
||||
{_FIND_EDITOR_SCROLLER_JS}
|
||||
const el = findScroller();
|
||||
if (!el) return null;
|
||||
el.scrollTop = target;
|
||||
return el.scrollTop;
|
||||
}}
|
||||
"""
|
||||
|
||||
_GET_EDITOR_SCROLL_JS = f"""
|
||||
() => {{
|
||||
{_FIND_EDITOR_SCROLLER_JS}
|
||||
const el = findScroller();
|
||||
return el ? el.scrollTop : null;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def test_open_file_scroll_survives_session_switch(
|
||||
page: Page,
|
||||
seeded_scroll_sessions: tuple[str, str, str],
|
||||
) -> None:
|
||||
"""A file open in the viewer reopens at its saved scroll offset.
|
||||
|
||||
The app persists which file is open per session, so switching back
|
||||
re-opens the viewer — historically at the top. Uses a long markdown
|
||||
file (default view mode is the rich-text editor).
|
||||
"""
|
||||
base_url, session_a, session_b = seeded_scroll_sessions
|
||||
long_md = "".join(
|
||||
f"## Section {i}\n\nSome paragraph text for section {i}.\n\n" for i in range(120)
|
||||
)
|
||||
resp = httpx.put(
|
||||
f"{base_url}/v1/sessions/{session_a}/resources/environments/default/filesystem/long_doc.md",
|
||||
json={"content": long_md, "encoding": "utf-8"},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
page.goto(f"{base_url}/c/{session_a}?view=explore")
|
||||
rail = page.get_by_role("complementary", name="Workspace")
|
||||
# `.first`: the file renders both as a tree row and in the changed strip.
|
||||
row = rail.get_by_role("button", name=re.compile("long_doc.md")).first
|
||||
expect(row).to_be_visible(timeout=30_000)
|
||||
row.click()
|
||||
expect(page.locator(".ProseMirror").first).to_be_visible(timeout=30_000)
|
||||
|
||||
# Scroll partway into the document.
|
||||
assert page.evaluate(_SET_EDITOR_SCROLL_JS, 250) == 250
|
||||
|
||||
# Round-trip through session B via the sidebar (client-side navigation).
|
||||
page.locator(f'a[href="/c/{session_b}"]').click()
|
||||
expect(page).to_have_url(f"{base_url}/c/{session_b}", timeout=15_000)
|
||||
expect(rail.get_by_text("other_session_file.txt")).to_be_visible(timeout=30_000)
|
||||
|
||||
page.locator(f'a[href="/c/{session_a}"]').click()
|
||||
expect(page).to_have_url(f"{base_url}/c/{session_a}", timeout=15_000)
|
||||
# The viewer re-opens the remembered file…
|
||||
expect(page.locator(".ProseMirror").first).to_be_visible(timeout=30_000)
|
||||
# …and returns to the saved offset once the content is tall enough.
|
||||
restored = None
|
||||
for _ in range(50):
|
||||
restored = page.evaluate(_GET_EDITOR_SCROLL_JS)
|
||||
if restored == 250:
|
||||
break
|
||||
page.wait_for_timeout(200)
|
||||
assert restored == 250
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
type UIEvent,
|
||||
} from "react";
|
||||
import {
|
||||
AtSignIcon,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
lineOverlapsSelection,
|
||||
} from "./codeViewerHelpers";
|
||||
import { NotebookPreview } from "./NotebookPreview";
|
||||
import { useScrollRestore } from "./useScrollRestore";
|
||||
import { PreviewSearchBar } from "./PreviewSearchBar";
|
||||
import { renderLineTokens } from "./codeViewerRendering";
|
||||
import { HtmlCommentViewer } from "./HtmlCommentViewer";
|
||||
@@ -155,14 +157,17 @@ const MARKDOWN_COMPONENTS: Components = {
|
||||
function MarkdownPreview({
|
||||
content,
|
||||
rootRef,
|
||||
onScroll,
|
||||
}: {
|
||||
content: string;
|
||||
rootRef?: RefObject<HTMLDivElement | null>;
|
||||
onScroll?: (event: UIEvent<HTMLElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-preview-scroll
|
||||
onScroll={onScroll}
|
||||
className="markdown-preview px-6 py-4 overflow-auto h-full prose dark:prose-invert prose-sm max-w-none"
|
||||
>
|
||||
<ReactMarkdown
|
||||
@@ -187,6 +192,8 @@ function PreviewWithSearch({
|
||||
searchOpen,
|
||||
onSearchHandled,
|
||||
searchInputRef,
|
||||
scrollKey,
|
||||
scrollReady,
|
||||
}: {
|
||||
content: string;
|
||||
isNotebook: boolean;
|
||||
@@ -194,8 +201,15 @@ function PreviewWithSearch({
|
||||
searchOpen: boolean;
|
||||
onSearchHandled: () => void;
|
||||
searchInputRef: RefObject<HTMLInputElement | null>;
|
||||
/** Persist/restore the preview's scroll position under this cache key. */
|
||||
scrollKey: string | null;
|
||||
/** True once the file content backing the preview is present. */
|
||||
scrollReady: boolean;
|
||||
}) {
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
// The preview div (not the FileViewer content area) is the real scroller
|
||||
// here, so scroll persistence attaches to it directly.
|
||||
const handleScroll = useScrollRestore(previewRef, scrollKey, scrollReady);
|
||||
const bar = (
|
||||
<PreviewSearchBar
|
||||
containerRef={previewRef}
|
||||
@@ -206,9 +220,9 @@ function PreviewWithSearch({
|
||||
/>
|
||||
);
|
||||
const preview = isNotebook ? (
|
||||
<NotebookPreview content={content} rootRef={previewRef} />
|
||||
<NotebookPreview content={content} rootRef={previewRef} onScroll={handleScroll} />
|
||||
) : (
|
||||
<MarkdownPreview content={content} rootRef={previewRef} />
|
||||
<MarkdownPreview content={content} rootRef={previewRef} onScroll={handleScroll} />
|
||||
);
|
||||
// The find bar sits above the preview; a truncated preview also shows the
|
||||
// banner. The bar renders nothing when closed, so layout is unchanged then.
|
||||
@@ -720,6 +734,8 @@ export function CodeViewer({
|
||||
searchOpen={searchOpen}
|
||||
onSearchHandled={handleSearchHandled}
|
||||
searchInputRef={searchInputRef}
|
||||
scrollKey={conversationId && path ? `viewer-preview:${conversationId}:${path}` : null}
|
||||
scrollReady={fileQuery.data !== undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
openHtmlArtifactInNewTab,
|
||||
} from "./codeViewerHelpers";
|
||||
import { CommentsPanel, type ActiveSelection } from "./CommentsPanel";
|
||||
import { useScrollRestore } from "./useScrollRestore";
|
||||
import { isPdfAnchor } from "./pdfCommentHelpers";
|
||||
|
||||
// Monaco diff is heavy (~MBs + worker); load it only when the diff view is
|
||||
@@ -715,6 +716,18 @@ function FileViewerBody({
|
||||
const viewMode: "editor" | "preview" | "source" | "diff" =
|
||||
diffActive && isDiffAvailable ? "diff" : fileViewMode;
|
||||
const diffViewActive = viewMode === "diff";
|
||||
// Persist where the reader was in the content area (markdown source, plain
|
||||
// text). The view mode is part of the key because each mode renders a
|
||||
// different height, so sharing one offset across modes would drop the reader
|
||||
// at an unrelated place after a toggle; the namespace is separate from the
|
||||
// `viewer:` keys Monaco writes for its own internal scroller.
|
||||
const contentScrollKey =
|
||||
conversationId && path ? `viewer-content:${conversationId}:${viewMode}:${path}` : null;
|
||||
const handleContentScroll = useScrollRestore(
|
||||
contentAreaRef,
|
||||
contentScrollKey,
|
||||
fileQuery.data !== undefined,
|
||||
);
|
||||
// Measure the content area so the split toggle can hide when there isn't
|
||||
// enough room for side-by-side. Only observe while the diff is shown — the
|
||||
// ref element only exists then, and it's the only mode that cares.
|
||||
@@ -1328,7 +1341,11 @@ function FileViewerBody({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 flex flex-col md:flex-row overflow-hidden">
|
||||
<div ref={contentAreaRef} className="flex-1 overflow-y-auto min-w-0">
|
||||
<div
|
||||
ref={contentAreaRef}
|
||||
onScroll={handleContentScroll}
|
||||
className="flex-1 overflow-y-auto min-w-0"
|
||||
>
|
||||
{isDeletedFile && viewMode !== "diff" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-8 text-sm text-muted-foreground">
|
||||
<Trash2Icon className="size-5 opacity-40" />
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { FilesPanel } from "./FilesPanel";
|
||||
import { FilesPanelDrawer } from "./FilesPanelDrawer";
|
||||
import { FolderTree } from "./FolderTree";
|
||||
import { SCROLL_RESTORE_BUDGET_MS } from "./useScrollRestore";
|
||||
|
||||
vi.mock("@/hooks/useWorkspaceChangedFiles", () => ({
|
||||
useWorkspaceAllFiles: vi.fn(),
|
||||
@@ -1243,3 +1244,146 @@ describe("FilesPanel sort control", () => {
|
||||
expect(screen.getByRole("button", { name: /^Sort:/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesPanel scroll position persistence", () => {
|
||||
function renderAndGetScrollSection(conversationId: string, files: WorkspaceFile[]) {
|
||||
const result = renderPanel({ conversationId, files });
|
||||
const section = result.container.querySelector("section");
|
||||
if (!section) throw new Error("scroll section not found");
|
||||
return { result, section };
|
||||
}
|
||||
|
||||
it("restores the saved scroll position when returning to a conversation", () => {
|
||||
const files = Array.from({ length: 50 }, (_, i) => file(`file-${i}.ts`));
|
||||
|
||||
// Scroll in conversation A, then leave it.
|
||||
const a = renderAndGetScrollSection("conv_scroll_a", files);
|
||||
a.section.scrollTop = 120;
|
||||
fireEvent.scroll(a.section);
|
||||
a.result.unmount();
|
||||
|
||||
// Conversation B starts at the top, unaffected by A's position.
|
||||
const b = renderAndGetScrollSection("conv_scroll_b", files);
|
||||
expect(b.section.scrollTop).toBe(0);
|
||||
b.result.unmount();
|
||||
|
||||
// Returning to A restores its saved position.
|
||||
const back = renderAndGetScrollSection("conv_scroll_a", files);
|
||||
expect(back.section.scrollTop).toBe(120);
|
||||
});
|
||||
|
||||
it("does not let the loading clamp overwrite the saved position", async () => {
|
||||
const files = Array.from({ length: 50 }, (_, i) => file(`file-${i}.ts`));
|
||||
const conversationId = "conv_scroll_clamp";
|
||||
|
||||
// Scroll in the conversation, then leave it.
|
||||
const first = renderAndGetScrollSection(conversationId, files);
|
||||
first.section.scrollTop = 120;
|
||||
fireEvent.scroll(first.section);
|
||||
first.result.unmount();
|
||||
|
||||
// Revisit while the queries are still disabled (environment pending):
|
||||
// data is undefined — not "loading" — and the short placeholder content
|
||||
// clamps scrollTop to 0, which fires a scroll event.
|
||||
const pending = {
|
||||
data: undefined,
|
||||
error: null,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
};
|
||||
useAllFilesMock.mockReturnValue(pending as unknown as ReturnType<typeof useWorkspaceAllFiles>);
|
||||
useChangedFilesMock.mockReturnValue(
|
||||
pending as unknown as ReturnType<typeof useWorkspaceChangedFiles>,
|
||||
);
|
||||
useDirectoryMock.mockReturnValue(directoryResult());
|
||||
useEnvironmentMock.mockReturnValue(environmentResult(null));
|
||||
useSearchMock.mockReturnValue(searchResult());
|
||||
// Fresh JSX per render — reusing the same element would let React bail
|
||||
// out of the re-render without re-reading the updated hook mocks.
|
||||
const panel = () => (
|
||||
<MemoryRouter initialEntries={[`/c/${conversationId}`]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/c/:conversationId"
|
||||
element={
|
||||
<FilesPanel
|
||||
sort="recent"
|
||||
onSortChange={vi.fn()}
|
||||
flatView={false}
|
||||
onFileSelect={vi.fn()}
|
||||
onFlatViewChange={vi.fn()}
|
||||
showHidden={false}
|
||||
onShowHiddenChange={vi.fn()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const view = render(panel());
|
||||
const section = view.container.querySelector("section");
|
||||
if (!section) throw new Error("scroll section not found");
|
||||
section.scrollTop = 0;
|
||||
fireEvent.scroll(section);
|
||||
|
||||
// Files arrive: the saved position survives the clamp and is restored.
|
||||
useAllFilesMock.mockReturnValue(allFilesResult(files));
|
||||
useChangedFilesMock.mockReturnValue(changedFilesResult([]));
|
||||
view.rerender(panel());
|
||||
expect(section.scrollTop).toBe(120);
|
||||
|
||||
// Let the restore's animation-frame loop settle (jsdom has no layout, so
|
||||
// the target is never "reachable" — the loop runs until its time budget
|
||||
// expires), then user scrolls are saved again.
|
||||
const expired = performance.now() + SCROLL_RESTORE_BUDGET_MS + 1;
|
||||
vi.spyOn(performance, "now").mockReturnValue(expired);
|
||||
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
|
||||
vi.mocked(performance.now).mockRestore();
|
||||
section.scrollTop = 40;
|
||||
fireEvent.scroll(section);
|
||||
view.unmount();
|
||||
const back = renderAndGetScrollSection(conversationId, files);
|
||||
expect(back.section.scrollTop).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FolderTree expanded state across conversation switches", () => {
|
||||
function renderTree(conversationId: string, files: WorkspaceFile[]) {
|
||||
useDirectoryMock.mockReturnValue(directoryResult());
|
||||
const tree = (id: string) => (
|
||||
<TooltipProvider>
|
||||
<FolderTree
|
||||
files={files}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
error={null}
|
||||
onFileSelect={vi.fn()}
|
||||
conversationId={id}
|
||||
showHidden={false}
|
||||
changedFiles={[]}
|
||||
sort="alpha"
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
const view = render(tree(conversationId));
|
||||
return { view, tree };
|
||||
}
|
||||
|
||||
it("re-syncs expanded folders when switching conversations without remounting", () => {
|
||||
const files = [file("src/App.tsx"), file("README.md")];
|
||||
const { view, tree } = renderTree("conv_tree_resync_a", files);
|
||||
|
||||
// Collapse src/ in conversation A (expanded by default).
|
||||
expect(screen.getByText("App.tsx")).toBeDefined();
|
||||
fireEvent.click(screen.getByRole("button", { name: /src\// }));
|
||||
expect(screen.queryByText("App.tsx")).toBeNull();
|
||||
|
||||
// Switch to conversation B in place: defaults apply, src/ is expanded.
|
||||
view.rerender(tree("conv_tree_resync_b"));
|
||||
expect(screen.getByText("App.tsx")).toBeDefined();
|
||||
|
||||
// Switch back to A in place: its collapsed state is restored.
|
||||
view.rerender(tree("conv_tree_resync_a"));
|
||||
expect(screen.queryByText("App.tsx")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SlidersHorizontalIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "@/lib/routing";
|
||||
import { useSessionHostOnline, useSessionRunnerOnline } from "@/hooks/RunnerHealthProvider";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { type ChangedSort, FlatFileList } from "./FlatFileList";
|
||||
import { FolderTree } from "./FolderTree";
|
||||
import { useScrollRestore } from "./useScrollRestore";
|
||||
|
||||
interface FilesPanelProps {
|
||||
onFileSelect: (path: string) => void;
|
||||
@@ -360,6 +361,18 @@ export function FilesPanel({
|
||||
// Highlight the filters toggle when include/exclude carry a value.
|
||||
const treeFiltersActive = treeInclude.trim().length > 0 || treeExclude.trim().length > 0;
|
||||
|
||||
// Persist/restore the list's scroll position across conversation and view
|
||||
// switches. Keyed per conversation + view (Changed vs All) since the two
|
||||
// lists have independent heights. Readiness is data presence rather than
|
||||
// `isLoading` — the files queries are disabled (not loading) until the
|
||||
// environment query resolves.
|
||||
const scrollRef = useRef<HTMLElement>(null);
|
||||
const scrollKey = conversationId
|
||||
? `files:${conversationId}:${flatView ? "changed" : "all"}`
|
||||
: null;
|
||||
const dataReady = flatView ? changedQuery.data !== undefined : allFilesQuery.data !== undefined;
|
||||
const handleScroll = useScrollRestore(scrollRef, scrollKey, dataReady);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -492,11 +505,13 @@ export function FilesPanel({
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"overflow-y-auto px-2 pb-2",
|
||||
flatView ? "pt-1" : "pt-2",
|
||||
fillHeight ? "min-h-0 flex-1" : "max-h-72",
|
||||
)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{flatView ? (
|
||||
<FlatFileList
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronRightIcon, FileIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
RunnerOfflineError,
|
||||
type WorkspaceChangedFile,
|
||||
@@ -244,10 +244,21 @@ export function FolderTree({
|
||||
});
|
||||
|
||||
// When files arrive for the first time (async load) and no cache entry
|
||||
// exists yet, compute and persist the default open set.
|
||||
useEffect(() => {
|
||||
// exists yet, compute and persist the default open set. Also re-sync from
|
||||
// the cache when the panel switches conversations without remounting —
|
||||
// otherwise the tree keeps the previous conversation's expanded set. A
|
||||
// layout effect so the switch resolves before paint (no collapsed flash).
|
||||
const expandedForRef = useRef(conversationId);
|
||||
useLayoutEffect(() => {
|
||||
if (!conversationId) return;
|
||||
if (!files || expandedPathsCache.has(conversationId)) return;
|
||||
const switched = expandedForRef.current !== conversationId;
|
||||
expandedForRef.current = conversationId;
|
||||
const cached = expandedPathsCache.get(conversationId);
|
||||
if (cached) {
|
||||
if (switched) setExpandedPaths(new Set(cached));
|
||||
return;
|
||||
}
|
||||
if (!files) return;
|
||||
const initial = defaultExpandedPaths(files);
|
||||
expandedPathsCache.set(conversationId, initial);
|
||||
setExpandedPaths(new Set(initial));
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ToolbarPlugin } from "./MarkdownEditorToolbar";
|
||||
import { TableHandles } from "./TableBubbleMenu";
|
||||
import { TruncatedBanner } from "./TruncatedBanner";
|
||||
import { useMarkdownEditorSync } from "./useMarkdownEditorSync";
|
||||
import { useScrollRestore } from "./useScrollRestore";
|
||||
import { useEditorAutoSave } from "./useEditorAutoSave";
|
||||
import { MarkdownCommentPlugin } from "./MarkdownCommentPlugin";
|
||||
import { MarkdownSearchBar } from "./MarkdownSearchBar";
|
||||
@@ -243,6 +244,14 @@ function MarkdownRichTextViewerInner({
|
||||
[],
|
||||
);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
// Persist/restore the editor's scroll position across unmount/remount and
|
||||
// session switches. Content is present at mount (the parent gates on the
|
||||
// file query), so the restore is ready immediately.
|
||||
const handleScrollPersist = useScrollRestore(
|
||||
scrollContainerRef,
|
||||
conversationId && path ? `viewer-mdedit:${conversationId}:${path}` : null,
|
||||
true,
|
||||
);
|
||||
// Fall back to a local ref when the parent doesn't pass one (the toolbar
|
||||
// path always does; this keeps the bar usable in isolation/tests).
|
||||
const fallbackSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -463,6 +472,7 @@ function MarkdownRichTextViewerInner({
|
||||
)}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScrollPersist}
|
||||
className="relative flex-1 overflow-auto px-8 py-6"
|
||||
// Link following. The Link extension runs with openOnClick:false so a
|
||||
// plain click in edit mode positions the cursor instead of navigating.
|
||||
|
||||
@@ -19,6 +19,9 @@ const h = vi.hoisted(() => ({
|
||||
onChange: null as ((value: string | undefined, ev: unknown) => void) | null,
|
||||
cmdS: null as (() => void) | null,
|
||||
blur: null as (() => void) | null,
|
||||
scrollTops: [] as number[],
|
||||
scroll: null as ((e: { scrollTop: number }) => void) | null,
|
||||
domNode: document.createElement("div"),
|
||||
}));
|
||||
|
||||
// Minimal Monaco namespace: only the members handleMount touches.
|
||||
@@ -34,6 +37,10 @@ interface FakeEditor {
|
||||
getModel: () => { setEOL: () => void };
|
||||
addCommand: (binding: number, handler: () => void) => void;
|
||||
onDidBlurEditorWidget: (handler: () => void) => { dispose: () => void };
|
||||
setScrollTop: (top: number) => void;
|
||||
onDidScrollChange: (handler: (e: { scrollTop: number }) => void) => { dispose: () => void };
|
||||
/** Real node so the restore can listen for the reader taking over scrolling. */
|
||||
getDomNode: () => HTMLElement;
|
||||
saveViewState: () => null;
|
||||
restoreViewState: () => void;
|
||||
getAction: () => { run: () => void };
|
||||
@@ -61,6 +68,14 @@ function makeFakeEditor(initial: string): FakeEditor {
|
||||
h.blur = handler;
|
||||
return { dispose: () => {} };
|
||||
},
|
||||
setScrollTop: (top) => {
|
||||
h.scrollTops.push(top);
|
||||
},
|
||||
onDidScrollChange: (handler) => {
|
||||
h.scroll = handler;
|
||||
return { dispose: () => {} };
|
||||
},
|
||||
getDomNode: () => h.domNode,
|
||||
saveViewState: () => null,
|
||||
restoreViewState: () => {},
|
||||
getAction: () => ({ run: () => {} }),
|
||||
@@ -109,6 +124,7 @@ vi.mock("@/hooks/useWriteFileContent", () => ({ useWriteFileContent: vi.fn() }))
|
||||
vi.mock("@/hooks/RunnerHealthProvider", () => ({ useSessionRunnerOnline: vi.fn() }));
|
||||
|
||||
import { MonacoCodeEditor } from "./MonacoCodeEditor";
|
||||
import { getSavedScrollTop, saveScrollTop } from "./useScrollRestore";
|
||||
import * as writeHook from "@/hooks/useWriteFileContent";
|
||||
import * as runnerHook from "@/hooks/RunnerHealthProvider";
|
||||
|
||||
@@ -166,6 +182,9 @@ beforeEach(() => {
|
||||
h.onChange = null;
|
||||
h.cmdS = null;
|
||||
h.blur = null;
|
||||
h.scrollTops = [];
|
||||
h.scroll = null;
|
||||
h.domNode = document.createElement("div");
|
||||
mockWrite();
|
||||
// Online → auto-save enabled.
|
||||
vi.mocked(runnerHook.useSessionRunnerOnline).mockReturnValue(true);
|
||||
@@ -290,3 +309,60 @@ describe("MonacoCodeEditor auto-save wiring (integration)", () => {
|
||||
expect(mutateAsync).toHaveBeenCalledWith({ path: PATH, content: EDITED });
|
||||
});
|
||||
});
|
||||
|
||||
// Monaco scrolls internally, so the viewer drives the shared scroll cache from
|
||||
// the editor's own scroll events rather than the DOM restore hook.
|
||||
describe("MonacoCodeEditor scroll position persistence", () => {
|
||||
const KEY = `viewer:conv_monaco_autosave:${PATH}`;
|
||||
|
||||
it("restores the saved offset on mount", async () => {
|
||||
saveScrollTop(KEY, 320);
|
||||
await renderMounted(makeEditor());
|
||||
expect(h.scrollTops).toContain(320);
|
||||
});
|
||||
|
||||
it("records the editor's offset as the user scrolls", async () => {
|
||||
saveScrollTop(KEY, 0);
|
||||
await renderMounted(makeEditor());
|
||||
expect(h.scroll).not.toBeNull();
|
||||
h.scroll?.({ scrollTop: 210 });
|
||||
expect(getSavedScrollTop(KEY)).toBe(210);
|
||||
});
|
||||
|
||||
it("keeps offsets separate per file", async () => {
|
||||
saveScrollTop(KEY, 320);
|
||||
saveScrollTop("viewer:conv_monaco_autosave:src/other.ts", 15);
|
||||
await renderMounted(makeEditor());
|
||||
// Reaching the restored offset ends the restore, so later scrolls persist.
|
||||
h.scroll?.({ scrollTop: 320 });
|
||||
h.scroll?.({ scrollTop: 44 });
|
||||
expect(getSavedScrollTop(KEY)).toBe(44);
|
||||
expect(getSavedScrollTop("viewer:conv_monaco_autosave:src/other.ts")).toBe(15);
|
||||
});
|
||||
|
||||
it("does not let the mount-time clamp overwrite the saved offset", async () => {
|
||||
saveScrollTop(KEY, 320);
|
||||
await renderMounted(makeEditor());
|
||||
h.scrollTops = [];
|
||||
|
||||
// Not laid out yet: Monaco clamps to 0 and reports it. Persisting that would
|
||||
// destroy the reader's position, so the target is re-asserted instead.
|
||||
h.scroll?.({ scrollTop: 0 });
|
||||
expect(getSavedScrollTop(KEY)).toBe(320);
|
||||
expect(h.scrollTops).toContain(320);
|
||||
|
||||
// Once the editor can hold the offset, saving resumes.
|
||||
h.scroll?.({ scrollTop: 320 });
|
||||
h.scroll?.({ scrollTop: 90 });
|
||||
expect(getSavedScrollTop(KEY)).toBe(90);
|
||||
});
|
||||
|
||||
it("stops re-asserting when the reader scrolls during the restore", async () => {
|
||||
saveScrollTop(KEY, 320);
|
||||
await renderMounted(makeEditor());
|
||||
|
||||
h.domNode.dispatchEvent(new Event("wheel"));
|
||||
h.scroll?.({ scrollTop: 10 });
|
||||
expect(getSavedScrollTop(KEY)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,8 @@ interface FakeEditor {
|
||||
getModel: () => { setEOL: () => void };
|
||||
addCommand: () => void;
|
||||
onDidBlurEditorWidget: () => { dispose: () => void };
|
||||
setScrollTop: (top: number) => void;
|
||||
onDidScrollChange: () => { dispose: () => void };
|
||||
saveViewState: () => null;
|
||||
restoreViewState: () => void;
|
||||
getAction: (id: string) => { run: () => void } | undefined;
|
||||
@@ -95,6 +97,8 @@ function makeFakeEditor(initial: string): FakeEditor {
|
||||
getModel: () => ({ setEOL: () => {} }),
|
||||
addCommand: () => {},
|
||||
onDidBlurEditorWidget: () => ({ dispose: () => {} }),
|
||||
setScrollTop: () => {},
|
||||
onDidScrollChange: () => ({ dispose: () => {} }),
|
||||
saveViewState: () => null,
|
||||
restoreViewState: () => {},
|
||||
// Only the find action is exercised here.
|
||||
|
||||
@@ -34,6 +34,8 @@ interface FakeEditor {
|
||||
getModel: () => { setEOL: () => void };
|
||||
addCommand: () => void;
|
||||
onDidBlurEditorWidget: () => { dispose: () => void };
|
||||
setScrollTop: (top: number) => void;
|
||||
onDidScrollChange: () => { dispose: () => void };
|
||||
saveViewState: () => null;
|
||||
restoreViewState: () => void;
|
||||
getAction: () => { run: () => void };
|
||||
@@ -52,6 +54,8 @@ function makeFakeEditor(initial: string): FakeEditor {
|
||||
getModel: () => ({ setEOL: () => {} }),
|
||||
addCommand: () => {},
|
||||
onDidBlurEditorWidget: () => ({ dispose: () => {} }),
|
||||
setScrollTop: () => {},
|
||||
onDidScrollChange: () => ({ dispose: () => {} }),
|
||||
saveViewState: () => null,
|
||||
restoreViewState: () => {},
|
||||
getAction: () => ({ run: () => {} }),
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "./monacoSetup";
|
||||
import type { monaco } from "./monacoSetup";
|
||||
import { useMonacoCommentLayer, type CodeEditorInstance } from "./useMonacoCommentLayer";
|
||||
import { attachEditorScrollRestore } from "./useScrollRestore";
|
||||
import "./monacoCodeEditor.css";
|
||||
|
||||
type EditorOptions = EditorProps["options"];
|
||||
@@ -287,6 +288,12 @@ function MonacoCodeEditorInner({
|
||||
const flushRef = useRef(autoSave.flush);
|
||||
flushRef.current = autoSave.flush;
|
||||
|
||||
// Monaco scrolls internally, so its offset is cached per conversation + file
|
||||
// rather than via the DOM scroll-restore hook. Held in a ref so the mount-time
|
||||
// onDidScrollChange subscription always writes the current file's key.
|
||||
const scrollKeyRef = useRef("");
|
||||
scrollKeyRef.current = `viewer:${conversationId}:${path}`;
|
||||
|
||||
const handleMount: OnMount = useCallback(
|
||||
(editor, monaco) => {
|
||||
editorInstanceRef.current = editor;
|
||||
@@ -327,6 +334,13 @@ function MonacoCodeEditorInner({
|
||||
setDirty(false);
|
||||
if (viewState) ed.restoreViewState(viewState);
|
||||
};
|
||||
// Reopening a file (or switching sessions and back) lands where the user
|
||||
// left off, and further scrolling is cached under the current file's key.
|
||||
attachEditorScrollRestore(
|
||||
editor,
|
||||
() => scrollKeyRef.current,
|
||||
() => editorInstanceRef.current === editor,
|
||||
);
|
||||
setMounted(true);
|
||||
},
|
||||
[setContentRef, setDirty, content],
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("@/hooks/usePermissions", () => ({ useCanEdit: vi.fn(() => true) }));
|
||||
|
||||
import { MonacoDiffViewer } from "./MonacoDiffViewer";
|
||||
import { codeFontFamilyForEditor, writeCodeFontSizePx } from "@/lib/codeFontPreferences";
|
||||
import { getSavedScrollTop, saveScrollTop } from "./useScrollRestore";
|
||||
|
||||
function renderDiff(props: {
|
||||
before: string | null;
|
||||
@@ -78,6 +79,15 @@ function renderDiff(props: {
|
||||
);
|
||||
}
|
||||
|
||||
// The modified editor's scroll API, used by the viewer to persist the reader's
|
||||
// place in the diff.
|
||||
function scrollStubs() {
|
||||
return {
|
||||
setScrollTop: vi.fn(),
|
||||
onDidScrollChange: vi.fn(() => ({ dispose: () => {} })),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.diffProps = null;
|
||||
h.onMount = null;
|
||||
@@ -145,7 +155,7 @@ describe("MonacoDiffViewer", () => {
|
||||
|
||||
it("wires getModifiedEditor() into the comment layer on mount", async () => {
|
||||
const setEOL = vi.fn();
|
||||
const fakeModified = { getModel: () => ({ setEOL }) };
|
||||
const fakeModified = { getModel: () => ({ setEOL }), ...scrollStubs() };
|
||||
renderDiff({ before: "a", after: "b\r\n", layout: "split" });
|
||||
await waitFor(() => expect(h.onMount).not.toBeNull());
|
||||
|
||||
@@ -170,9 +180,80 @@ describe("MonacoDiffViewer", () => {
|
||||
expect(setEOL).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("restores and records the modified side's scroll offset", async () => {
|
||||
saveScrollTop("viewer-diff:conv_1:src/a.ts", 260);
|
||||
const setScrollTop = vi.fn();
|
||||
const onDidScrollChange = vi.fn((_listener: (e: { scrollTop: number }) => void) => ({
|
||||
dispose: () => {},
|
||||
}));
|
||||
const fakeModified = {
|
||||
getModel: () => ({ setEOL: vi.fn() }),
|
||||
setScrollTop,
|
||||
onDidScrollChange,
|
||||
getDomNode: () => document.createElement("div"),
|
||||
};
|
||||
renderDiff({ before: "a", after: "b", layout: "split" });
|
||||
await waitFor(() => expect(h.onMount).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
h.onMount?.(
|
||||
{ getModifiedEditor: () => fakeModified } as unknown as Parameters<DiffOnMount>[0],
|
||||
{
|
||||
editor: { EndOfLineSequence: { LF: 0, CRLF: 1 } },
|
||||
} as unknown as Parameters<DiffOnMount>[1],
|
||||
);
|
||||
});
|
||||
|
||||
// The reader's place in the diff is restored, and further scrolling is
|
||||
// cached under the diff's own key.
|
||||
expect(setScrollTop).toHaveBeenCalledWith(260);
|
||||
const handler = onDidScrollChange.mock.calls[0]![0];
|
||||
handler({ scrollTop: 260 });
|
||||
handler({ scrollTop: 88 });
|
||||
expect(getSavedScrollTop("viewer-diff:conv_1:src/a.ts")).toBe(88);
|
||||
});
|
||||
|
||||
it("does not let the mount-time clamp overwrite the diff's saved offset", async () => {
|
||||
saveScrollTop("viewer-diff:conv_1:src/a.ts", 260);
|
||||
const setScrollTop = vi.fn();
|
||||
const onDidScrollChange = vi.fn((_listener: (e: { scrollTop: number }) => void) => ({
|
||||
dispose: () => {},
|
||||
}));
|
||||
const fakeModified = {
|
||||
getModel: () => ({ setEOL: vi.fn() }),
|
||||
setScrollTop,
|
||||
onDidScrollChange,
|
||||
getDomNode: () => document.createElement("div"),
|
||||
};
|
||||
renderDiff({ before: "a", after: "b", layout: "split" });
|
||||
await waitFor(() => expect(h.onMount).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
h.onMount?.(
|
||||
{ getModifiedEditor: () => fakeModified } as unknown as Parameters<DiffOnMount>[0],
|
||||
{
|
||||
editor: { EndOfLineSequence: { LF: 0, CRLF: 1 } },
|
||||
} as unknown as Parameters<DiffOnMount>[1],
|
||||
);
|
||||
});
|
||||
setScrollTop.mockClear();
|
||||
|
||||
// The panes aren't laid out yet, so Monaco reports the clamped 0; caching it
|
||||
// would lose the reader's place, so the target is re-asserted instead.
|
||||
const handler = onDidScrollChange.mock.calls[0]![0];
|
||||
handler({ scrollTop: 0 });
|
||||
expect(getSavedScrollTop("viewer-diff:conv_1:src/a.ts")).toBe(260);
|
||||
expect(setScrollTop).toHaveBeenCalledWith(260);
|
||||
|
||||
// Once the offset is reachable, saving resumes.
|
||||
handler({ scrollTop: 260 });
|
||||
handler({ scrollTop: 12 });
|
||||
expect(getSavedScrollTop("viewer-diff:conv_1:src/a.ts")).toBe(12);
|
||||
});
|
||||
|
||||
it("re-fonts the mounted diff editor when the code-font preference changes", async () => {
|
||||
const updateOptions = vi.fn();
|
||||
const fakeModified = { getModel: () => ({ setEOL: vi.fn() }) };
|
||||
const fakeModified = { getModel: () => ({ setEOL: vi.fn() }), ...scrollStubs() };
|
||||
const fakeDiff = { getModifiedEditor: () => fakeModified, updateOptions };
|
||||
renderDiff({ before: "a", after: "b", layout: "split" });
|
||||
await waitFor(() => expect(h.onMount).not.toBeNull());
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
resolvedThemeToMonaco,
|
||||
} from "./monacoSetup";
|
||||
import { useMonacoCommentLayer, type CodeEditorInstance } from "./useMonacoCommentLayer";
|
||||
import { attachEditorScrollRestore } from "./useScrollRestore";
|
||||
import "./monacoCodeEditor.css";
|
||||
|
||||
interface MonacoDiffViewerProps {
|
||||
@@ -107,6 +108,12 @@ export function MonacoDiffViewer({
|
||||
const diffEditorRef = useRef<Parameters<DiffOnMount>[0] | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// The diff scrolls inside Monaco, so its offset is cached per conversation +
|
||||
// file rather than via the DOM scroll-restore hook. Kept in its own namespace
|
||||
// so a file's diff and its editor view don't share one offset.
|
||||
const scrollKeyRef = useRef("");
|
||||
scrollKeyRef.current = `viewer-diff:${conversationId}:${path}`;
|
||||
|
||||
const handleMount: DiffOnMount = useCallback(
|
||||
(diffEditor, monaco) => {
|
||||
diffEditorRef.current = diffEditor;
|
||||
@@ -121,6 +128,13 @@ export function MonacoDiffViewer({
|
||||
? monaco.editor.EndOfLineSequence.CRLF
|
||||
: monaco.editor.EndOfLineSequence.LF,
|
||||
);
|
||||
// Restore the reader's place in the diff and cache further scrolling under
|
||||
// the diff's own key.
|
||||
attachEditorScrollRestore(
|
||||
modified,
|
||||
() => scrollKeyRef.current,
|
||||
() => modifiedEditorRef.current === modified,
|
||||
);
|
||||
setMounted(true);
|
||||
},
|
||||
[after],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { RefObject, UIEvent } from "react";
|
||||
import type { BundledLanguage } from "shiki";
|
||||
import AnsiDefault from "ansi-to-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
@@ -213,9 +213,11 @@ function CodeCell({ cell, language }: { cell: NotebookCell; language: BundledLan
|
||||
export function NotebookPreview({
|
||||
content,
|
||||
rootRef,
|
||||
onScroll,
|
||||
}: {
|
||||
content: string;
|
||||
rootRef?: RefObject<HTMLDivElement | null>;
|
||||
onScroll?: (event: UIEvent<HTMLElement>) => void;
|
||||
}) {
|
||||
const { notebook, error } = parseNotebook(content);
|
||||
|
||||
@@ -234,7 +236,12 @@ export function NotebookPreview({
|
||||
const language = langName as BundledLanguage;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} data-preview-scroll className="h-full space-y-4 overflow-auto px-6 py-4">
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-preview-scroll
|
||||
onScroll={onScroll}
|
||||
className="h-full space-y-4 overflow-auto px-6 py-4"
|
||||
>
|
||||
{(notebook.cells ?? []).map((cell, i) => {
|
||||
if (cell.cell_type === "markdown") {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Tests for useScrollRestore — the shared scroll-position cache behind the
|
||||
// Files panel and the file viewer:
|
||||
//
|
||||
// 1. Revisiting a key restores the offset saved before unmount.
|
||||
// 2. A fresh key starts at the top, unaffected by another key's offset.
|
||||
// 3. The loading clamp (scrollTop forced to 0 before the content is tall
|
||||
// enough) cannot overwrite the saved offset.
|
||||
// 4. The restore keeps trying while the content is still growing, and only
|
||||
// gives up once its time budget expires.
|
||||
// 5. Real user input (a wheel gesture) settles the restore immediately.
|
||||
// 6. Saving resumes once the restore settles.
|
||||
// 7. A null key disables persistence entirely.
|
||||
|
||||
import { useRef } from "react";
|
||||
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
SCROLL_RESTORE_BUDGET_MS,
|
||||
getSavedScrollTop,
|
||||
saveScrollTop,
|
||||
useScrollRestore,
|
||||
} from "./useScrollRestore";
|
||||
|
||||
function Scroller({ scrollKey, ready }: { scrollKey: string | null; ready: boolean }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const onScroll = useScrollRestore(ref, scrollKey, ready);
|
||||
return <div ref={ref} data-testid="scroller" onScroll={onScroll} />;
|
||||
}
|
||||
|
||||
function mount(scrollKey: string | null, ready = true) {
|
||||
const view = render(<Scroller scrollKey={scrollKey} ready={ready} />);
|
||||
const el = view.getByTestId("scroller");
|
||||
return { view, el };
|
||||
}
|
||||
|
||||
// The restore's budget is measured with performance.now(), so tests drive a
|
||||
// controllable clock instead of waiting out real seconds.
|
||||
let now = 0;
|
||||
|
||||
async function nextFrame() {
|
||||
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
|
||||
}
|
||||
|
||||
// jsdom has no layout (scrollHeight/clientHeight are 0), so a saved offset > 0
|
||||
// is never reachable — the restore settles when the budget runs out.
|
||||
async function settleRestore() {
|
||||
now += SCROLL_RESTORE_BUDGET_MS + 1;
|
||||
await nextFrame();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
now = 0;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useScrollRestore", () => {
|
||||
it("restores the saved offset when a key is revisited", async () => {
|
||||
const first = mount("view:a");
|
||||
await settleRestore();
|
||||
first.el.scrollTop = 120;
|
||||
fireEvent.scroll(first.el);
|
||||
first.view.unmount();
|
||||
|
||||
const again = mount("view:a");
|
||||
expect(again.el.scrollTop).toBe(120);
|
||||
});
|
||||
|
||||
it("starts a different key at the top", async () => {
|
||||
const first = mount("view:b");
|
||||
await settleRestore();
|
||||
first.el.scrollTop = 200;
|
||||
fireEvent.scroll(first.el);
|
||||
first.view.unmount();
|
||||
|
||||
const other = mount("view:c");
|
||||
expect(other.el.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("does not let the loading clamp overwrite the saved offset", () => {
|
||||
saveScrollTop("view:clamp", 90);
|
||||
|
||||
// Revisit: the container is still a short placeholder, so the browser
|
||||
// clamps scrollTop to 0 and fires a scroll event.
|
||||
const { el } = mount("view:clamp");
|
||||
el.scrollTop = 0;
|
||||
fireEvent.scroll(el);
|
||||
|
||||
expect(getSavedScrollTop("view:clamp")).toBe(90);
|
||||
});
|
||||
|
||||
it("keeps retrying while the height is stable but the budget has not expired", async () => {
|
||||
saveScrollTop("view:slow", 140);
|
||||
const { el } = mount("view:slow");
|
||||
|
||||
// Async content (highlighting, images, lazy cells) can stall for several
|
||||
// frames before growing; the restore must not surrender during the stall.
|
||||
now += 100;
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
|
||||
el.scrollTop = 0;
|
||||
fireEvent.scroll(el);
|
||||
expect(getSavedScrollTop("view:slow")).toBe(140);
|
||||
});
|
||||
|
||||
it("settles as soon as the target is reachable", async () => {
|
||||
saveScrollTop("view:reachable", 50);
|
||||
const { el } = mount("view:reachable");
|
||||
// Tall content: the offset fits, so no budget needs to be spent.
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
|
||||
await nextFrame();
|
||||
|
||||
el.scrollTop = 60;
|
||||
fireEvent.scroll(el);
|
||||
expect(getSavedScrollTop("view:reachable")).toBe(60);
|
||||
});
|
||||
|
||||
it("stops fighting the user when they scroll during a pending restore", async () => {
|
||||
saveScrollTop("view:wheel", 300);
|
||||
const { el } = mount("view:wheel");
|
||||
|
||||
fireEvent.wheel(el);
|
||||
el.scrollTop = 12;
|
||||
fireEvent.scroll(el);
|
||||
|
||||
expect(getSavedScrollTop("view:wheel")).toBe(12);
|
||||
});
|
||||
|
||||
it("saves again once the restore has settled", async () => {
|
||||
saveScrollTop("view:settle", 90);
|
||||
const { el } = mount("view:settle");
|
||||
await settleRestore();
|
||||
|
||||
el.scrollTop = 45;
|
||||
fireEvent.scroll(el);
|
||||
|
||||
expect(getSavedScrollTop("view:settle")).toBe(45);
|
||||
});
|
||||
|
||||
it("waits for `ready` before restoring", () => {
|
||||
saveScrollTop("view:pending", 70);
|
||||
const { el } = mount("view:pending", false);
|
||||
expect(el.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("persists nothing when the key is null", async () => {
|
||||
const { el } = mount(null);
|
||||
await settleRestore();
|
||||
el.scrollTop = 30;
|
||||
fireEvent.scroll(el);
|
||||
|
||||
expect(getSavedScrollTop("null")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
import type { RefObject, UIEvent } from "react";
|
||||
|
||||
/**
|
||||
* Module-level cache so scroll positions survive unmount/remount and
|
||||
* conversation switches within a JS session. Shared by the Files panel
|
||||
* (keyed per conversation + view) and the file viewer surfaces (keyed per
|
||||
* conversation + path).
|
||||
*/
|
||||
const scrollTopCache = new Map<string, number>();
|
||||
|
||||
/** Read a saved scroll offset (e.g. to seed a Monaco editor on mount). */
|
||||
export function getSavedScrollTop(key: string): number | undefined {
|
||||
return scrollTopCache.get(key);
|
||||
}
|
||||
|
||||
/** Record a scroll offset (e.g. from Monaco's onDidScrollChange). */
|
||||
export function saveScrollTop(key: string, scrollTop: number): void {
|
||||
scrollTopCache.set(key, scrollTop);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a restore keeps re-asserting the saved offset while the content is
|
||||
* still too short to hold it. Async work (syntax highlighting, image decode,
|
||||
* lazy cells) grows the container in bursts with stalls in between, so the
|
||||
* window has to outlast a stall. Shared with the Monaco surfaces.
|
||||
*/
|
||||
export const SCROLL_RESTORE_BUDGET_MS = 1500;
|
||||
|
||||
/** Pointer/touch/wheel input that means the user has taken over scrolling. */
|
||||
const USER_SCROLL_EVENTS = ["wheel", "touchstart", "pointerdown"] as const;
|
||||
|
||||
/** The slice of a Monaco code editor the scroll restore needs. */
|
||||
interface ScrollableEditor {
|
||||
setScrollTop: (top: number) => void;
|
||||
onDidScrollChange: (listener: (e: { scrollTop: number }) => void) => { dispose: () => void };
|
||||
getDomNode?: () => HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore and persist a Monaco editor's own scroll offset (Monaco scrolls
|
||||
* internally, so the DOM hook can't drive it).
|
||||
*
|
||||
* Same shape as `useScrollRestore`: the editor isn't laid out at mount, so the
|
||||
* offset clamps to 0 and Monaco reports that clamp as a scroll event. Saving
|
||||
* stays off — and the target is re-asserted — until the offset is actually
|
||||
* reached, the user scrolls, or the restore budget runs out. Without that, the
|
||||
* clamp would overwrite the reader's saved position with 0.
|
||||
*
|
||||
* @param editor The editor to restore (the modified side, for a diff).
|
||||
* @param getKey Reads the current cache key (files can switch under one editor).
|
||||
* @param isCurrent False once the editor has been replaced or torn down.
|
||||
*/
|
||||
export function attachEditorScrollRestore(
|
||||
editor: ScrollableEditor,
|
||||
getKey: () => string,
|
||||
isCurrent: () => boolean,
|
||||
): void {
|
||||
const saved = getSavedScrollTop(getKey());
|
||||
let pending =
|
||||
saved !== undefined && saved > 0
|
||||
? { target: saved, deadline: performance.now() + SCROLL_RESTORE_BUDGET_MS }
|
||||
: null;
|
||||
const dom = pending ? (editor.getDomNode?.() ?? null) : null;
|
||||
const settle = () => {
|
||||
pending = null;
|
||||
for (const type of USER_SCROLL_EVENTS) dom?.removeEventListener(type, settle);
|
||||
};
|
||||
if (pending) {
|
||||
editor.setScrollTop(pending.target);
|
||||
requestAnimationFrame(() => {
|
||||
if (pending && isCurrent()) editor.setScrollTop(pending.target);
|
||||
});
|
||||
for (const type of USER_SCROLL_EVENTS) dom?.addEventListener(type, settle, { passive: true });
|
||||
}
|
||||
editor.onDidScrollChange((e) => {
|
||||
if (pending) {
|
||||
if (Math.abs(e.scrollTop - pending.target) > 1 && performance.now() < pending.deadline) {
|
||||
editor.setScrollTop(pending.target);
|
||||
return;
|
||||
}
|
||||
settle();
|
||||
}
|
||||
saveScrollTop(getKey(), e.scrollTop);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist and restore a scroll container's position across key changes
|
||||
* (conversation/file switches) and unmount/remount.
|
||||
*
|
||||
* Restoring is not a one-shot write: while new content loads the container
|
||||
* is a short placeholder and the browser clamps scrollTop to 0, and the
|
||||
* content then grows in steps, each of which can clamp again. So the
|
||||
* restore waits for `ready`, then re-asserts the saved offset on every
|
||||
* render and via an animation-frame loop until the container is tall
|
||||
* enough to hold it, giving up once `SCROLL_RESTORE_BUDGET_MS` has passed.
|
||||
* Any wheel/touch/pointer input settles it immediately so the user is never
|
||||
* fought for the offset. Saving stays off until the restore settles so
|
||||
* clamp-induced scroll events can't overwrite the cached value.
|
||||
*
|
||||
* @param ref The scrollable element.
|
||||
* @param key Cache key for the current content (null disables persistence).
|
||||
* @param ready True once the content backing the container is present.
|
||||
* @returns An onScroll handler to attach to the container.
|
||||
*/
|
||||
export function useScrollRestore(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
key: string | null,
|
||||
ready: boolean,
|
||||
): (event: UIEvent<HTMLElement>) => void {
|
||||
const pendingRef = useRef<{ target: number; deadline: number } | null>(null);
|
||||
const keyRef = useRef<string | null>(null);
|
||||
if (key !== keyRef.current) {
|
||||
keyRef.current = key;
|
||||
// The deadline belongs to the pending entry, not to an effect run, so
|
||||
// re-renders during the restore can't keep extending the window.
|
||||
pendingRef.current = key
|
||||
? {
|
||||
target: scrollTopCache.get(key) ?? 0,
|
||||
deadline: performance.now() + SCROLL_RESTORE_BUDGET_MS,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// No dependency array: intentionally runs after every render — each
|
||||
// content-growth step is another chance to reach the saved offset. Cleanup
|
||||
// cancels the previous run's frame, so only one loop is ever live.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
const pending = pendingRef.current;
|
||||
if (!el || !pending || !ready) return;
|
||||
let frame = 0;
|
||||
function settleForUser() {
|
||||
pendingRef.current = null;
|
||||
cancelAnimationFrame(frame);
|
||||
detach();
|
||||
}
|
||||
const detach = () => {
|
||||
for (const type of USER_SCROLL_EVENTS) el.removeEventListener(type, settleForUser);
|
||||
};
|
||||
const attempt = () => {
|
||||
// A user gesture (or a later render's loop) may have already settled it.
|
||||
if (pendingRef.current !== pending) {
|
||||
detach();
|
||||
return;
|
||||
}
|
||||
el.scrollTop = pending.target;
|
||||
const maxScroll = el.scrollHeight - el.clientHeight;
|
||||
if (maxScroll >= pending.target || performance.now() >= pending.deadline) {
|
||||
pendingRef.current = null;
|
||||
detach();
|
||||
return;
|
||||
}
|
||||
frame = requestAnimationFrame(attempt);
|
||||
};
|
||||
for (const type of USER_SCROLL_EVENTS) {
|
||||
el.addEventListener(type, settleForUser, { passive: true });
|
||||
}
|
||||
attempt();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
detach();
|
||||
};
|
||||
});
|
||||
|
||||
return useCallback((event: UIEvent<HTMLElement>) => {
|
||||
if (keyRef.current && pendingRef.current === null) {
|
||||
scrollTopCache.set(keyRef.current, event.currentTarget.scrollTop);
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user