diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21fd05b6a..0dc635f28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Silenced a false-positive `MaxListenersExceededWarning` logged on every request proxied through an external rewrite. [#1697](https://github.com/sourcebot-dev/sourcebot/pull/1697)
+- Fixed recently opened files appearing across different browse revisions. [#1686](https://github.com/sourcebot-dev/sourcebot/pull/1686)
## [5.1.15] - 2026-09-29
diff --git a/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.test.tsx b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.test.tsx
new file mode 100644
index 000000000..cfdfba51a
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.test.tsx
@@ -0,0 +1,157 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FileSearchCommandDialog } from './fileSearchCommandDialog';
+
+const mocks = vi.hoisted(() => ({
+ params: { repoName: 'github.com/org/repo', revisionName: 'main' as string | undefined },
+ navigateToPath: vi.fn(),
+ updateBrowseState: vi.fn(),
+ files: [
+ { type: 'blob', name: 'main.ts', path: 'src/main.ts' },
+ { type: 'blob', name: 'feature.ts', path: 'src/feature.ts' },
+ ],
+}));
+
+vi.mock('../hooks/useBrowseParams', () => ({ useBrowseParams: () => mocks.params }));
+vi.mock('../hooks/useBrowseNavigation', () => ({
+ useBrowseNavigation: () => ({ navigateToPath: mocks.navigateToPath }),
+}));
+vi.mock('../hooks/useBrowseState', () => ({
+ useBrowseState: () => ({
+ state: { isFileSearchOpen: true },
+ updateBrowseState: mocks.updateBrowseState,
+ }),
+}));
+vi.mock('react-hotkeys-hook', () => ({ useHotkeys: vi.fn() }));
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: () => ({
+ data: mocks.files,
+ isLoading: false,
+ isError: false,
+ }),
+}));
+vi.mock('@/app/api/(client)/client', () => ({ getFiles: vi.fn() }));
+vi.mock('@/app/(app)/browse/components/fileTreeItemIcon', () => ({ FileTreeItemIcon: () => null }));
+
+beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ mocks.params = { repoName: 'github.com/org/repo', revisionName: 'main' };
+ mocks.files = [
+ { type: 'blob', name: 'main.ts', path: 'src/main.ts' },
+ { type: 'blob', name: 'feature.ts', path: 'src/feature.ts' },
+ ];
+ vi.stubGlobal('ResizeObserver', class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ });
+ HTMLElement.prototype.scrollIntoView = vi.fn();
+ HTMLElement.prototype.scrollTo = vi.fn();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+const selectFile = (name: string) => {
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: name } });
+ fireEvent.click(screen.getByRole('option'));
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } });
+};
+
+describe('file search recents', () => {
+ it('keeps separate histories when switching revisions and restores them after remounting', () => {
+ const view = render();
+ selectFile('main.ts');
+ expect(mocks.navigateToPath).toHaveBeenLastCalledWith({
+ repoName: mocks.params.repoName,
+ revisionName: 'main',
+ path: 'src/main.ts',
+ pathType: 'blob',
+ });
+
+ mocks.params.revisionName = 'feature';
+ view.rerender();
+ expect(screen.queryByText('main.ts')).toBeNull();
+ selectFile('feature.ts');
+
+ mocks.params.revisionName = 'main';
+ view.rerender();
+ expect(screen.getByText('main.ts')).toBeTruthy();
+ expect(screen.queryByText('feature.ts')).toBeNull();
+
+ view.unmount();
+ mocks.params.revisionName = 'feature';
+ render();
+ expect(screen.getByText('feature.ts')).toBeTruthy();
+ expect(screen.queryByText('main.ts')).toBeNull();
+ });
+
+ it('shares history between the default revision and explicit HEAD', () => {
+ mocks.params.revisionName = undefined;
+ const view = render();
+ selectFile('main.ts');
+
+ mocks.params.revisionName = 'HEAD';
+ view.rerender();
+ expect(screen.getByText('main.ts')).toBeTruthy();
+ });
+
+ it('does not inherit legacy history whose revision is unknown', () => {
+ localStorage.setItem(`recentlyOpenedFiles-${mocks.params.repoName}`, JSON.stringify([
+ { type: 'blob', name: 'old.ts', path: 'src/old.ts' },
+ ]));
+
+ render();
+
+ expect(screen.queryByText('old.ts')).toBeNull();
+ });
+
+ it('keeps repository and revision pairs distinct even when names contain separators', () => {
+ mocks.params = { repoName: 'repo@branch', revisionName: 'feature' };
+ const view = render();
+ selectFile('main.ts');
+
+ mocks.params = { repoName: 'repo', revisionName: 'branch@feature' };
+ view.rerender();
+ expect(screen.queryByText('main.ts')).toBeNull();
+ });
+
+ it('hides files removed from the current revision when its file list changes', () => {
+ const view = render();
+ selectFile('main.ts');
+ selectFile('feature.ts');
+
+ mocks.files = mocks.files.filter(file => file.name !== 'main.ts');
+ view.rerender();
+
+ expect(screen.queryByText('main.ts')).toBeNull();
+ fireEvent.click(screen.getByRole('option'));
+ expect(mocks.navigateToPath).toHaveBeenLastCalledWith(expect.objectContaining({ path: 'src/feature.ts' }));
+ });
+
+ it('bounds history across revisions without creating a key for every commit', () => {
+ const key = `recentlyOpenedFiles-v2-${JSON.stringify(mocks.params.repoName)}`;
+ const history = Array.from({ length: 100 }, (_, index) => ({
+ revision: `commit-${index}`,
+ file: { type: 'blob', name: 'main.ts', path: 'src/main.ts' },
+ }));
+ localStorage.setItem(key, JSON.stringify(history));
+ const view = render();
+ selectFile('main.ts');
+
+ mocks.params.revisionName = 'next-commit';
+ view.rerender();
+ selectFile('feature.ts');
+ selectFile('feature.ts');
+
+ const stored = JSON.parse(localStorage.getItem(key)!);
+ expect(localStorage.length).toBe(1);
+ expect(stored).toHaveLength(100);
+ expect(stored[0]).toEqual({ revision: 'next-commit', file: mocks.files[1] });
+ expect(stored[1]).toEqual({ revision: 'main', file: mocks.files[0] });
+ expect(stored.at(-1).revision).toBe('commit-97');
+ });
+});
diff --git a/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx
index ac743d231..cf91fba32 100644
--- a/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx
+++ b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx
@@ -17,6 +17,11 @@ import { getFiles } from "@/app/api/(client)/client";
const MAX_RESULTS = 100;
+type RecentlyOpenedFile = {
+ revision: string;
+ file: FileTreeItem;
+};
+
type SearchResult = {
file: FileTreeItem;
match?: {
@@ -35,7 +40,11 @@ export const FileSearchCommandDialog = () => {
const [searchQuery, setSearchQuery] = useState('');
const { navigateToPath } = useBrowseNavigation();
- const [recentlyOpened, setRecentlyOpened] = useLocalStorage(`recentlyOpenedFiles-${repoName}`, []);
+ const revision = revisionName ?? 'HEAD';
+ const [history, setHistory] = useLocalStorage(
+ `recentlyOpenedFiles-v2-${JSON.stringify(repoName)}`,
+ [],
+ );
useHotkeys("mod+p", (event) => {
event.preventDefault();
@@ -61,6 +70,16 @@ export const FileSearchCommandDialog = () => {
enabled: isFileSearchOpen,
});
+ const recentlyOpened = useMemo(() => {
+ const currentFiles = new Map(files?.map(file => [file.path, file]));
+ return history
+ .filter(entry => entry.revision === revision)
+ .flatMap(entry => {
+ const file = currentFiles.get(entry.file.path);
+ return file ? [file] : [];
+ });
+ }, [history, revision, files]);
+
const { filteredFiles, maxResultsHit } = useMemo((): { filteredFiles: SearchResult[]; maxResultsHit: boolean } => {
if (!files || isLoading) {
return {
@@ -104,9 +123,9 @@ export const FileSearchCommandDialog = () => {
}, [searchQuery]);
const onSelect = useCallback((file: FileTreeItem) => {
- setRecentlyOpened((prev) => {
- const filtered = prev.filter(f => f.path !== file.path);
- return [file, ...filtered];
+ setHistory((prev) => {
+ const filtered = prev.filter(entry => entry.revision !== revision || entry.file.path !== file.path);
+ return [{ revision, file }, ...filtered].slice(0, MAX_RESULTS);
});
navigateToPath({
repoName,
@@ -117,7 +136,7 @@ export const FileSearchCommandDialog = () => {
updateBrowseState({
isFileSearchOpen: false,
});
- }, [navigateToPath, repoName, revisionName, setRecentlyOpened, updateBrowseState]);
+ }, [navigateToPath, repoName, revisionName, revision, setHistory, updateBrowseState]);
// @note: We were hitting issues when the user types into the input field while the files are still
// loading. The workaround was to set `disabled` when loading and then focus the input field when
@@ -265,4 +284,4 @@ const ResultsSkeleton = () => {
))}
);
-};
\ No newline at end of file
+};