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 +};