diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f4d08c7..bbf24384f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Made the default home page configurable with `DEFAULT_HOME_VIEW_PAGE`, defaulting to Code Search and supporting Ask. [#1677](https://github.com/sourcebot-dev/sourcebot/pull/1677) - Require authentication for the streaming and blocking Ask APIs in Public SaaS deployments. [#1679](https://github.com/sourcebot-dev/sourcebot/pull/1679) +- Fixed recently opened files appearing across different browse revisions. [#1686](https://github.com/sourcebot-dev/sourcebot/pull/1686) ## [5.1.14] - 2026-09-17 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..22926e684 --- /dev/null +++ b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.test.tsx @@ -0,0 +1,116 @@ +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(), +})); + +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: [ + { type: 'blob', name: 'main.ts', path: 'src/main.ts' }, + { type: 'blob', name: 'feature.ts', path: 'src/feature.ts' }, + ], + 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' }; + 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(); + }); +}); diff --git a/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx index ac743d231..7b1a16f0e 100644 --- a/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx +++ b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx @@ -35,7 +35,10 @@ export const FileSearchCommandDialog = () => { const [searchQuery, setSearchQuery] = useState(''); const { navigateToPath } = useBrowseNavigation(); - const [recentlyOpened, setRecentlyOpened] = useLocalStorage(`recentlyOpenedFiles-${repoName}`, []); + const [recentlyOpened, setRecentlyOpened] = useLocalStorage( + `recentlyOpenedFiles-${JSON.stringify([repoName, revisionName ?? 'HEAD'])}`, + [], + ); useHotkeys("mod+p", (event) => { event.preventDefault(); @@ -265,4 +268,4 @@ const ResultsSkeleton = () => { ))} ); -}; \ No newline at end of file +};