P3: transfer engine with queue, progress, cancel + transfer bar UI

- TransferEngine: FIFO queue, provider-generic stream pipeline (covers all four
  panel combos), throttled transferUpdate push, cancel via stream destroy,
  self-copy guards, failSession hook for dead SSH sessions
- TransfersService + TransferBar: per-job rows, progress bars, cancel/dismiss,
  auto-dismiss completed, dst panel refresh
- F5 + Copy button wired to active-panel selection
- verified: 30MB nested tree copy (UTF-8 names, empty dirs), self-copy rejection,
  mid-transfer cancel leaving partial file

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 13:46:34 +02:00
co-authored by Claude Fable 5
parent b907e0a4ce
commit 88af44acfc
9 changed files with 455 additions and 7 deletions
+17 -5
View File
@@ -2,6 +2,7 @@ import { BrowserView } from "electrobun/bun";
import type { AppRPC } from "../shared/rpc";
import type { Result } from "../shared/types";
import type { SessionRegistry } from "./fs/registry";
import type { TransferEngine } from "./transfers/engine";
import { removeRecursive } from "./transfers/walk";
// SFTP ops can be slow (big listings, recursive deletes) — the 1s electrobun
@@ -18,7 +19,7 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
const NOT_YET = { ok: false as const, error: "Not implemented yet" };
export function createAppRPC(registry: SessionRegistry) {
export function createAppRPC(registry: SessionRegistry, engine: TransferEngine) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
@@ -70,10 +71,21 @@ export function createAppRPC(registry: SessionRegistry) {
// P4: SFTP sessions
connect: async () => NOT_YET,
disconnect: async () => NOT_YET,
// P3: transfer engine
enqueueTransfers: async () => NOT_YET,
cancelTransfer: async () => NOT_YET,
dismissTransfer: async () => NOT_YET,
enqueueTransfers: (params) =>
wrap(async () => ({ jobIds: engine.enqueue(params) })),
cancelTransfer: ({ jobId }) =>
wrap(async () => {
engine.cancel(jobId);
return {};
}),
dismissTransfer: ({ jobId }) =>
wrap(async () => {
engine.dismiss(jobId);
return {};
}),
// P4: connection store
getConnections: async () => NOT_YET,
upsertConnection: async () => NOT_YET,