mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): sort file search results (#1109)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const RPC_METHODS = {
|
||||
ReadGeneratedImage: 'readGeneratedImage',
|
||||
WriteFile: 'writeFile',
|
||||
ListDirectory: 'listDirectory',
|
||||
StatFiles: 'statFiles',
|
||||
GetDirectoryTree: 'getDirectoryTree',
|
||||
UploadFile: 'uploadFile',
|
||||
DeleteUpload: 'deleteUpload',
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -100,6 +100,8 @@ export type FileSearchItem = {
|
||||
filePath: string
|
||||
fullPath: string
|
||||
fileType: 'file' | 'folder'
|
||||
size?: number
|
||||
modified?: number
|
||||
}
|
||||
|
||||
export type FileSearchResponse = {
|
||||
|
||||
Reference in New Issue
Block a user