feat: resumable transfer UX — surface partial downloads to UI on reconnect

Daemon scans the download directory for .tmp.meta sidecars on network join
and emits resumable_transfers IPC event. Web UI shows them in the Transfers
panel with a dimmed progress bar and "will resume on reconnect" note.

- proto: ResumableFile type, EvtResumableTransfers, resumable_files IpcMessage field
- mesh: ScanResumable() scans download dir and emits the event
- netmgr: call ScanResumable() after join (both Join and JoinByHash paths)
- web: resumableFiles store state, resumable_transfers handler, Transfers UI section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-29 10:02:49 +02:00
parent 9de625d617
commit f319721e01
6 changed files with 93 additions and 4 deletions

View File

@@ -7,12 +7,13 @@ function fmt(bytes: number): string {
}
export function Transfers() {
const { pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
const { pendingOffers, fileProgress, resumableFiles, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
const hasPending = Object.keys(pendingOffers).length > 0
const hasActive = Object.keys(fileProgress).length > 0
const hasResumable = Object.keys(resumableFiles).length > 0
if (!hasPending && !hasActive) return null
if (!hasPending && !hasActive && !hasResumable) return null
function alias(peerId: string) {
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
@@ -22,6 +23,24 @@ export function Transfers() {
<div className="sidebar-section">
<span className="sidebar-label">Transfers</span>
{hasResumable && (
<>
<span className="sidebar-label" style={{ fontSize: 10, opacity: 0.6 }}>resumable</span>
{Object.entries(resumableFiles).map(([sha256, f]) => {
const pct = f.size > 0 ? Math.round((f.offset / f.size) * 100) : 0
return (
<div key={sha256} className="transfer-row">
<span className="transfer-name" title={f.name}>{f.name}</span>
<span className="transfer-meta">{fmt(f.offset)} / {fmt(f.size)} · {alias(f.from)} · will resume on reconnect</span>
<div className="transfer-progress">
<div className="transfer-progress-bar" style={{ width: `${pct}%`, opacity: 0.5 }} />
</div>
</div>
)
})}
</>
)}
{Object.entries(pendingOffers).map(([xid, offer]) => (
<div key={xid} className="transfer-row">
<span className="transfer-name" title={offer.name}>{offer.name}</span>

View File

@@ -55,6 +55,8 @@ interface WasteState {
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
// active in-progress transfers: xid → progress
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
// partial downloads found on daemon startup: sha256 → info
resumableFiles: Record<string, { name: string; from: string; size: number; offset: number }>
// actions
connect: (url: string) => void
@@ -96,6 +98,7 @@ export const useWaste = create<WasteState>((set, get) => ({
sharedFilesByNetwork: {},
pendingOffers: {},
fileProgress: {},
resumableFiles: {},
connect(url: string) {
const adapter = new DaemonAdapter(url)
@@ -355,6 +358,14 @@ export const useWaste = create<WasteState>((set, get) => ({
}
break
}
case 'resumable_transfers': {
const files = (msg.resumable_files ?? []) as Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
if (files.length === 0) break
const byHash: Record<string, { name: string; from: string; size: number; offset: number }> = {}
for (const f of files) byHash[f.sha256] = { name: f.name, from: f.from, size: f.size, offset: f.offset }
set(s => ({ resumableFiles: { ...s.resumableFiles, ...byHash } }))
break
}
case 'history_loaded': {
const room = msg.room
const incoming = (msg.messages ?? []) as ChatMessage[]

View File

@@ -85,6 +85,7 @@ export type IpcMsgType =
| 'history_loaded'
| 'room_created'
| 'create_room'
| 'resumable_transfers'
export interface IpcMessage {
type: IpcMsgType
@@ -129,4 +130,6 @@ export interface IpcMessage {
remote_address?: string
// history_loaded
messages?: ChatMessage[]
// resumable_transfers
resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
}