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:
@@ -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); }
|
.file-entry-dl:hover { background: rgba(124,106,247,0.15); }
|
||||||
|
|
||||||
/* ── folder picker ── */
|
/* ── 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 { 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-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; }
|
||||||
|
|||||||
@@ -440,20 +440,18 @@ class PeerConn {
|
|||||||
} else if (m['type'] === 'pm') {
|
} else if (m['type'] === 'pm') {
|
||||||
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
|
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
|
||||||
} else if (m['type'] === 'browse') {
|
} else if (m['type'] === 'browse') {
|
||||||
const path = (m['path'] as string) || '/'
|
const files = Array.from(this.share.entries()).map(([path, f]) => ({
|
||||||
void path // path-based subdirs not supported; always return root
|
name: f.name, path, size: f.size, mime: f.type
|
||||||
const files = Array.from(this.share.values()).map(f => ({
|
|
||||||
name: f.name, size: f.size, mime: f.type
|
|
||||||
}))
|
}))
|
||||||
this._dc({ type: 'files', files })
|
this._dc({ type: 'files', files })
|
||||||
} else if (m['type'] === '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 })
|
this.on('files', { peer: this.peerId, files })
|
||||||
} else if (m['type'] === 'get') {
|
} else if (m['type'] === 'get') {
|
||||||
const name = m['name'] as string
|
const path = m['path'] as string
|
||||||
const file = this.share.get(name)
|
const file = this.share.get(path)
|
||||||
if (!file) return
|
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') {
|
} else if (m['type'] === 'file-offer') {
|
||||||
const name = m['name'] as string
|
const name = m['name'] as string
|
||||||
const size = m['size'] as number
|
const size = m['size'] as number
|
||||||
@@ -461,6 +459,7 @@ class PeerConn {
|
|||||||
this.on('file_offer', { peer: this.peerId, name, size, xid })
|
this.on('file_offer', { peer: this.peerId, name, size, xid })
|
||||||
} else if (m['type'] === 'file-accept') {
|
} else if (m['type'] === 'file-accept') {
|
||||||
const xid = m['xid'] as string
|
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)
|
if (this._pushQueue.has(xid) || this.share.has(xid)) void this._stream(xid)
|
||||||
} else if (m['type'] === 'file-done') {
|
} else if (m['type'] === 'file-done') {
|
||||||
const xid = m['xid'] as string
|
const xid = m['xid'] as string
|
||||||
@@ -480,8 +479,8 @@ class PeerConn {
|
|||||||
this._dc({ type: 'browse', path: '/' })
|
this._dc({ type: 'browse', path: '/' })
|
||||||
}
|
}
|
||||||
|
|
||||||
requestGet(name: string) {
|
requestGet(path: string) {
|
||||||
this._dc({ type: 'get', name })
|
this._dc({ type: 'get', path })
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptOffer(xid: string, name: string, size: number) {
|
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`,
|
error_message: `transfer ${(data['xid'] as string).slice(0, 8)} cancelled`,
|
||||||
})
|
})
|
||||||
} else if (event === 'files') {
|
} 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({
|
this.emit({
|
||||||
type: 'file_list',
|
type: 'file_list',
|
||||||
peer_id: data['peer'] as string,
|
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') {
|
} else if (event === 'file_recv') {
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -856,8 +855,8 @@ export class BrowserAdapter {
|
|||||||
this.peers.get(peerId)?.requestBrowse()
|
this.peers.get(peerId)?.requestBrowse()
|
||||||
}
|
}
|
||||||
|
|
||||||
requestGet(peerId: string, name: string) {
|
requestGet(peerId: string, path: string) {
|
||||||
this.peers.get(peerId)?.requestGet(name)
|
this.peers.get(peerId)?.requestGet(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendFileTo(peerId: string, file: File) {
|
sendFileTo(peerId: string, file: File) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
import { useWaste } from '../store'
|
import { useWaste } from '../store'
|
||||||
import { BrowserAdapter } from '../adapter/browser'
|
import { BrowserAdapter } from '../adapter/browser'
|
||||||
|
import type { FileEntry } from '../types'
|
||||||
|
|
||||||
function fmt(bytes: number): string {
|
function fmt(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
@@ -7,20 +9,111 @@ function fmt(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
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() {
|
export function FileBrowser() {
|
||||||
const { activeFilePeer, fileLists, setActiveFilePeer, adapter, connectedPeers } = useWaste()
|
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
|
if (!activeFilePeer) return null
|
||||||
|
|
||||||
const peer = connectedPeers.find(p => p.id === activeFilePeer)
|
const peer = connectedPeers.find(p => p.id === activeFilePeer)
|
||||||
const files = fileLists[activeFilePeer] ?? null
|
const files = fileLists[activeFilePeer] ?? null
|
||||||
|
|
||||||
function download(name: string) {
|
function download(path: string) {
|
||||||
if (adapter instanceof BrowserAdapter) {
|
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 (
|
return (
|
||||||
<div className="file-browser">
|
<div className="file-browser">
|
||||||
<div className="file-browser-header">
|
<div className="file-browser-header">
|
||||||
@@ -33,15 +126,66 @@ export function FileBrowser() {
|
|||||||
) : files.length === 0 ? (
|
) : files.length === 0 ? (
|
||||||
<div className="file-browser-empty">No files shared</div>
|
<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">
|
<ul className="file-list">
|
||||||
{files.map(f => (
|
{cwd && !search && (
|
||||||
<li key={f.name} className="file-entry">
|
<li className="file-entry file-entry-dir" onClick={navigateUp}>
|
||||||
<span className="file-entry-name">{f.name}</span>
|
<span className="file-entry-icon">📁</span>
|
||||||
<span className="file-entry-size">{fmt(f.size_bytes)}</span>
|
<span className="file-entry-name">..</span>
|
||||||
<button className="file-entry-dl" onClick={() => download(f.name)}>↓</button>
|
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useWaste } from '../store'
|
import { useWaste } from '../store'
|
||||||
|
|
||||||
export function FolderPicker() {
|
export function FolderPicker() {
|
||||||
const { setSharedFiles, activeNetworkId, sharedFilesByNetwork } = useWaste()
|
const { setSharedFiles, activeNetworkId, sharedFilesByNetwork } = useWaste()
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [includeSubfolders, setIncludeSubfolders] = useState(true)
|
||||||
|
|
||||||
const current = activeNetworkId ? sharedFilesByNetwork[activeNetworkId] : undefined
|
const current = activeNetworkId ? sharedFilesByNetwork[activeNetworkId] : undefined
|
||||||
const label = current && current.size > 0
|
const label = current && current.size > 0
|
||||||
@@ -16,7 +17,11 @@ export function FolderPicker() {
|
|||||||
const map = new Map<string, File>()
|
const map = new Map<string, File>()
|
||||||
for (let i = 0; i < fileList.length; i++) {
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
const f = fileList[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)
|
setSharedFiles(map)
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
@@ -40,6 +45,14 @@ export function FolderPicker() {
|
|||||||
>
|
>
|
||||||
{label ?? '+ Share folder'}
|
{label ?? '+ Share folder'}
|
||||||
</button>
|
</button>
|
||||||
|
<label className="folder-picker-subfolders">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeSubfolders}
|
||||||
|
onChange={e => setIncludeSubfolders(e.target.checked)}
|
||||||
|
/>
|
||||||
|
include subfolders
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface ChatMessage {
|
|||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
name: string
|
name: string
|
||||||
size_bytes: number
|
size_bytes: number
|
||||||
|
path?: string // relative path including filename, e.g. "docs/report.pdf"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileOffer {
|
export interface FileOffer {
|
||||||
|
|||||||
Reference in New Issue
Block a user