- FolderPicker: uses webkitRelativePath so paths like docs/report.pdf are preserved; adds "include subfolders" checkbox (default on) - FileBrowser: virtual directory tree with breadcrumb navigation, sort by name/size, search filter, folders shown before files - Protocol: browse sends path alongside name; get uses path as key - FileEntry type gains optional path field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
6.4 KiB
TypeScript
193 lines
6.4 KiB
TypeScript
import { useState, useMemo } from 'react'
|
|
import { useWaste } from '../store'
|
|
import { BrowserAdapter } from '../adapter/browser'
|
|
import type { FileEntry } from '../types'
|
|
|
|
function fmt(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
type SortKey = 'name' | 'size'
|
|
type SortDir = 'asc' | 'desc'
|
|
|
|
interface DirEntry {
|
|
kind: 'dir'
|
|
name: string
|
|
path: string
|
|
count: number
|
|
}
|
|
|
|
interface FileRow {
|
|
kind: 'file'
|
|
name: string
|
|
path: string
|
|
size: number
|
|
}
|
|
|
|
type Row = DirEntry | FileRow
|
|
|
|
function buildRows(files: FileEntry[], cwd: string, search: string, sort: SortKey, sortDir: SortDir): Row[] {
|
|
// Filter to entries under cwd
|
|
const prefix = cwd ? cwd + '/' : ''
|
|
const inDir = files.filter(f => {
|
|
const p = f.path ?? f.name
|
|
return p.startsWith(prefix)
|
|
})
|
|
|
|
if (search.trim()) {
|
|
// Flat search across all files under cwd
|
|
const q = search.toLowerCase()
|
|
return inDir
|
|
.filter(f => f.name.toLowerCase().includes(q))
|
|
.map(f => ({ kind: 'file' as const, name: f.name, path: f.path ?? f.name, size: f.size_bytes }))
|
|
.sort((a, b) => {
|
|
const cmp = sort === 'name' ? a.name.localeCompare(b.name) : a.size - b.size
|
|
return sortDir === 'asc' ? cmp : -cmp
|
|
})
|
|
}
|
|
|
|
// Build immediate children: dirs and files at this level
|
|
const dirs = new Map<string, number>() // dirName → file count
|
|
const fileRows: FileRow[] = []
|
|
|
|
for (const f of inDir) {
|
|
const rel = (f.path ?? f.name).slice(prefix.length)
|
|
const slash = rel.indexOf('/')
|
|
if (slash === -1) {
|
|
fileRows.push({ kind: 'file', name: f.name, path: f.path ?? f.name, size: f.size_bytes })
|
|
} else {
|
|
const dirName = rel.slice(0, slash)
|
|
dirs.set(dirName, (dirs.get(dirName) ?? 0) + 1)
|
|
}
|
|
}
|
|
|
|
const dirRows: DirEntry[] = Array.from(dirs.entries()).map(([name, count]) => ({
|
|
kind: 'dir', name, path: prefix + name, count,
|
|
}))
|
|
|
|
// Sort each group separately, then dirs first
|
|
const cmpFiles = (a: FileRow, b: FileRow) => {
|
|
const cmp = sort === 'name' ? a.name.localeCompare(b.name) : a.size - b.size
|
|
return sortDir === 'asc' ? cmp : -cmp
|
|
}
|
|
dirRows.sort((a, b) => a.name.localeCompare(b.name))
|
|
fileRows.sort(cmpFiles)
|
|
|
|
return [...dirRows, ...fileRows]
|
|
}
|
|
|
|
export function FileBrowser() {
|
|
const { activeFilePeer, fileLists, setActiveFilePeer, adapter, connectedPeers } = useWaste()
|
|
const [cwd, setCwd] = useState('')
|
|
const [search, setSearch] = useState('')
|
|
const [sort, setSort] = useState<SortKey>('name')
|
|
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
|
|
|
if (!activeFilePeer) return null
|
|
|
|
const peer = connectedPeers.find(p => p.id === activeFilePeer)
|
|
const files = fileLists[activeFilePeer] ?? null
|
|
|
|
function download(path: string) {
|
|
if (adapter instanceof BrowserAdapter) {
|
|
adapter.requestGet(activeFilePeer!, path)
|
|
}
|
|
}
|
|
|
|
function toggleSort(key: SortKey) {
|
|
if (sort === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
|
else { setSort(key); setSortDir('asc') }
|
|
}
|
|
|
|
function navigateUp() {
|
|
const parts = cwd.split('/')
|
|
parts.pop()
|
|
setCwd(parts.join('/'))
|
|
}
|
|
|
|
const rows = useMemo(() => {
|
|
if (!files) return []
|
|
return buildRows(files, cwd, search, sort, sortDir)
|
|
}, [files, cwd, search, sort, sortDir])
|
|
|
|
const cwdParts = cwd ? cwd.split('/') : []
|
|
|
|
return (
|
|
<div className="file-browser">
|
|
<div className="file-browser-header">
|
|
<span className="file-browser-title">Files · {peer?.alias ?? activeFilePeer.slice(0, 8)}</span>
|
|
<button className="file-browser-close" onClick={() => setActiveFilePeer(null)}>✕</button>
|
|
</div>
|
|
|
|
{files === null ? (
|
|
<div className="file-browser-empty">Loading…</div>
|
|
) : files.length === 0 ? (
|
|
<div className="file-browser-empty">No files shared</div>
|
|
) : (
|
|
<>
|
|
<div className="file-browser-toolbar">
|
|
<input
|
|
className="file-browser-search"
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
placeholder="search…"
|
|
/>
|
|
</div>
|
|
|
|
{/* Breadcrumb */}
|
|
<div className="file-browser-breadcrumb">
|
|
<button onClick={() => setCwd('')} className="breadcrumb-seg">~</button>
|
|
{cwdParts.map((seg, i) => (
|
|
<span key={i}>
|
|
<span className="breadcrumb-sep">/</span>
|
|
<button
|
|
className="breadcrumb-seg"
|
|
onClick={() => setCwd(cwdParts.slice(0, i + 1).join('/'))}
|
|
>{seg}</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sort bar */}
|
|
<div className="file-browser-sortbar">
|
|
<button className={`sort-btn ${sort === 'name' ? 'active' : ''}`} onClick={() => toggleSort('name')}>
|
|
name {sort === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
|
</button>
|
|
<button className={`sort-btn ${sort === 'size' ? 'active' : ''}`} onClick={() => toggleSort('size')}>
|
|
size {sort === 'size' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
|
</button>
|
|
</div>
|
|
|
|
<ul className="file-list">
|
|
{cwd && !search && (
|
|
<li className="file-entry file-entry-dir" onClick={navigateUp}>
|
|
<span className="file-entry-icon">📁</span>
|
|
<span className="file-entry-name">..</span>
|
|
</li>
|
|
)}
|
|
{rows.length === 0 && (
|
|
<li className="file-browser-empty">no results</li>
|
|
)}
|
|
{rows.map(row => row.kind === 'dir' ? (
|
|
<li key={row.path} className="file-entry file-entry-dir" onClick={() => { setCwd(row.path); setSearch('') }}>
|
|
<span className="file-entry-icon">📁</span>
|
|
<span className="file-entry-name">{row.name}</span>
|
|
<span className="file-entry-size">{row.count} file{row.count !== 1 ? 's' : ''}</span>
|
|
</li>
|
|
) : (
|
|
<li key={row.path} className="file-entry">
|
|
<span className="file-entry-icon">📄</span>
|
|
<span className="file-entry-name">{row.name}</span>
|
|
<span className="file-entry-size">{fmt(row.size)}</span>
|
|
<button className="file-entry-dl" onClick={() => download(row.path)}>↓</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|