From fb6f6975552f175d8d9c84b672887c243ac82585 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Sun, 2 Aug 2026 20:04:46 +0800 Subject: [PATCH] fix(web): display Windows file search paths correctly (#1311) * fix(web): display Windows file search paths correctly * fix(hub): scope path normalization to Windows --- hub/src/web/routes/git.test.ts | 61 +++++++++++++++++++++++++++++++ hub/src/web/routes/git.ts | 12 ++++++ web/src/routes/sessions/files.tsx | 10 ++--- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/hub/src/web/routes/git.test.ts b/hub/src/web/routes/git.test.ts index 1f569003..2de76f4b 100644 --- a/hub/src/web/routes/git.test.ts +++ b/hub/src/web/routes/git.test.ts @@ -90,4 +90,65 @@ describe('file search route', () => { ] }) }) + + it('normalizes ripgrep path separators before deriving file names and directories', async () => { + const session = { + id: 'session-1', + namespace: 'default', + active: true, + metadata: { path: 'C:\\project' } + } as unknown as Session + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: 'session-1', session }), + runRipgrep: async () => ({ + success: true, + stdout: 'src\\nested\\file.ts\nroot.ts\n' + }), + statFiles: async (_sessionId: string, paths: string[]) => ({ + success: true, + entries: paths.map((path) => ({ path, size: 10, modified: 100 })) + }) + } as unknown as Partial + + const response = await buildApp(engine).request('/api/sessions/session-1/files?query=.ts') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + files: [ + { fileName: 'file.ts', filePath: 'src/nested', fullPath: 'src/nested/file.ts', fileType: 'file', size: 10, modified: 100 }, + { fileName: 'root.ts', filePath: '', fullPath: 'root.ts', fileType: 'file', size: 10, modified: 100 }, + ] + }) + }) + + it('preserves backslashes in file names for non-Windows sessions', async () => { + const session = { + id: 'session-1', + namespace: 'default', + active: true, + metadata: { path: '/project' } + } as unknown as Session + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: 'session-1', session }), + runRipgrep: async () => ({ + success: true, + stdout: 'src/file\\name.ts\n' + }), + statFiles: async (_sessionId: string, paths: string[]) => ({ + success: true, + entries: paths.map((path) => ({ path, size: 10, modified: 100 })) + }) + } as unknown as Partial + + const response = await buildApp(engine).request('/api/sessions/session-1/files?query=.ts') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + files: [ + { fileName: 'file\\name.ts', filePath: 'src', fullPath: 'src/file\\name.ts', fileType: 'file', size: 10, modified: 100 }, + ] + }) + }) }) diff --git a/hub/src/web/routes/git.ts b/hub/src/web/routes/git.ts index 5c074ea9..5665c8eb 100644 --- a/hub/src/web/routes/git.ts +++ b/hub/src/web/routes/git.ts @@ -21,6 +21,14 @@ const generatedImageSchema = z.object({ imageId: z.string().min(1) }) +function normalizeFileSearchPath(path: string): string { + return path.replaceAll('\\', '/') +} + +function isWindowsSessionPath(path: string): boolean { + return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') +} + function parseBooleanParam(value: string | undefined): boolean | undefined { if (value === 'true') return true if (value === 'false') return false @@ -227,10 +235,14 @@ export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono path const paths = stdout .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0) + .map(normalizePath) .slice(0, limit) const metadataResult = await runRpc(() => engine.statFiles(sessionResult.sessionId, paths)) diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index 01f2b63e..d8e53552 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -260,8 +260,7 @@ function SearchResultRow(props: { onOpen: () => void showDivider: boolean }) { - const { t, locale } = useTranslation() - const subtitle = getProjectRootLabel(props.file.filePath, t) + const { locale } = useTranslation() const metadata = formatFileMetadata(props.file.size, props.file.modified, locale) const icon = props.file.fileType === 'file' ? @@ -275,11 +274,8 @@ function SearchResultRow(props: { > {icon}
-
{props.file.fileName}
-
- {subtitle} - {metadata ? {metadata} : null} -
+
{props.file.fullPath}
+ {metadata ?
{metadata}
: null}
)