diff --git a/web/src/hooks/useAppGoBack.test.ts b/web/src/hooks/useAppGoBack.test.ts index 91101ff3..1bee2418 100644 --- a/web/src/hooks/useAppGoBack.test.ts +++ b/web/src/hooks/useAppGoBack.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getSettingsBackTarget } from './useAppGoBack' +import { getSessionFilesBackSearch, getSettingsBackTarget } from './useAppGoBack' describe('getSettingsBackTarget', () => { it.each([ @@ -14,3 +14,26 @@ describe('getSettingsBackTarget', () => { expect(getSettingsBackTarget(pathname)).toBe(target) }) }) + +describe('getSessionFilesBackSearch', () => { + it('preserves the directory tab and file search query', () => { + expect(getSessionFilesBackSearch({ + path: 'encoded-path', + staged: false, + tab: 'directories', + query: '感', + })).toEqual({ + tab: 'directories', + query: '感', + }) + }) + + it('drops unrelated and invalid search values', () => { + expect(getSessionFilesBackSearch({ + path: 'encoded-path', + tab: 'changes', + query: '', + })).toEqual({}) + expect(getSessionFilesBackSearch(null)).toEqual({}) + }) +}) diff --git a/web/src/hooks/useAppGoBack.ts b/web/src/hooks/useAppGoBack.ts index 470e95d7..51c09941 100644 --- a/web/src/hooks/useAppGoBack.ts +++ b/web/src/hooks/useAppGoBack.ts @@ -8,6 +8,21 @@ export function getSettingsBackTarget(pathname: string): string | null { return null } +export function getSessionFilesBackSearch(search: unknown): { + tab?: 'directories' + query?: string +} { + if (!search || typeof search !== 'object') return {} + + const currentSearch = search as { tab?: unknown; query?: unknown } + return { + ...(currentSearch.tab === 'directories' ? { tab: 'directories' as const } : {}), + ...(typeof currentSearch.query === 'string' && currentSearch.query.length > 0 + ? { query: currentSearch.query } + : {}), + } +} + export function useAppGoBack(): () => void { const navigate = useNavigate() const router = useRouter() @@ -31,13 +46,7 @@ export function useAppGoBack(): () => void { // For single file view, go back to files list if (pathname.match(/^\/sessions\/[^/]+\/file$/)) { const filesPath = pathname.replace(/\/file$/, '/files') - - const tab = (search && typeof search === 'object' && 'tab' in search) - ? (search as { tab?: unknown }).tab - : undefined - const nextSearch = tab === 'directories' ? { tab: 'directories' as const } : {} - - navigate({ to: filesPath, search: nextSearch }) + navigate({ to: filesPath, search: getSessionFilesBackSearch(search) }) return } diff --git a/web/src/router.tsx b/web/src/router.tsx index 4a04692f..f87b57a6 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1295,15 +1295,21 @@ const sessionDetailRoute = createRoute({ const sessionFilesRoute = createRoute({ getParentRoute: () => sessionDetailRoute, path: 'files', - validateSearch: (search: Record): { tab?: 'changes' | 'directories' } => { + validateSearch: (search: Record): { tab?: 'changes' | 'directories'; query?: string } => { const tabValue = typeof search.tab === 'string' ? search.tab : undefined const tab = tabValue === 'directories' ? 'directories' : tabValue === 'changes' ? 'changes' : undefined + const query = typeof search.query === 'string' && search.query.length > 0 + ? search.query + : undefined - return tab ? { tab } : {} + return { + ...(tab ? { tab } : {}), + ...(query ? { query } : {}), + } }, component: FilesPage, }) @@ -1318,6 +1324,7 @@ type SessionFileSearch = { path: string staged?: boolean tab?: 'changes' | 'directories' + query?: string } const sessionFileRoute = createRoute({ @@ -1337,6 +1344,9 @@ const sessionFileRoute = createRoute({ : tabValue === 'changes' ? 'changes' : undefined + const query = typeof search.query === 'string' && search.query.length > 0 + ? search.query + : undefined const result: SessionFileSearch = { path } if (staged !== undefined) { @@ -1345,6 +1355,9 @@ const sessionFileRoute = createRoute({ if (tab !== undefined) { result.tab = tab } + if (query !== undefined) { + result.query = query + } return result }, component: FilePage, diff --git a/web/src/routes/sessions/files.test.tsx b/web/src/routes/sessions/files.test.tsx new file mode 100644 index 00000000..1cc8a941 --- /dev/null +++ b/web/src/routes/sessions/files.test.tsx @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { I18nProvider } from '@/lib/i18n-context' +import { encodeBase64 } from '@/lib/utils' +import FilesPage from './files' + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + fileSearch: vi.fn(), + search: { + tab: 'directories' as const, + query: '感', + }, +})) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => mocks.navigate, + useParams: () => ({ sessionId: 'session-1' }), + useSearch: () => mocks.search, +})) + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ api: {} }), +})) + +vi.mock('@/hooks/useAppGoBack', () => ({ + useAppGoBack: () => vi.fn(), +})) + +vi.mock('@/hooks/queries/useSession', () => ({ + useSession: () => ({ + session: { + id: 'session-1', + metadata: { path: '/workspace/project' }, + }, + }), +})) + +vi.mock('@/hooks/queries/useGitStatusFiles', () => ({ + useGitStatusFiles: () => ({ + status: null, + error: null, + isLoading: false, + refetch: vi.fn(), + }), +})) + +vi.mock('@/hooks/queries/useSessionFileSearch', () => ({ + useSessionFileSearch: (...args: unknown[]) => { + mocks.fileSearch(...args) + return { + files: [{ + fileName: '感言.ts', + filePath: 'src', + fullPath: 'src/感言.ts', + fileType: 'file' as const, + }], + error: null, + isLoading: false, + refetch: vi.fn(), + } + }, +})) + +vi.mock('@/components/SessionHeader', () => ({ + SessionHeader: () => null, +})) + +vi.mock('@/components/SessionFiles/DirectoryTree', () => ({ + DirectoryTree: () => null, +})) + +function renderFilesPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + + return render( + + + + + + ) +} + +describe('FilesPage search navigation', () => { + beforeEach(() => { + vi.clearAllMocks() + window.localStorage.clear() + window.sessionStorage.clear() + }) + + it('restores the route query and carries it through file navigation', () => { + renderFilesPage() + + const input = screen.getByRole('textbox') + expect(input).toHaveValue('感') + expect(mocks.fileSearch).toHaveBeenCalledWith( + expect.anything(), + 'session-1', + '感', + { enabled: true }, + ) + + fireEvent.click(screen.getByRole('button', { name: /感言\.ts/ })) + expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/sessions/$sessionId/file', + params: { sessionId: 'session-1' }, + search: { + path: encodeBase64('src/感言.ts'), + tab: 'directories', + query: '感', + }, + }) + + fireEvent.change(input, { target: { value: '言' } }) + expect(mocks.navigate).toHaveBeenLastCalledWith({ + to: '/sessions/$sessionId/files', + params: { sessionId: 'session-1' }, + search: { + tab: 'directories', + query: '言', + }, + replace: true, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + expect(mocks.navigate).toHaveBeenLastCalledWith({ + to: '/sessions/$sessionId/files', + params: { sessionId: 'session-1' }, + search: { tab: 'directories' }, + replace: true, + }) + }) +}) diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index b7ed7767..01f2b63e 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -317,12 +317,24 @@ export default function FilesPage() { const { sessionId } = useParams({ from: '/sessions/$sessionId/files' }) const search = useSearch({ from: '/sessions/$sessionId/files' }) const { session } = useSession(api, sessionId) - const [searchQuery, setSearchQuery] = useState('') const scrollRef = useRef(null) const initialTab = search.tab === 'directories' ? 'directories' : 'changes' const [activeTab, setActiveTab] = useState<'changes' | 'directories'>(initialTab) const [directorySort, setDirectorySort] = useState(readDirectorySort) + const searchQuery = search.query ?? '' + + const setSearchQuery = useCallback((query: string) => { + navigate({ + to: '/sessions/$sessionId/files', + params: { sessionId }, + search: { + ...(activeTab === 'directories' ? { tab: 'directories' as const } : {}), + ...(query ? { query } : {}), + }, + replace: true, + }) + }, [activeTab, navigate, sessionId]) useEffect(() => { try { @@ -370,19 +382,18 @@ export default function FilesPage() { ) const handleOpenFile = useCallback((path: string, staged?: boolean) => { - const fileSearch = staged === undefined - ? (activeTab === 'directories' - ? { path: encodeBase64(path), tab: 'directories' as const } - : { path: encodeBase64(path) }) - : (activeTab === 'directories' - ? { path: encodeBase64(path), staged, tab: 'directories' as const } - : { path: encodeBase64(path), staged }) + const fileSearch = { + path: encodeBase64(path), + ...(staged !== undefined ? { staged } : {}), + ...(activeTab === 'directories' ? { tab: 'directories' as const } : {}), + ...(searchQuery ? { query: searchQuery } : {}), + } navigate({ to: '/sessions/$sessionId/file', params: { sessionId }, search: fileSearch }) - }, [activeTab, navigate, sessionId]) + }, [activeTab, navigate, searchQuery, sessionId]) const branchLabel = getDetachedBranchLabel(gitStatus?.branch, t) const showGitErrorBanner = Boolean(gitError) @@ -423,10 +434,13 @@ export default function FilesPage() { navigate({ to: '/sessions/$sessionId/files', params: { sessionId }, - search: nextTab === 'changes' ? {} : { tab: nextTab }, + search: { + ...(nextTab === 'directories' ? { tab: nextTab } : {}), + ...(searchQuery ? { query: searchQuery } : {}), + }, replace: true, }) - }, [navigate, sessionId]) + }, [navigate, searchQuery, sessionId]) const handleToggleFiles = useCallback(() => { navigate({