feat: subfolder support in file sharing + directory browser UI
- 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>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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`
|
||||
@@ -7,20 +9,111 @@ function fmt(bytes: number): string {
|
||||
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(name: string) {
|
||||
function download(path: string) {
|
||||
if (adapter instanceof BrowserAdapter) {
|
||||
adapter.requestGet(activeFilePeer!, name)
|
||||
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">
|
||||
@@ -33,15 +126,66 @@ export function FileBrowser() {
|
||||
) : files.length === 0 ? (
|
||||
<div className="file-browser-empty">No files shared</div>
|
||||
) : (
|
||||
<ul className="file-list">
|
||||
{files.map(f => (
|
||||
<li key={f.name} className="file-entry">
|
||||
<span className="file-entry-name">{f.name}</span>
|
||||
<span className="file-entry-size">{fmt(f.size_bytes)}</span>
|
||||
<button className="file-entry-dl" onClick={() => download(f.name)}>↓</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRef } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function FolderPicker() {
|
||||
const { setSharedFiles, activeNetworkId, sharedFilesByNetwork } = useWaste()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [includeSubfolders, setIncludeSubfolders] = useState(true)
|
||||
|
||||
const current = activeNetworkId ? sharedFilesByNetwork[activeNetworkId] : undefined
|
||||
const label = current && current.size > 0
|
||||
@@ -16,7 +17,11 @@ export function FolderPicker() {
|
||||
const map = new Map<string, File>()
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const f = fileList[i]
|
||||
map.set(f.name, f)
|
||||
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath
|
||||
// Strip the root folder name prefix (first path segment) so paths are relative to the picked folder
|
||||
const path = rel ? rel.split('/').slice(1).join('/') : f.name
|
||||
if (!includeSubfolders && path.includes('/')) continue
|
||||
map.set(path, f)
|
||||
}
|
||||
setSharedFiles(map)
|
||||
e.target.value = ''
|
||||
@@ -40,6 +45,14 @@ export function FolderPicker() {
|
||||
>
|
||||
{label ?? '+ Share folder'}
|
||||
</button>
|
||||
<label className="folder-picker-subfolders">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSubfolders}
|
||||
onChange={e => setIncludeSubfolders(e.target.checked)}
|
||||
/>
|
||||
include subfolders
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user