-
Notifications
You must be signed in to change notification settings - Fork 376
fix(web): scope recent files to the browse revision #1686
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<FileSearchCommandDialog />); | ||
| 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(<FileSearchCommandDialog />); | ||
| expect(screen.queryByText('main.ts')).toBeNull(); | ||
| selectFile('feature.ts'); | ||
|
|
||
| mocks.params.revisionName = 'main'; | ||
| view.rerender(<FileSearchCommandDialog />); | ||
| expect(screen.getByText('main.ts')).toBeTruthy(); | ||
| expect(screen.queryByText('feature.ts')).toBeNull(); | ||
|
|
||
| view.unmount(); | ||
| mocks.params.revisionName = 'feature'; | ||
| render(<FileSearchCommandDialog />); | ||
| 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(<FileSearchCommandDialog />); | ||
| selectFile('main.ts'); | ||
|
|
||
| mocks.params.revisionName = 'HEAD'; | ||
| view.rerender(<FileSearchCommandDialog />); | ||
| 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(<FileSearchCommandDialog />); | ||
|
|
||
| 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(<FileSearchCommandDialog />); | ||
| selectFile('main.ts'); | ||
|
|
||
| mocks.params = { repoName: 'repo', revisionName: 'branch@feature' }; | ||
| view.rerender(<FileSearchCommandDialog />); | ||
| expect(screen.queryByText('main.ts')).toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,7 +35,10 @@ export const FileSearchCommandDialog = () => { | |
| const [searchQuery, setSearchQuery] = useState(''); | ||
| const { navigateToPath } = useBrowseNavigation(); | ||
|
|
||
| const [recentlyOpened, setRecentlyOpened] = useLocalStorage<FileTreeItem[]>(`recentlyOpenedFiles-${repoName}`, []); | ||
| const [recentlyOpened, setRecentlyOpened] = useLocalStorage<FileTreeItem[]>( | ||
| `recentlyOpenedFiles-${JSON.stringify([repoName, revisionName ?? 'HEAD'])}`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Each distinct (repo, revision) pair — including every commit SHA the user browses at — permanently creates a new localStorage entry that is never evicted, and legacy Prompt for AI agents |
||
| [], | ||
| ); | ||
|
|
||
| useHotkeys("mod+p", (event) => { | ||
| event.preventDefault(); | ||
|
|
@@ -265,4 +268,4 @@ const ResultsSkeleton = () => { | |
| ))} | ||
| </div> | ||
| ); | ||
| }; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Scoping the key by revision closes the cross-revision case, but the "Recently opened" list is still rendered as a stored snapshot without validating any entry against the files that currently exist at that revision (
recentlyOpened.map(...)runs with no cross-check againstfiles). If the revision's content moved since the file was opened — a branch advanced and deleted/renamed the path — selecting the entry callsnavigateToPathto a blob that no longer exists, which is the same end-user failure mode #1387 describes, just narrowed to the matching key. FilterrecentlyOpenedagainst the currentfiles(or remove missing paths on read), e.g.recentlyOpened.filter(r => files.some(f => f.path === r.path)).Prompt for AI agents