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
+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': '文件',