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:
+137
-43
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user