feat(web): enhance directory browser with metadata and sorting (#1055)

* feat(web): enhance directory browser with metadata and sorting

* test(web): update ToolCard fixture timestamps
This commit is contained in:
Ananovo
2026-07-18 12:26:03 +08:00
committed by GitHub
parent 2c559373c9
commit 058a375e6d
10 changed files with 332 additions and 27 deletions
@@ -4,6 +4,10 @@ import { FileIcon } from '@/components/FileIcon'
import { useSessionDirectory } from '@/hooks/queries/useSessionDirectory'
import { formatDirectoryError } from '@/lib/files-i18n'
import { useTranslation } from '@/lib/use-translation'
import { useToast } from '@/lib/toast-context'
import { downloadBase64File } from '@/lib/file-download'
import { formatFileMetadata } from '@/lib/file-metadata'
import { sortDirectoryEntries, type DirectorySort } from '@/lib/directory-sort'
function ChevronIcon(props: { className?: string; collapsed: boolean }) {
return (
@@ -43,6 +47,16 @@ function FolderIcon(props: { className?: string }) {
)
}
function DownloadIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
}
function DirectorySkeleton(props: { depth: number; rows?: number }) {
const rows = props.rows ?? 4
const indent = 12 + props.depth * 14
@@ -84,20 +98,48 @@ function DirectoryNode(props: {
onOpenFile: (path: string) => void
expanded: Set<string>
onToggle: (path: string) => void
sort: DirectorySort
}) {
const { t } = useTranslation()
const { t, locale } = useTranslation()
const toast = useToast()
const [downloadingPath, setDownloadingPath] = useState<string | null>(null)
const isExpanded = props.expanded.has(props.path)
const { entries, error, isLoading } = useSessionDirectory(props.api, props.sessionId, props.path, {
enabled: isExpanded
})
const directories = useMemo(() => entries.filter((entry) => entry.type === 'directory'), [entries])
const files = useMemo(() => entries.filter((entry) => entry.type === 'file'), [entries])
const sortedEntries = useMemo(
() => sortDirectoryEntries(entries, props.sort, locale),
[entries, locale, props.sort],
)
const directories = useMemo(() => sortedEntries.filter((entry) => entry.type === 'directory'), [sortedEntries])
const files = useMemo(() => sortedEntries.filter((entry) => entry.type === 'file'), [sortedEntries])
const childDepth = props.depth + 1
const indent = 12 + props.depth * 14
const childIndent = 12 + childDepth * 14
const handleDownload = async (filePath: string, fileName: string) => {
if (!props.api || downloadingPath) return
setDownloadingPath(filePath)
try {
const result = await props.api.readSessionFile(props.sessionId, filePath)
if (!result.success || result.content === undefined) {
throw new Error(result.error ?? t('files.directories.download.error.default'))
}
downloadBase64File(fileName, result.content)
} catch (error) {
toast.addToast({
title: t('files.directories.download.error.title'),
body: error instanceof Error ? error.message : t('files.directories.download.error.default'),
sessionId: props.sessionId,
url: `/sessions/${props.sessionId}/files?tab=directories`,
})
} finally {
setDownloadingPath(null)
}
}
return (
<div>
<button
@@ -133,26 +175,38 @@ function DirectoryNode(props: {
onOpenFile={props.onOpenFile}
expanded={props.expanded}
onToggle={props.onToggle}
sort={props.sort}
/>
)
})}
{files.map((entry) => {
const filePath = props.path ? `${props.path}/${entry.name}` : entry.name
const metadata = formatFileMetadata(entry.size, entry.modified, locale)
const isDownloading = downloadingPath === filePath
return (
<button
<div
key={filePath}
type="button"
onClick={() => props.onOpenFile(filePath)}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-[var(--app-subtle-bg)] transition-colors"
style={{ paddingLeft: childIndent }}
>
<span className="h-4 w-4" />
<FileIcon fileName={entry.name} size={22} />
<div className="min-w-0 flex-1">
<button type="button" onClick={() => props.onOpenFile(filePath)} className="min-w-0 flex-1 text-left">
<div className="truncate font-medium">{entry.name}</div>
</div>
</button>
{metadata ? <div className="truncate text-xs text-[var(--app-hint)]">{metadata}</div> : null}
</button>
<button
type="button"
onClick={() => void handleDownload(filePath, entry.name)}
disabled={Boolean(downloadingPath)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)] disabled:cursor-wait disabled:opacity-50"
title={t('files.directories.download')}
aria-label={t('files.directories.downloadNamed', { name: entry.name })}
>
<DownloadIcon className={`h-4 w-4 ${isDownloading ? 'animate-pulse' : ''}`} />
</button>
</div>
)
})}
@@ -199,6 +253,7 @@ export function DirectoryTree(props: {
sessionId: string
rootLabel: string
onOpenFile: (path: string) => void
sort: DirectorySort
}) {
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded(props.sessionId))
@@ -229,6 +284,7 @@ export function DirectoryTree(props: {
onOpenFile={props.onOpenFile}
expanded={expanded}
onToggle={handleToggle}
sort={props.sort}
/>
</div>
)
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import type { DirectoryEntry } from '@/types/api'
import { sortDirectoryEntries } from './directory-sort'
const entries: DirectoryEntry[] = [
{ name: 'large.txt', type: 'file', size: 500, modified: 20 },
{ name: 'folder-b', type: 'directory', size: 999, modified: 30 },
{ name: 'small.txt', type: 'file', size: 10, modified: 10 },
{ name: 'unknown.txt', type: 'file' },
{ name: 'folder-a', type: 'directory', size: 1, modified: 5 },
]
describe('directory sorting', () => {
it('defaults to folders first and name ascending', () => {
expect(sortDirectoryEntries(entries, { field: 'name', direction: 'asc' }, 'en').map((entry) => entry.name)).toEqual([
'folder-a', 'folder-b', 'large.txt', 'small.txt', 'unknown.txt',
])
})
it('sorts files by size while keeping folders alphabetic', () => {
expect(sortDirectoryEntries(entries, { field: 'size', direction: 'desc' }, 'en').map((entry) => entry.name)).toEqual([
'folder-a', 'folder-b', 'large.txt', 'small.txt', 'unknown.txt',
])
})
it('sorts by modified time and leaves missing metadata last', () => {
expect(sortDirectoryEntries(entries, { field: 'modified', direction: 'desc' }, 'en').map((entry) => entry.name)).toEqual([
'folder-b', 'folder-a', 'large.txt', 'small.txt', 'unknown.txt',
])
})
})
+41
View File
@@ -0,0 +1,41 @@
import type { DirectoryEntry } from '@/types/api'
import type { Locale } from '@/lib/use-translation'
export type DirectorySortField = 'name' | 'modified' | 'size'
export type DirectorySortDirection = 'asc' | 'desc'
export type DirectorySort = { field: DirectorySortField; direction: DirectorySortDirection }
export const DEFAULT_DIRECTORY_SORT: DirectorySort = { field: 'name', direction: 'asc' }
function compareOptionalNumbers(left: number | undefined, right: number | undefined, direction: DirectorySortDirection): number {
const leftMissing = left === undefined || !Number.isFinite(left)
const rightMissing = right === undefined || !Number.isFinite(right)
if (leftMissing && rightMissing) return 0
if (leftMissing) return 1
if (rightMissing) return -1
return direction === 'asc' ? left - right : right - left
}
export function sortDirectoryEntries(
entries: DirectoryEntry[],
sort: DirectorySort,
locale: Locale,
): DirectoryEntry[] {
const collator = new Intl.Collator(locale, { numeric: true, sensitivity: 'base' })
const byName = (left: DirectoryEntry, right: DirectoryEntry, direction: DirectorySortDirection) => {
const result = collator.compare(left.name, right.name)
return direction === 'asc' ? result : -result
}
return [...entries].sort((left, right) => {
const leftDirectory = left.type === 'directory'
const rightDirectory = right.type === 'directory'
if (leftDirectory !== rightDirectory) return leftDirectory ? -1 : 1
if (sort.field === 'name') return byName(left, right, sort.direction)
if (sort.field === 'size' && leftDirectory) return byName(left, right, 'asc')
const result = compareOptionalNumbers(left[sort.field], right[sort.field], sort.direction)
return result || byName(left, right, 'asc')
})
}
+16
View File
@@ -0,0 +1,16 @@
export function downloadBase64File(fileName: string, base64Content: string, mimeType?: string | null): void {
const byteChars = atob(base64Content)
const bytes = new Uint8Array(byteChars.length)
for (let index = 0; index < byteChars.length; index += 1) {
bytes[index] = byteChars.charCodeAt(index)
}
const url = URL.createObjectURL(new Blob([bytes], { type: mimeType ?? 'application/octet-stream' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
import { formatFileMetadata, formatFileSize, formatModifiedTime } from './file-metadata'
describe('file metadata formatting', () => {
it('formats byte sizes using compact binary units', () => {
expect(formatFileSize(0)).toBe('0 B')
expect(formatFileSize(999)).toBe('999 B')
expect(formatFileSize(1536)).toBe('1.5 KB')
expect(formatFileSize(12 * 1024)).toBe('12 KB')
expect(formatFileSize(2.25 * 1024 * 1024)).toBe('2.3 MB')
})
it('ignores invalid metadata', () => {
expect(formatFileSize(undefined)).toBeNull()
expect(formatFileSize(-1)).toBeNull()
expect(formatModifiedTime(Number.NaN, 'en')).toBeNull()
})
it('combines available size and modified time', () => {
vi.stubGlobal('Intl', {
...Intl,
DateTimeFormat: class {
format() { return '2026/07/16 10:31' }
},
})
expect(formatFileMetadata(1024, 1_784_175_060_000, 'en')).toBe('2026/07/16 10:31 · 1 KB')
vi.unstubAllGlobals()
})
})
+31
View File
@@ -0,0 +1,31 @@
import type { Locale } from '@/lib/use-translation'
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
export function formatFileSize(bytes: number | undefined): string | null {
if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return null
if (bytes < 1024) return `${bytes} B`
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1)
const value = bytes / (1024 ** unitIndex)
const formatted = value >= 10 ? Math.round(value).toString() : value.toFixed(1).replace(/\.0$/, '')
return `${formatted} ${BYTE_UNITS[unitIndex]}`
}
export function formatModifiedTime(modified: number | undefined, locale: Locale): string | null {
if (modified === undefined || !Number.isFinite(modified)) return null
const date = new Date(modified)
if (Number.isNaN(date.getTime())) return null
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
export function formatFileMetadata(size: number | undefined, modified: number | undefined, locale: Locale): string | null {
return [formatModifiedTime(modified, locale), formatFileSize(size)].filter(Boolean).join(' · ') || null
}
+15
View File
@@ -341,6 +341,21 @@ export default {
'files.directories.empty': 'Empty directory.',
'files.directories.error.listFailed': 'Failed to list directory.',
'files.directories.error.listFailedWithDetail': 'Failed to list directory: {error}',
'files.directories.download': 'Download file',
'files.directories.downloadNamed': 'Download {name}',
'files.directories.download.error.title': 'Download failed',
'files.directories.download.error.default': 'Failed to download file.',
'files.sort.title': 'Sort files',
'files.sort.by': 'Sort by',
'files.sort.name': 'Name',
'files.sort.modified': 'Modified time',
'files.sort.size': 'File size',
'files.sort.nameAsc': 'A to Z',
'files.sort.nameDesc': 'Z to A',
'files.sort.oldest': 'Oldest first',
'files.sort.newest': 'Newest first',
'files.sort.smallest': 'Smallest first',
'files.sort.largest': 'Largest first',
// File page
'file.page.fallbackName': 'File',
+15
View File
@@ -345,6 +345,21 @@ export default {
'files.directories.empty': '空目录。',
'files.directories.error.listFailed': '加载目录失败。',
'files.directories.error.listFailedWithDetail': '加载目录失败:{error}',
'files.directories.download': '下载文件',
'files.directories.downloadNamed': '下载 {name}',
'files.directories.download.error.title': '下载失败',
'files.directories.download.error.default': '文件下载失败。',
'files.sort.title': '文件排序',
'files.sort.by': '排序依据',
'files.sort.name': '名称',
'files.sort.modified': '修改时间',
'files.sort.size': '文件大小',
'files.sort.nameAsc': 'A 到 Z',
'files.sort.nameDesc': 'Z 到 A',
'files.sort.oldest': '最早优先',
'files.sort.newest': '最新优先',
'files.sort.smallest': '最小优先',
'files.sort.largest': '最大优先',
// File page
'file.page.fallbackName': '文件',
+2 -18
View File
@@ -20,6 +20,7 @@ import {
persistMarkdownPreviewMode,
type MarkdownPreviewMode,
} from '@/lib/file-markdown-preview'
import { downloadBase64File } from '@/lib/file-download'
const MAX_COPYABLE_FILE_BYTES = 1_000_000
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
@@ -64,23 +65,6 @@ function DownloadIcon(props: { className?: string }) {
)
}
function triggerDownload(fileName: string, base64Content: string, mimeType: string | null) {
const byteChars = atob(base64Content)
const byteArray = new Uint8Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) {
byteArray[i] = byteChars.charCodeAt(i)
}
const blob = new Blob([byteArray], { type: mimeType ?? 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function BackIcon(props: { className?: string }) {
return (
<svg
@@ -321,7 +305,7 @@ export default function FilePage() {
{canDownload ? (
<button
type="button"
onClick={() => triggerDownload(fileName, fileContentResult!.content!, imageMimeType)}
onClick={() => downloadBase64File(fileName, fileContentResult!.content!, imageMimeType)}
className="shrink-0 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title={t('file.page.download')}
>
+87
View File
@@ -20,6 +20,14 @@ import { encodeBase64 } from '@/lib/utils'
import { queryKeys } from '@/lib/query-keys'
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 {
DEFAULT_DIRECTORY_SORT,
type DirectorySort,
type DirectorySortDirection,
type DirectorySortField,
} from '@/lib/directory-sort'
function RefreshIcon(props: { className?: string }) {
return (
@@ -41,6 +49,72 @@ function RefreshIcon(props: { className?: string }) {
)
}
function SortIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h13" /><path d="M3 12h9" /><path d="M3 18h5" />
<path d="m17 15 3 3 3-3" /><path d="M20 18V6" />
</svg>
)
}
const DIRECTORY_SORT_STORAGE_KEY = 'hapi-directory-sort'
function readDirectorySort(): DirectorySort {
try {
const value = JSON.parse(localStorage.getItem(DIRECTORY_SORT_STORAGE_KEY) ?? '') as Partial<DirectorySort>
if (['name', 'modified', 'size'].includes(value.field ?? '') && ['asc', 'desc'].includes(value.direction ?? '')) {
return value as DirectorySort
}
} catch {
// Use the default when storage is unavailable or invalid.
}
return DEFAULT_DIRECTORY_SORT
}
function DirectorySortMenu(props: { sort: DirectorySort; onChange: (sort: DirectorySort) => void }) {
const { t } = useTranslation()
const fields: Array<{ value: DirectorySortField; label: string }> = [
{ value: 'name', label: t('files.sort.name') },
{ value: 'modified', label: t('files.sort.modified') },
{ value: 'size', label: t('files.sort.size') },
]
const directions: Array<{ value: DirectorySortDirection; label: string }> = props.sort.field === 'name'
? [{ value: 'asc', label: t('files.sort.nameAsc') }, { value: 'desc', label: t('files.sort.nameDesc') }]
: props.sort.field === 'modified'
? [{ value: 'asc', label: t('files.sort.oldest') }, { value: 'desc', label: t('files.sort.newest') }]
: [{ value: 'asc', label: t('files.sort.smallest') }, { value: 'desc', label: t('files.sort.largest') }]
const optionClass = 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-[var(--app-subtle-bg)]'
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')}>
<SortIcon />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content side="bottom" align="end" sideOffset={6} collisionPadding={8} className="z-50 w-48 rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 shadow-lg">
<div className="px-2 pb-1 text-xs font-semibold text-[var(--app-hint)]">{t('files.sort.by')}</div>
{fields.map((field) => (
<button key={field.value} type="button" className={optionClass} onClick={() => props.onChange({ field: field.value, direction: props.sort.direction })}>
<span className="flex h-4 w-4 items-center justify-center">{props.sort.field === field.value ? <CheckIcon className="h-3.5 w-3.5" /> : null}</span>
{field.label}
</button>
))}
<div className="my-1 border-t border-[var(--app-divider)]" />
{directions.map((direction) => (
<button key={direction.value} type="button" className={optionClass} onClick={() => props.onChange({ ...props.sort, direction: direction.value })}>
<span className="flex h-4 w-4 items-center justify-center">{props.sort.direction === direction.value ? <CheckIcon className="h-3.5 w-3.5" /> : null}</span>
{direction.label}
</button>
))}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
}
function SearchIcon(props: { className?: string }) {
return (
<svg
@@ -235,6 +309,15 @@ export default function FilesPage() {
const initialTab = search.tab === 'directories' ? 'directories' : 'changes'
const [activeTab, setActiveTab] = useState<'changes' | 'directories'>(initialTab)
const [directorySort, setDirectorySort] = useState<DirectorySort>(readDirectorySort)
useEffect(() => {
try {
localStorage.setItem(DIRECTORY_SORT_STORAGE_KEY, JSON.stringify(directorySort))
} catch {
// Sorting still works when storage is unavailable.
}
}, [directorySort])
useEffect(() => {
const el = scrollRef.current
@@ -384,6 +467,9 @@ export default function FilesPage() {
autoCorrect="off"
/>
</div>
{activeTab === 'directories' && !searchQuery ? (
<DirectorySortMenu sort={directorySort} onChange={setDirectorySort} />
) : null}
<button
type="button"
onClick={handleRefresh}
@@ -477,6 +563,7 @@ export default function FilesPage() {
sessionId={sessionId}
rootLabel={rootLabel}
onOpenFile={(path) => handleOpenFile(path)}
sort={directorySort}
/>
) : gitLoading ? (
<FileListSkeleton label={t('loading.git')} />