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:
Fredrik Johansson
2026-06-26 20:29:00 +02:00
parent 80e05b81ac
commit 0f54f3bbad
5 changed files with 202 additions and 28 deletions

View File

@@ -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) {