Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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(<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();
});

it('hides files removed from the current revision when its file list changes', () => {
const view = render(<FileSearchCommandDialog />);
selectFile('main.ts');
selectFile('feature.ts');

mocks.files = mocks.files.filter(file => file.name !== 'main.ts');
view.rerender(<FileSearchCommandDialog />);

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(<FileSearchCommandDialog />);
selectFile('main.ts');

mocks.params.revisionName = 'next-commit';
view.rerender(<FileSearchCommandDialog />);
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand All @@ -35,7 +40,11 @@ export const FileSearchCommandDialog = () => {
const [searchQuery, setSearchQuery] = useState('');
const { navigateToPath } = useBrowseNavigation();

const [recentlyOpened, setRecentlyOpened] = useLocalStorage<FileTreeItem[]>(`recentlyOpenedFiles-${repoName}`, []);
const revision = revisionName ?? 'HEAD';
const [history, setHistory] = useLocalStorage<RecentlyOpenedFile[]>(
`recentlyOpenedFiles-v2-${JSON.stringify(repoName)}`,
[],
);

useHotkeys("mod+p", (event) => {
event.preventDefault();
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -265,4 +284,4 @@ const ResultsSkeleton = () => {
))}
</div>
);
};
};