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
+137 -43
View File
@@ -1,4 +1,4 @@
import { Injectable, computed, effect, signal } from '@angular/core';
import { Injectable, computed, signal, type WritableSignal } from '@angular/core';
import type { ConnectionConfig, FileEntry } from '@shared/types';
import { RpcService } from './rpc.service';
import { ToastService } from './toast.service';
@@ -145,43 +145,157 @@ export class PanelState {
}
}
/** One tab: an independent left/right panel pair. */
export interface Workspace {
id: number;
left: PanelState;
right: PanelState;
activeSide: WritableSignal<Side>;
}
@Injectable({ providedIn: 'root' })
export class PanelsService {
readonly left: PanelState;
readonly right: PanelState;
readonly activeSide = signal<Side>('left');
readonly workspaces = signal<Workspace[]>([]);
readonly activeWorkspaceId = signal(0);
readonly activeWorkspace = computed(() => {
const list = this.workspaces();
return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0];
});
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
readonly focusTick = signal(0);
private nextWorkspaceId = 1;
constructor(
private rpc: RpcService,
private toast: ToastService,
) {
const deps = { rpc, toast };
this.left = new PanelState(deps);
this.right = new PanelState(deps);
if (rpc.available) {
// this service is first injected during template rendering, where signal
// writes (load -> loading.set) are forbidden — defer past the render pass
queueMicrotask(() => {
void this.left.load(null);
void this.right.load(null);
});
}
this.newWorkspace();
// a session died on its own — fall the affected panel(s) back to local
effect(() => {
const event = this.rpc.sessionClosed();
if (!event) return;
for (const panel of [this.left, this.right]) {
if (panel.sessionId() === event.sessionId) {
this.toast.error(`${panel.title()}: disconnected (${event.reason})`);
this.resetToLocal(panel);
// a session died on its own — reset every panel bound to it, in any tab
this.rpc.onSessionClosed((event) => {
for (const workspace of this.workspaces()) {
for (const panel of [workspace.left, workspace.right]) {
if (panel.sessionId() === event.sessionId) {
this.toast.error(`${panel.title()}: disconnected (${event.reason})`);
this.resetToLocal(panel);
}
}
}
});
}
// ---- panels of the active workspace ----
get(side: Side): PanelState {
const workspace = this.activeWorkspace();
return side === 'left' ? workspace.left : workspace.right;
}
activeSide(): Side {
return this.activeWorkspace().activeSide();
}
setActiveSide(side: Side): void {
this.activeWorkspace().activeSide.set(side);
}
active(): PanelState {
return this.get(this.activeSide());
}
otherSide(side: Side): Side {
return side === 'left' ? 'right' : 'left';
}
other(side: Side): PanelState {
return this.get(this.otherSide(side));
}
/** Every panel across every workspace (for session-wide updates). */
allPanels(): PanelState[] {
return this.workspaces().flatMap((w) => [w.left, w.right]);
}
refocus(): void {
this.focusTick.update((n) => n + 1);
}
// ---- workspace (tab) management ----
newWorkspace(): void {
const deps = { rpc: this.rpc, toast: this.toast };
const workspace: Workspace = {
id: this.nextWorkspaceId++,
left: new PanelState(deps),
right: new PanelState(deps),
activeSide: signal<Side>('left'),
};
this.workspaces.update((list) => [...list, workspace]);
this.activeWorkspaceId.set(workspace.id);
if (this.rpc.available) {
// may be called during template rendering, where signal writes inside
// load() are forbidden — defer past the render pass
queueMicrotask(() => {
void workspace.left.load(null);
void workspace.right.load(null);
});
}
this.refocus();
}
/** Close a tab; its remote sessions are disconnected. The last tab stays. */
closeWorkspace(id: number): void {
const list = this.workspaces();
if (list.length <= 1) return;
const index = list.findIndex((w) => w.id === id);
if (index < 0) return;
const workspace = list[index];
for (const panel of [workspace.left, workspace.right]) {
const sessionId = panel.sessionId();
if (sessionId !== 'local') {
void this.rpc.request.disconnect({ sessionId });
}
}
this.workspaces.update((l) => l.filter((w) => w.id !== id));
if (this.activeWorkspaceId() === id) {
const neighbor = list[index + 1] ?? list[index - 1];
this.activeWorkspaceId.set(neighbor.id);
}
this.refocus();
}
setActiveWorkspace(id: number): void {
if (this.activeWorkspaceId() !== id) {
this.activeWorkspaceId.set(id);
this.refocus();
}
}
cycleWorkspace(delta: 1 | -1): void {
const list = this.workspaces();
if (list.length < 2) return;
const index = list.findIndex((w) => w.id === this.activeWorkspaceId());
const next = list[(index + delta + list.length) % list.length];
this.setActiveWorkspace(next.id);
}
/** Tab label: "Local" for a local/local pair, else the remote titles. */
workspaceName(workspace: Workspace): string {
const remoteTitles = [workspace.left, workspace.right]
.filter((p) => p.sessionId() !== 'local')
.map((p) => p.title());
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : 'Local';
}
workspaceIsRemote(workspace: Workspace): boolean {
return workspace.left.sessionId() !== 'local' || workspace.right.sessionId() !== 'local';
}
// ---- session binding ----
/** Connect a panel to a server; replaces (and disconnects) its previous remote session. */
async connectPanel(side: Side, config: ConnectionConfig, title: string): Promise<boolean> {
const panel = this.get(side);
@@ -218,24 +332,4 @@ export class PanelsService {
panel.title.set('Local');
void panel.load(null);
}
get(side: Side): PanelState {
return side === 'left' ? this.left : this.right;
}
active(): PanelState {
return this.get(this.activeSide());
}
otherSide(side: Side): Side {
return side === 'left' ? 'right' : 'left';
}
other(side: Side): PanelState {
return this.get(this.otherSide(side));
}
refocus(): void {
this.focusTick.update((n) => n + 1);
}
}