Skip to content

Commit aaa8cfe

Browse files
feat(web): restore scroll position when browsing files (#1261)
The code preview panel now persists the scroll position per file/revision to sessionStorage and restores it on mount. Restoration is skipped when a highlight_range query param is present so the existing highlight scroll-into-view takes precedence. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1d63c10 commit aaa8cfe

2 files changed

Lines changed: 87 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Added ask connectors: connect 3rd party MCP servers to your ask agent. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106)
1212
- Added progress bar when navigating between pages. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106)
1313
- Added a integrated changelog into the sidebar. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106)
14+
- Added scroll position restoration when viewing files in the code browser, so returning to a previously viewed file restores your scroll position. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106)
1415

1516
### Changed
1617
- [**Breaking Change**] Changed the default role assignment to `Owner` for organizations on the free tier. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106)

packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/pureCodePreviewPanel.tsx

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useKeymapExtension } from "@/hooks/useKeymapExtension";
1010
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
1111
import { search } from "@codemirror/search";
1212
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
13-
import { useCallback, useEffect, useMemo, useState } from "react";
13+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1414
import { useRouter } from "next/navigation";
1515
import { EditorContextMenu } from "@/app/(app)/components/editorContextMenu";
1616
import { BrowseHighlightRange, getBrowsePath, HIGHLIGHT_RANGE_QUERY_PARAM } from "@/app/(app)/browse/hooks/utils";
@@ -36,6 +36,7 @@ export const PureCodePreviewPanel = ({
3636
blame,
3737
}: PureCodePreviewPanelProps) => {
3838
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
39+
const scrollAreaRef = useRef<HTMLDivElement>(null);
3940
const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);
4041
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
4142
const keymapExtension = useKeymapExtension(editorRef?.view);
@@ -172,10 +173,93 @@ export const PureCodePreviewPanel = ({
172173
});
173174
}, [editorRef, highlightRange]);
174175

176+
// Scroll restoration. The editor grows to its content height, so the actual
177+
// scroll container is the ScrollArea's viewport (not CodeMirror's own
178+
// scroller). We persist the scroll position per file and restore it on mount.
179+
const scrollPositionStorageKey = useMemo(
180+
() => `browse-scroll-pos:${repoName}@${revisionName}:${path}`,
181+
[repoName, revisionName, path],
182+
);
183+
184+
// Persist the scroll position as the user scrolls, throttled to one write
185+
// per animation frame.
186+
useEffect(() => {
187+
const viewport = scrollAreaRef.current?.querySelector<HTMLElement>(
188+
'[data-radix-scroll-area-viewport]'
189+
);
190+
if (!viewport) {
191+
return;
192+
}
193+
194+
let frame = 0;
195+
const handleScroll = () => {
196+
if (frame) {
197+
return;
198+
}
199+
frame = requestAnimationFrame(() => {
200+
frame = 0;
201+
sessionStorage.setItem(scrollPositionStorageKey, JSON.stringify({
202+
top: viewport.scrollTop,
203+
left: viewport.scrollLeft,
204+
}));
205+
});
206+
};
207+
208+
viewport.addEventListener('scroll', handleScroll, { passive: true });
209+
return () => {
210+
viewport.removeEventListener('scroll', handleScroll);
211+
if (frame) {
212+
cancelAnimationFrame(frame);
213+
}
214+
};
215+
}, [scrollPositionStorageKey]);
216+
217+
// Restore the saved scroll position once the editor is ready. We skip this
218+
// when a highlight range is present so the highlight scroll-into-view above
219+
// takes precedence. The ref guards against re-restoring (e.g. after the user
220+
// has already scrolled) while still re-running when the file changes.
221+
const restoredScrollKeyRef = useRef<string | null>(null);
222+
useEffect(() => {
223+
if (highlightRange) {
224+
return;
225+
}
226+
if (restoredScrollKeyRef.current === scrollPositionStorageKey) {
227+
return;
228+
}
229+
if (!editorRef?.view) {
230+
return;
231+
}
232+
233+
const viewport = scrollAreaRef.current?.querySelector<HTMLElement>(
234+
'[data-radix-scroll-area-viewport]'
235+
);
236+
if (!viewport) {
237+
return;
238+
}
239+
240+
restoredScrollKeyRef.current = scrollPositionStorageKey;
241+
242+
const saved = sessionStorage.getItem(scrollPositionStorageKey);
243+
if (!saved) {
244+
return;
245+
}
246+
247+
try {
248+
const { top, left } = JSON.parse(saved);
249+
// Defer to the next frame so the editor's content has been laid out
250+
// and the viewport has its full scroll height.
251+
requestAnimationFrame(() => {
252+
viewport.scrollTo({ top, left });
253+
});
254+
} catch {
255+
// Ignore malformed entries.
256+
}
257+
}, [editorRef, highlightRange, scrollPositionStorageKey]);
258+
175259
const theme = useCodeMirrorTheme();
176260

177261
return (
178-
<ScrollArea className="h-full overflow-auto flex-1">
262+
<ScrollArea ref={scrollAreaRef} className="h-full overflow-auto flex-1">
179263
<CodeMirror
180264
className="relative"
181265
ref={setEditorRef}

0 commit comments

Comments
 (0)