fix(web): preserve file search when returning from preview (#1251)

This commit is contained in:
Ananovo
2026-07-30 23:24:23 +08:00
committed by GitHub
parent 3a931e3c81
commit 578646812e
5 changed files with 219 additions and 21 deletions
+24 -1
View File
@@ -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({})
})
})
+16 -7
View File
@@ -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
}
+15 -2
View File
@@ -1295,15 +1295,21 @@ const sessionDetailRoute = createRoute({
const sessionFilesRoute = createRoute({
getParentRoute: () => sessionDetailRoute,
path: 'files',
validateSearch: (search: Record<string, unknown>): { tab?: 'changes' | 'directories' } => {
validateSearch: (search: Record<string, unknown>): { 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,
+139
View File
@@ -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(
<QueryClientProvider client={queryClient}>
<I18nProvider>
<FilesPage />
</I18nProvider>
</QueryClientProvider>
)
}
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,
})
})
})
+25 -11
View File
@@ -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<HTMLDivElement>(null)
const initialTab = search.tab === 'directories' ? 'directories' : 'changes'
const [activeTab, setActiveTab] = useState<'changes' | 'directories'>(initialTab)
const [directorySort, setDirectorySort] = useState<DirectorySort>(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({