mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '文件',
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
Reference in New Issue
Block a user