v1.1: fix transfer freeze, session tabs, per-panel toolbars, status bar

Freeze root cause: TransfersService consumed transferUpdate through an effect
that both read and wrote the jobMap signal — the first progress message spun
the effect into an infinite re-run loop, pegging the webview thread while the
copy completed fine bun-side. Push messages now flow through plain callback
registries on RpcService (also stops signal coalescing from dropping progress
snapshots). Verified live: a 60MB boot-time self-test copy streamed progress
into the running UI and the post-completion panel refreshes arrived.

- Tabs: workspaces (left+right PanelState pair each) in PanelsService; tab bar
  replaces the header; sessions persist in background tabs; Cmd+T/Cmd+W/Ctrl+Tab;
  closing a tab disconnects its sessions; last tab can't close
- Per-panel toolbar (top, icon-only): direction-explicit copy (-> / <-),
  rename, new folder/file, chmod, delete; panel footers removed
- VSCode-style status bar: transfer summary (spinner/percent/queued/failures,
  click to expand the job list) + active panel info; function bar removed
- transfer details list is collapsible, auto-expands on failure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 14:50:19 +02:00
co-authored by Claude Fable 5
parent 76b9b621a2
commit 9eae6c58dc
12 changed files with 410 additions and 158 deletions
+19 -5
View File
@@ -1,4 +1,4 @@
import { Injectable, signal } from '@angular/core';
import { Injectable } from '@angular/core';
import { Electroview } from 'electrobun/view';
import type { AppRPC } from '@shared/rpc';
import type { TransferSnapshot } from '@shared/types';
@@ -13,8 +13,11 @@ export class RpcService {
/** False when the page is opened in a plain browser instead of the app. */
readonly available: boolean;
readonly transferUpdate = signal<TransferSnapshot | null>(null);
readonly sessionClosed = signal<SessionClosedEvent | null>(null);
// Push messages are delivered through plain callbacks, NOT signals: a signal
// consumed from an effect that also writes signals re-triggers itself
// (this froze the app), and signal coalescing would drop progress snapshots.
private transferListeners = new Set<(s: TransferSnapshot) => void>();
private closedListeners = new Set<(e: SessionClosedEvent) => void>();
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve();
@@ -27,8 +30,12 @@ export class RpcService {
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
messages: {
transferUpdate: (snapshot) => this.transferUpdate.set(snapshot),
sessionClosed: (event) => this.sessionClosed.set(event),
transferUpdate: (snapshot) => {
for (const listener of this.transferListeners) listener(snapshot);
},
sessionClosed: (event) => {
for (const listener of this.closedListeners) listener(event);
},
},
},
});
@@ -43,7 +50,14 @@ export class RpcService {
socket.addEventListener('error', () => resolve(), { once: true });
});
}
}
onTransferUpdate(listener: (snapshot: TransferSnapshot) => void): void {
this.transferListeners.add(listener);
}
onSessionClosed(listener: (event: SessionClosedEvent) => void): void {
this.closedListeners.add(listener);
}
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */