From df36cec01e25f78452544282d42050d9e0795e25 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Fri, 24 Jul 2026 10:58:07 +0800 Subject: [PATCH] feat(web): sort file search results (#1109) --- .../common/handlers/directories.test.ts | 28 +++++++++ .../modules/common/handlers/directories.ts | 35 ++++++++++- hub/src/sync/rpcGateway.ts | 6 ++ hub/src/sync/syncEngine.ts | 6 ++ hub/src/web/routes/git.test.ts | 32 ++++++++++ hub/src/web/routes/git.ts | 35 +++++++---- shared/src/apiTypes.ts | 12 ++++ shared/src/rpcMethods.ts | 1 + web/src/lib/directory-sort.test.ts | 27 ++++++++- web/src/lib/directory-sort.ts | 20 ++++++- web/src/routes/sessions/files.tsx | 60 ++++++++++++++----- web/src/types/api.ts | 2 + 12 files changed, 233 insertions(+), 31 deletions(-) diff --git a/cli/src/modules/common/handlers/directories.test.ts b/cli/src/modules/common/handlers/directories.test.ts index f4fc7afa..0c50735d 100644 --- a/cli/src/modules/common/handlers/directories.test.ts +++ b/cli/src/modules/common/handlers/directories.test.ts @@ -63,4 +63,32 @@ describe('directory RPC handlers', () => { expect(link?.type).toBe('other') expect(link?.size).toBeUndefined() }) + + it('returns metadata for a batch of searched files', async () => { + const response = await rpc.handleRequest({ + method: 'session-test:statFiles', + params: JSON.stringify({ paths: ['README.md', 'src/index.ts', 'missing.txt'] }) + }) + const parsed = JSON.parse(response) as { + success: boolean + entries?: Array<{ path: string; size?: number; modified?: number }> + } + + expect(parsed.success).toBe(true) + expect(parsed.entries).toHaveLength(3) + expect(parsed.entries?.[0]).toMatchObject({ path: 'README.md', size: 6 }) + expect(parsed.entries?.[0]?.modified).toBeTypeOf('number') + expect(parsed.entries?.[2]).toEqual({ path: 'missing.txt' }) + }) + + it('rejects stat paths outside the session working directory', async () => { + const response = await rpc.handleRequest({ + method: 'session-test:statFiles', + params: JSON.stringify({ paths: ['../outside.txt'] }) + }) + const parsed = JSON.parse(response) as { success: boolean; error?: string } + + expect(parsed.success).toBe(false) + expect(parsed.error).toContain('outside the working directory') + }) }) diff --git a/cli/src/modules/common/handlers/directories.ts b/cli/src/modules/common/handlers/directories.ts index bc142d73..532ef1b2 100644 --- a/cli/src/modules/common/handlers/directories.ts +++ b/cli/src/modules/common/handlers/directories.ts @@ -1,7 +1,7 @@ import { logger } from '@/ui/logger' import { readdir, stat } from 'fs/promises' import { basename, join, resolve } from 'path' -import type { DirectoryEntry, ListDirectoryResponse } from '@hapi/protocol/apiTypes' +import type { DirectoryEntry, ListDirectoryResponse, StatFilesResponse } from '@hapi/protocol/apiTypes' import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { validatePath } from '../pathSecurity' @@ -16,6 +16,10 @@ interface GetDirectoryTreeRequest { maxDepth: number } +interface StatFilesRequest { + paths: string[] +} + interface TreeNode { name: string path: string @@ -93,6 +97,35 @@ export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager, } }) + rpcHandlerManager.registerHandler(RPC_METHODS.StatFiles, async (data) => { + if (!Array.isArray(data.paths) || data.paths.length > 500) { + return rpcError('Invalid file paths') + } + + for (const path of data.paths) { + const validation = validatePath(path, workingDirectory) + if (!validation.valid) { + return rpcError(validation.error ?? 'Invalid file path') + } + } + + const entries = await Promise.all(data.paths.map(async (path) => { + try { + const stats = await stat(resolve(workingDirectory, path)) + return { + path, + size: stats.size, + modified: stats.mtime.getTime() + } + } catch (error) { + logger.debug(`Failed to stat ${path}:`, error) + return { path } + } + })) + + return { success: true, entries } + }) + rpcHandlerManager.registerHandler(RPC_METHODS.GetDirectoryTree, async (data) => { logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index e79d7705..ee5d5a0d 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -26,6 +26,7 @@ import type { OpencodeReasoningEffortResponse, PathExistsResponse, SlashCommandsResponse, + StatFilesResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' import type { Server } from 'socket.io' @@ -62,6 +63,7 @@ export type RpcUploadFileResponse = UploadFileResponse export type RpcDeleteUploadResponse = DeleteUploadResponse export type RpcDirectoryEntry = DirectoryEntry export type RpcListDirectoryResponse = ListDirectoryResponse +export type RpcStatFilesResponse = StatFilesResponse export type RpcPathExistsResponse = PathExistsResponse export type RpcCodexModel = CodexModelSummary export type RpcListCodexModelsResponse = CodexModelsResponse @@ -260,6 +262,10 @@ export class RpcGateway { return await this.sessionRpc(sessionId, RPC_METHODS.ListDirectory, { path }) as RpcListDirectoryResponse } + async statFiles(sessionId: string, paths: string[]): Promise { + return await this.sessionRpc(sessionId, RPC_METHODS.StatFiles, { paths }) as RpcStatFilesResponse + } + async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.UploadFile, { sessionId, filename, content, mimeType }) as RpcUploadFileResponse } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 4e4451e9..305ad47f 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -29,6 +29,7 @@ import { type RpcDeleteUploadResponse, type RpcGeneratedImageResponse, type RpcListDirectoryResponse, + type RpcStatFilesResponse, type RpcListCodexModelsResponse, type RpcArchiveCodexSessionResponse, type RpcListCursorModelsResponse, @@ -54,6 +55,7 @@ export type { RpcDeleteUploadResponse, RpcGeneratedImageResponse, RpcListDirectoryResponse, + RpcStatFilesResponse, RpcListCodexModelsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, @@ -1666,6 +1668,10 @@ export class SyncEngine { return await this.rpcGateway.listDirectory(sessionId, path) } + async statFiles(sessionId: string, paths: string[]): Promise { + return await this.rpcGateway.statFiles(sessionId, paths) + } + async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { return await this.rpcGateway.uploadFile(sessionId, filename, content, mimeType) } diff --git a/hub/src/web/routes/git.test.ts b/hub/src/web/routes/git.test.ts index 320f1eb9..1f569003 100644 --- a/hub/src/web/routes/git.test.ts +++ b/hub/src/web/routes/git.test.ts @@ -59,3 +59,35 @@ describe('generated images route', () => { expect(rpcCalls).toBe(0) }) }) + +describe('file search route', () => { + it('adds size and modification metadata to search results', 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/large.txt\nsrc/small.txt\n' + }), + statFiles: async (_sessionId: string, paths: string[]) => ({ + success: true, + entries: paths.map((path, index) => ({ path, size: index ? 10 : 500, modified: index ? 100 : 200 })) + }) + } as unknown as Partial + + const response = await buildApp(engine).request('/api/sessions/session-1/files?query=.txt') + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + files: [ + { fileName: 'large.txt', filePath: 'src', fullPath: 'src/large.txt', fileType: 'file', size: 500, modified: 200 }, + { fileName: 'small.txt', filePath: 'src', fullPath: 'src/small.txt', fileType: 'file', size: 10, modified: 100 }, + ] + }) + }) +}) diff --git a/hub/src/web/routes/git.ts b/hub/src/web/routes/git.ts index 08a889e3..5c074ea9 100644 --- a/hub/src/web/routes/git.ts +++ b/hub/src/web/routes/git.ts @@ -227,22 +227,33 @@ export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono line.trim()) .filter((line) => line.length > 0) .slice(0, limit) - .map((fullPath) => { - const parts = fullPath.split('/') - const fileName = parts[parts.length - 1] || fullPath - const filePath = parts.slice(0, -1).join('/') - return { - fileName, - filePath, - fullPath, - fileType: 'file' as const - } - }) + + const metadataResult = await runRpc(() => engine.statFiles(sessionResult.sessionId, paths)) + const metadataByPath = new Map( + metadataResult.success + ? (metadataResult.entries ?? []).map((entry) => [entry.path, entry] as const) + : [] + ) + + const files = paths.map((fullPath) => { + const parts = fullPath.split('/') + const fileName = parts[parts.length - 1] || fullPath + const filePath = parts.slice(0, -1).join('/') + const metadata = metadataByPath.get(fullPath) + return { + fileName, + filePath, + fullPath, + fileType: 'file' as const, + size: metadata?.size, + modified: metadata?.modified + } + }) return c.json({ success: true, files }) }) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 8448fff3..ff06fabb 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -400,6 +400,18 @@ export type ListDirectoryResponse = { export type RpcListDirectoryResponse = ListDirectoryResponse +export type FileMetadataEntry = { + path: string + size?: number + modified?: number +} + +export type StatFilesResponse = { + success: boolean + entries?: FileMetadataEntry[] + error?: string +} + export type MachineDirectoryEntry = DirectoryEntry & { isGitRepo?: boolean } diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index c6a195a8..9c461a72 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -18,6 +18,7 @@ export const RPC_METHODS = { ReadGeneratedImage: 'readGeneratedImage', WriteFile: 'writeFile', ListDirectory: 'listDirectory', + StatFiles: 'statFiles', GetDirectoryTree: 'getDirectoryTree', UploadFile: 'uploadFile', DeleteUpload: 'deleteUpload', diff --git a/web/src/lib/directory-sort.test.ts b/web/src/lib/directory-sort.test.ts index 70673165..c6f52037 100644 --- a/web/src/lib/directory-sort.test.ts +++ b/web/src/lib/directory-sort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { DirectoryEntry } from '@/types/api' -import { sortDirectoryEntries } from './directory-sort' +import type { DirectoryEntry, FileSearchItem } from '@/types/api' +import { sortDirectoryEntries, sortFileSearchItems } from './directory-sort' const entries: DirectoryEntry[] = [ { name: 'large.txt', type: 'file', size: 500, modified: 20 }, @@ -29,3 +29,26 @@ describe('directory sorting', () => { ]) }) }) + +describe('file search sorting', () => { + const results: FileSearchItem[] = [ + { fileName: 'large10.txt', filePath: 'src', fullPath: 'src/large10.txt', fileType: 'file', size: 500, modified: 20 }, + { fileName: 'small2.txt', filePath: 'src', fullPath: 'src/small2.txt', fileType: 'file', size: 10, modified: 10 }, + { fileName: 'unknown.txt', filePath: 'src', fullPath: 'src/unknown.txt', fileType: 'file' }, + ] + + it('uses natural filename ordering', () => { + expect(sortFileSearchItems(results, { field: 'name', direction: 'asc' }, 'en').map((entry) => entry.fileName)).toEqual([ + 'large10.txt', 'small2.txt', 'unknown.txt', + ]) + }) + + it('sorts metadata and keeps missing values last', () => { + expect(sortFileSearchItems(results, { field: 'size', direction: 'asc' }, 'en').map((entry) => entry.fileName)).toEqual([ + 'small2.txt', 'large10.txt', 'unknown.txt', + ]) + expect(sortFileSearchItems(results, { field: 'modified', direction: 'desc' }, 'en').map((entry) => entry.fileName)).toEqual([ + 'large10.txt', 'small2.txt', 'unknown.txt', + ]) + }) +}) diff --git a/web/src/lib/directory-sort.ts b/web/src/lib/directory-sort.ts index 10173267..badc5af8 100644 --- a/web/src/lib/directory-sort.ts +++ b/web/src/lib/directory-sort.ts @@ -1,4 +1,4 @@ -import type { DirectoryEntry } from '@/types/api' +import type { DirectoryEntry, FileSearchItem } from '@/types/api' import type { Locale } from '@/lib/use-translation' export type DirectorySortField = 'name' | 'modified' | 'size' @@ -39,3 +39,21 @@ export function sortDirectoryEntries( return result || byName(left, right, 'asc') }) } + +export function sortFileSearchItems( + entries: FileSearchItem[], + sort: DirectorySort, + locale: Locale, +): FileSearchItem[] { + const collator = new Intl.Collator(locale, { numeric: true, sensitivity: 'base' }) + const byName = (left: FileSearchItem, right: FileSearchItem, direction: DirectorySortDirection) => { + const result = collator.compare(left.fileName, right.fileName) + return direction === 'asc' ? result : -result + } + + return [...entries].sort((left, right) => { + if (sort.field === 'name') return byName(left, right, sort.direction) + const result = compareOptionalNumbers(left[sort.field], right[sort.field], sort.direction) + return result || byName(left, right, 'asc') + }) +} diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index 03bdc655..b7ed7767 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -18,15 +18,17 @@ import { } from '@/lib/files-i18n' import { encodeBase64 } from '@/lib/utils' import { queryKeys } from '@/lib/query-keys' +import { formatFileMetadata } from '@/lib/file-metadata' import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from '@/lib/use-translation' import * as Popover from '@radix-ui/react-popover' -import { CheckIcon } from '@/components/icons' +import { CheckIcon, CloseIcon } from '@/components/icons' import { DEFAULT_DIRECTORY_SORT, type DirectorySort, type DirectorySortDirection, type DirectorySortField, + sortFileSearchItems, } from '@/lib/directory-sort' function RefreshIcon(props: { className?: string }) { @@ -72,7 +74,7 @@ function readDirectorySort(): DirectorySort { return DEFAULT_DIRECTORY_SORT } -function DirectorySortMenu(props: { sort: DirectorySort; onChange: (sort: DirectorySort) => void }) { +function DirectorySortMenu(props: { sort: DirectorySort; onChange: (sort: DirectorySort) => void; embedded?: boolean }) { const { t } = useTranslation() const fields: Array<{ value: DirectorySortField; label: string }> = [ { value: 'name', label: t('files.sort.name') }, @@ -89,7 +91,14 @@ function DirectorySortMenu(props: { sort: DirectorySort; onChange: (sort: Direct return ( - @@ -251,8 +260,9 @@ function SearchResultRow(props: { onOpen: () => void showDivider: boolean }) { - const { t } = useTranslation() + const { t, locale } = useTranslation() const subtitle = getProjectRootLabel(props.file.filePath, t) + const metadata = formatFileMetadata(props.file.size, props.file.modified, locale) const icon = props.file.fileType === 'file' ? : @@ -266,7 +276,10 @@ function SearchResultRow(props: { {icon}
{props.file.fileName}
-
{subtitle}
+
+ {subtitle} + {metadata ? {metadata} : null} +
) @@ -297,7 +310,7 @@ const SCROLL_KEY_PREFIX = 'hapi-dir-scroll-' export default function FilesPage() { const { api } = useAppContext() - const { t } = useTranslation() + const { t, locale } = useTranslation() const navigate = useNavigate() const queryClient = useQueryClient() const goBack = useAppGoBack() @@ -351,6 +364,10 @@ export default function FilesPage() { const searchResults = useSessionFileSearch(api, sessionId, searchQuery, { enabled: shouldSearch }) + const sortedSearchResults = useMemo( + () => sortFileSearchItems(searchResults.files, directorySort, locale), + [directorySort, locale, searchResults.files] + ) const handleOpenFile = useCallback((path: string, staged?: boolean) => { const fileSearch = staged === undefined @@ -456,20 +473,33 @@ export default function FilesPage() {
-
- +
+ setSearchQuery(event.target.value)} placeholder={t('files.page.searchPlaceholder')} - className="min-w-0 flex-1 bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none" + className="w-full bg-transparent py-2 pl-10 pr-20 text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none" autoCapitalize="none" autoCorrect="off" /> + {searchQuery ? ( + + ) : null} + {activeTab === 'directories' || searchQuery ? ( +
+ +
+ ) : null}
- {activeTab === 'directories' && !searchQuery ? ( - - ) : null}