diff --git a/web/src/App.css b/web/src/App.css index 3743751..8471edf 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -126,6 +126,23 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; } .file-entry-dl:hover { background: rgba(124,106,247,0.15); } /* ── folder picker ── */ -.folder-picker { width: 100%; } +.folder-picker { width: 100%; display: flex; flex-direction: column; gap: 4px; } .folder-picker-btn { background: var(--surface); color: var(--muted); border: 1px solid var(--border); font-size: 12px; padding: 5px 10px; border-radius: 4px; width: 100%; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .folder-picker-btn:hover { color: var(--text); border-color: var(--accent); background: var(--surface); } +.folder-picker-subfolders { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); cursor: pointer; } +.folder-picker-subfolders input { width: auto; cursor: pointer; } + +/* ── file browser toolbar / breadcrumb / sort ── */ +.file-browser-toolbar { padding: 6px 8px 0; } +.file-browser-search { font-size: 12px; padding: 4px 8px; } +.file-browser-breadcrumb { display: flex; align-items: center; flex-wrap: wrap; padding: 4px 8px; gap: 0; font-size: 11px; color: var(--muted); border-bottom: 1px solid var(--border); } +.breadcrumb-seg { background: none; color: var(--muted); font-size: 11px; padding: 0 2px; } +.breadcrumb-seg:hover { color: var(--accent); background: none; } +.breadcrumb-sep { color: var(--border); padding: 0 1px; } +.file-browser-sortbar { display: flex; gap: 4px; padding: 4px 8px; border-bottom: 1px solid var(--border); } +.sort-btn { background: none; color: var(--muted); font-size: 11px; padding: 1px 6px; border-radius: 3px; } +.sort-btn:hover { background: rgba(255,255,255,0.05); color: var(--text); } +.sort-btn.active { color: var(--accent); background: none; } +.file-entry-dir { cursor: pointer; } +.file-entry-dir:hover { background: rgba(255,255,255,0.04); } +.file-entry-icon { font-size: 12px; flex-shrink: 0; } diff --git a/web/src/adapter/browser.ts b/web/src/adapter/browser.ts index 0e8fec3..8aa8c6d 100644 --- a/web/src/adapter/browser.ts +++ b/web/src/adapter/browser.ts @@ -440,20 +440,18 @@ class PeerConn { } else if (m['type'] === 'pm') { this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() }) } else if (m['type'] === 'browse') { - const path = (m['path'] as string) || '/' - void path // path-based subdirs not supported; always return root - const files = Array.from(this.share.values()).map(f => ({ - name: f.name, size: f.size, mime: f.type + const files = Array.from(this.share.entries()).map(([path, f]) => ({ + name: f.name, path, size: f.size, mime: f.type })) this._dc({ type: 'files', files }) } else if (m['type'] === 'files') { - const files = m['files'] as Array<{ name: string; size: number; mime?: string }> + const files = m['files'] as Array<{ name: string; path?: string; size: number; mime?: string }> this.on('files', { peer: this.peerId, files }) } else if (m['type'] === 'get') { - const name = m['name'] as string - const file = this.share.get(name) + const path = m['path'] as string + const file = this.share.get(path) if (!file) return - this._dc({ type: 'file-offer', name: file.name, size: file.size, xid: name }) + this._dc({ type: 'file-offer', name: file.name, path, size: file.size, xid: path }) } else if (m['type'] === 'file-offer') { const name = m['name'] as string const size = m['size'] as number @@ -461,6 +459,7 @@ class PeerConn { this.on('file_offer', { peer: this.peerId, name, size, xid }) } else if (m['type'] === 'file-accept') { const xid = m['xid'] as string + // xid is either a push UUID or a share path if (this._pushQueue.has(xid) || this.share.has(xid)) void this._stream(xid) } else if (m['type'] === 'file-done') { const xid = m['xid'] as string @@ -480,8 +479,8 @@ class PeerConn { this._dc({ type: 'browse', path: '/' }) } - requestGet(name: string) { - this._dc({ type: 'get', name }) + requestGet(path: string) { + this._dc({ type: 'get', path }) } acceptOffer(xid: string, name: string, size: number) { @@ -819,11 +818,11 @@ export class BrowserAdapter { error_message: `transfer ${(data['xid'] as string).slice(0, 8)} cancelled`, }) } else if (event === 'files') { - const raw = data['files'] as Array<{ name: string; size: number; mime?: string }> + const raw = data['files'] as Array<{ name: string; path?: string; size: number; mime?: string }> this.emit({ type: 'file_list', peer_id: data['peer'] as string, - files: raw.map(f => ({ name: f.name, size_bytes: f.size })), + files: raw.map(f => ({ name: f.name, path: f.path ?? f.name, size_bytes: f.size })), }) } else if (event === 'file_recv') { this.emit({ @@ -856,8 +855,8 @@ export class BrowserAdapter { this.peers.get(peerId)?.requestBrowse() } - requestGet(peerId: string, name: string) { - this.peers.get(peerId)?.requestGet(name) + requestGet(peerId: string, path: string) { + this.peers.get(peerId)?.requestGet(path) } sendFileTo(peerId: string, file: File) { diff --git a/web/src/components/FileBrowser.tsx b/web/src/components/FileBrowser.tsx index d58b571..a378dfd 100644 --- a/web/src/components/FileBrowser.tsx +++ b/web/src/components/FileBrowser.tsx @@ -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() // 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('name') + const [sortDir, setSortDir] = useState('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 (
@@ -33,15 +126,66 @@ export function FileBrowser() { ) : files.length === 0 ? (
No files shared
) : ( -
    - {files.map(f => ( -
  • - {f.name} - {fmt(f.size_bytes)} - -
  • - ))} -
+ <> +
+ setSearch(e.target.value)} + placeholder="search…" + /> +
+ + {/* Breadcrumb */} +
+ + {cwdParts.map((seg, i) => ( + + / + + + ))} +
+ + {/* Sort bar */} +
+ + +
+ +
    + {cwd && !search && ( +
  • + 📁 + .. +
  • + )} + {rows.length === 0 && ( +
  • no results
  • + )} + {rows.map(row => row.kind === 'dir' ? ( +
  • { setCwd(row.path); setSearch('') }}> + 📁 + {row.name} + {row.count} file{row.count !== 1 ? 's' : ''} +
  • + ) : ( +
  • + 📄 + {row.name} + {fmt(row.size)} + +
  • + ))} +
+ )}
) diff --git a/web/src/components/FolderPicker.tsx b/web/src/components/FolderPicker.tsx index cee210b..75af6e3 100644 --- a/web/src/components/FolderPicker.tsx +++ b/web/src/components/FolderPicker.tsx @@ -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(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() 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'} +
) } diff --git a/web/src/types.ts b/web/src/types.ts index d7ab87c..da6b5ba 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -29,6 +29,7 @@ export interface ChatMessage { export interface FileEntry { name: string size_bytes: number + path?: string // relative path including filename, e.g. "docs/report.pdf" } export interface FileOffer {