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:
@@ -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>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user