feat(web): sort file search results (#1109)

This commit is contained in:
Ananovo
2026-07-24 10:58:07 +08:00
committed by GitHub
parent ee5b0239cb
commit df36cec01e
12 changed files with 233 additions and 31 deletions
@@ -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')
})
})
+34 -1
View File
@@ -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<StatFilesRequest, StatFilesResponse>(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<GetDirectoryTreeRequest, GetDirectoryTreeResponse>(RPC_METHODS.GetDirectoryTree, async (data) => {
logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth)
+6
View File
@@ -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<RpcStatFilesResponse> {
return await this.sessionRpc(sessionId, RPC_METHODS.StatFiles, { paths }) as RpcStatFilesResponse
}
async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise<RpcUploadFileResponse> {
return await this.sessionRpc(sessionId, RPC_METHODS.UploadFile, { sessionId, filename, content, mimeType }) as RpcUploadFileResponse
}
+6
View File
@@ -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<RpcStatFilesResponse> {
return await this.rpcGateway.statFiles(sessionId, paths)
}
async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise<RpcUploadFileResponse> {
return await this.rpcGateway.uploadFile(sessionId, filename, content, mimeType)
}
+32
View File
@@ -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<SyncEngine>
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 },
]
})
})
})
+23 -12
View File
@@ -227,22 +227,33 @@ export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono<We
}
const stdout = result.stdout ?? ''
const files = stdout
const paths = stdout
.split('\n')
.map((line) => 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 })
})
+12
View File
@@ -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
}
+1
View File
@@ -18,6 +18,7 @@ export const RPC_METHODS = {
ReadGeneratedImage: 'readGeneratedImage',
WriteFile: 'writeFile',
ListDirectory: 'listDirectory',
StatFiles: 'statFiles',
GetDirectoryTree: 'getDirectoryTree',
UploadFile: 'uploadFile',
DeleteUpload: 'deleteUpload',
+25 -2
View File
@@ -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',
])
})
})
+19 -1
View File
@@ -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')
})
}
+45 -15
View File
@@ -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 (
<Popover.Root>
<Popover.Trigger asChild>
<button type="button" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]" title={t('files.sort.title')} aria-label={t('files.sort.title')}>
<button
type="button"
className={props.embedded
? 'flex w-10 shrink-0 self-stretch items-center justify-center rounded-r-md rounded-l-sm text-[var(--app-hint)] transition-colors hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)]'
: 'flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]'}
title={t('files.sort.title')}
aria-label={t('files.sort.title')}
>
<SortIcon />
</button>
</Popover.Trigger>
@@ -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'
? <FileIcon fileName={props.file.fileName} size={22} />
: <FolderIcon className="text-[var(--app-link)]" />
@@ -266,7 +276,10 @@ function SearchResultRow(props: {
{icon}
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{props.file.fileName}</div>
<div className="truncate text-xs text-[var(--app-hint)]">{subtitle}</div>
<div className="flex min-w-0 items-center gap-2 text-xs text-[var(--app-hint)]">
<span className="truncate">{subtitle}</span>
{metadata ? <span className="shrink-0">{metadata}</span> : null}
</div>
</div>
</button>
)
@@ -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() {
<div className="bg-[var(--app-bg)]">
<div className="mx-auto flex w-full max-w-content items-center gap-2 border-b border-[var(--app-border)] p-3">
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2">
<SearchIcon className="shrink-0 text-[var(--app-hint)]" />
<div className="relative min-w-0 flex-1 rounded-md bg-[var(--app-subtle-bg)]">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--app-hint)]" />
<input
value={searchQuery}
onChange={(event) => 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 ? (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 right-10 flex items-center rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
title={t('sessions.search.clear')}
aria-label={t('sessions.search.clear')}
>
<CloseIcon className="h-3.5 w-3.5" />
</button>
) : null}
{activeTab === 'directories' || searchQuery ? (
<div className="absolute inset-y-0 right-0 flex items-stretch">
<DirectorySortMenu sort={directorySort} onChange={setDirectorySort} embedded />
</div>
) : null}
</div>
{activeTab === 'directories' && !searchQuery ? (
<DirectorySortMenu sort={directorySort} onChange={setDirectorySort} />
) : null}
<button
type="button"
onClick={handleRefresh}
@@ -546,12 +576,12 @@ export default function FilesPage() {
</div>
) : (
<div className="border-t border-[var(--app-divider)]">
{searchResults.files.map((file, index) => (
{sortedSearchResults.map((file, index) => (
<SearchResultRow
key={`${file.fullPath}-${index}`}
key={file.fullPath}
file={file}
onOpen={() => handleOpenFile(file.fullPath)}
showDivider={index < searchResults.files.length - 1}
showDivider={index < sortedSearchResults.length - 1}
/>
))}
</div>
+2
View File
@@ -100,6 +100,8 @@ export type FileSearchItem = {
filePath: string
fullPath: string
fileType: 'file' | 'folder'
size?: number
modified?: number
}
export type FileSearchResponse = {