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
+4 -62
View File
@@ -1,19 +1,4 @@
<div class="flex h-screen flex-col bg-background text-foreground"> <div class="flex h-screen flex-col bg-background text-foreground">
<header class="flex items-center gap-2 border-b border-border px-3 py-1.5">
<ng-icon name="mynaArrowLeftRight" class="text-muted-foreground" size="0.9rem" />
<h1 class="text-[13px] font-semibold tracking-tight">{{ appName }}</h1>
<div class="flex-1"></div>
@if (rpc.available) {
<button
(click)="dialogs.manager.set(true)"
title="Manage connections"
class="grid size-6 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaCog" size="0.95rem" />
</button>
}
</header>
@if (!rpc.available) { @if (!rpc.available) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
@@ -25,58 +10,15 @@
</div> </div>
</div> </div>
} @else { } @else {
<app-tab-bar />
<main class="flex min-h-0 flex-1 divide-x divide-border"> <main class="flex min-h-0 flex-1 divide-x divide-border">
<app-file-panel side="left" /> <app-file-panel side="left" />
<app-file-panel side="right" /> <app-file-panel side="right" />
</main> </main>
<app-transfer-bar /> <app-transfer-bar />
<app-status-bar />
<!-- function bar: acts on the active panel -->
<footer class="flex items-center gap-1 border-t border-border px-2 py-1">
<button
(click)="transfers.copyToOtherPanel()"
title="Copy to other panel (F5)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaArrowLeftRight" size="0.9rem" /> Copy
</button>
<button
(click)="actions.rename()"
title="Rename (F2)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.9rem" /> Rename
</button>
<button
(click)="actions.mkdir()"
title="New folder (F7)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="0.9rem" /> Folder
</button>
<button
(click)="actions.createFile()"
title="New file"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFilePlus" size="0.9rem" /> File
</button>
<button
(click)="actions.chmod()"
title="Permissions"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLock" size="0.9rem" /> Permissions
</button>
<button
(click)="actions.delete()"
title="Delete (F8 / Del)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.9rem" /> Delete
</button>
</footer>
} }
<!-- dialogs --> <!-- dialogs -->
@@ -94,7 +36,7 @@
} }
<!-- toasts --> <!-- toasts -->
<div class="pointer-events-none fixed right-3 bottom-3 z-50 flex w-80 flex-col gap-2"> <div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
@for (t of toast.toasts(); track t.id) { @for (t of toast.toasts(); track t.id) {
<div <div
class="pointer-events-auto flex items-start gap-2 rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg" class="pointer-events-auto flex items-start gap-2 rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
+22 -5
View File
@@ -6,18 +6,21 @@ import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog';
import { PromptDialog } from './dialogs/prompt-dialog'; import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from './dialogs.service'; import { DialogsService } from './dialogs.service';
import { FileActionsService } from './file-actions.service';
import { FilePanel } from './file-panel/file-panel'; import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from './panels.service';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
import { TransferBar } from './transfer-bar/transfer-bar'; import { TransferBar } from './transfer-bar/transfer-bar';
import { TransfersService } from './transfers.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [
NgIcon, NgIcon,
FilePanel, FilePanel,
TabBar,
StatusBar,
PromptDialog, PromptDialog,
ConfirmDialog, ConfirmDialog,
ChmodDialog, ChmodDialog,
@@ -25,19 +28,33 @@ import { TransfersService } from './transfers.service';
ConnectionManager, ConnectionManager,
], ],
templateUrl: './app.html', templateUrl: './app.html',
host: { '(window:keydown)': 'onWindowKeydown($event)' },
}) })
export class App { export class App {
readonly appName = BRANDING.appName;
readonly rpc = inject(RpcService); readonly rpc = inject(RpcService);
readonly toast = inject(ToastService); readonly toast = inject(ToastService);
readonly dialogs = inject(DialogsService); readonly dialogs = inject(DialogsService);
readonly actions = inject(FileActionsService); readonly panels = inject(PanelsService);
readonly transfers = inject(TransfersService);
constructor() { constructor() {
document.title = BRANDING.appName; document.title = BRANDING.appName;
} }
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void {
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 't') {
event.preventDefault();
this.panels.newWorkspace();
} else if (mod && event.key.toLowerCase() === 'w') {
event.preventDefault();
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
} else if (event.ctrlKey && event.key === 'Tab') {
event.preventDefault();
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
}
}
toastIcon(kind: 'error' | 'info' | 'success'): string { toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'mynaXCircle'; if (kind === 'error') return 'mynaXCircle';
if (kind === 'success') return 'mynaCheckCircle'; if (kind === 'success') return 'mynaCheckCircle';
+8 -14
View File
@@ -2,17 +2,16 @@ import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types'; import type { FileEntry } from '@shared/types';
import { DialogsService } from './dialogs.service'; import { DialogsService } from './dialogs.service';
import { joinPath } from './format'; import { joinPath } from './format';
import { PanelsService, type PanelState } from './panels.service'; import { type PanelState } from './panels.service';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
/** /**
* File operations invoked from the function bar / keyboard. They always act on * File operations invoked from a panel's toolbar / keyboard. They act on the
* the ACTIVE panel: on the selection if there is one, else on the cursor row. * GIVEN panel: on the selection if there is one, else on the cursor row.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class FileActionsService { export class FileActionsService {
private readonly panels = inject(PanelsService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
@@ -25,8 +24,7 @@ export class FileActionsService {
return atCursor ? [atCursor] : []; return atCursor ? [atCursor] : [];
} }
mkdir(): void { mkdir(panel: PanelState): void {
const panel = this.panels.active();
this.dialogs.prompt.set({ this.dialogs.prompt.set({
title: 'New folder', title: 'New folder',
placeholder: 'folder name', placeholder: 'folder name',
@@ -42,8 +40,7 @@ export class FileActionsService {
}); });
} }
createFile(): void { createFile(panel: PanelState): void {
const panel = this.panels.active();
this.dialogs.prompt.set({ this.dialogs.prompt.set({
title: 'New file', title: 'New file',
placeholder: 'file name', placeholder: 'file name',
@@ -59,8 +56,7 @@ export class FileActionsService {
}); });
} }
rename(): void { rename(panel: PanelState): void {
const panel = this.panels.active();
const entry = panel.sorted()[panel.cursor()]; const entry = panel.sorted()[panel.cursor()];
if (!entry) return; if (!entry) return;
this.dialogs.prompt.set({ this.dialogs.prompt.set({
@@ -80,8 +76,7 @@ export class FileActionsService {
}); });
} }
delete(): void { delete(panel: PanelState): void {
const panel = this.panels.active();
const targets = this.targets(panel); const targets = this.targets(panel);
if (!targets.length) return; if (!targets.length) return;
const what = const what =
@@ -102,8 +97,7 @@ export class FileActionsService {
}); });
} }
chmod(): void { chmod(panel: PanelState): void {
const panel = this.panels.active();
const targets = this.targets(panel); const targets = this.targets(panel);
if (!targets.length) return; if (!targets.length) return;
this.dialogs.chmod.set({ this.dialogs.chmod.set({
+47 -10
View File
@@ -1,5 +1,5 @@
<!-- session strip --> <!-- session strip -->
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1.5"> <div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1">
<button <button
(click)="menuOpen.set(!menuOpen())" (click)="menuOpen.set(!menuOpen())"
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium hover:bg-accent" class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
@@ -27,6 +27,52 @@
</button> </button>
} }
<div class="flex-1"></div>
<!-- panel actions: act on THIS panel -->
<button
(click)="copyToOther()"
[title]="side() === 'left' ? 'Copy to right panel (F5)' : 'Copy to left panel (F5)'"
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-primary"
>
<ng-icon [name]="copyIcon()" size="0.95rem" />
</button>
<button
(click)="run('rename')"
title="Rename (F2)"
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.9rem" />
</button>
<button
(click)="run('mkdir')"
title="New folder (F7)"
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="0.9rem" />
</button>
<button
(click)="run('createFile')"
title="New file"
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFilePlus" size="0.9rem" />
</button>
<button
(click)="run('chmod')"
title="Permissions"
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLock" size="0.9rem" />
</button>
<button
(click)="run('delete')"
title="Delete (F8 / Del)"
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.9rem" />
</button>
@if (menuOpen()) { @if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div> <div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div <div
@@ -135,7 +181,6 @@
(keydown)="onKeydown($event)" (keydown)="onKeydown($event)"
(mousedown)="activate()" (mousedown)="activate()"
class="flex-1 overflow-y-auto outline-none" class="flex-1 overflow-y-auto outline-none"
[class.bg-accent]="false"
> >
@for (entry of panel().sorted(); track entry.name; let i = $index) { @for (entry of panel().sorted(); track entry.name; let i = $index) {
<div <div
@@ -168,11 +213,3 @@
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div> <div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
} }
</div> </div>
<!-- footer -->
<div class="flex items-center gap-2 border-t border-border px-2 py-1 text-[11px] text-muted-foreground">
<span>{{ panel().sorted().length }} items</span>
@if (panel().selected().size; as count) {
<span>· {{ count }} selected</span>
}
</div>
+18 -6
View File
@@ -34,6 +34,8 @@ export class FilePanel {
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isActive = computed(() => this.panels.activeSide() === this.side());
readonly isRemote = computed(() => this.panel().sessionId() !== 'local'); readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
/** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
readonly pathDraft = signal(''); readonly pathDraft = signal('');
readonly menuOpen = signal(false); readonly menuOpen = signal(false);
@@ -55,7 +57,17 @@ export class FilePanel {
} }
activate(): void { activate(): void {
this.panels.activeSide.set(this.side()); this.panels.setActiveSide(this.side());
}
copyToOther(): void {
this.activate();
void this.transfers.copyToOtherPanel(this.side());
}
run(action: 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
this.activate();
this.actions[action](this.panel());
} }
pickLocal(): void { pickLocal(): void {
@@ -129,7 +141,7 @@ export class FilePanel {
if (key === 'Tab') { if (key === 'Tab') {
event.preventDefault(); event.preventDefault();
this.panels.activeSide.set(this.panels.otherSide(this.side())); this.panels.setActiveSide(this.panels.otherSide(this.side()));
return; return;
} }
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') { if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') {
@@ -171,17 +183,17 @@ export class FilePanel {
panel.up(); panel.up();
break; break;
case 'F2': case 'F2':
this.actions.rename(); this.actions.rename(panel);
break; break;
case 'F5': case 'F5':
void this.transfers.copyToOtherPanel(); void this.transfers.copyToOtherPanel(this.side());
break; break;
case 'F7': case 'F7':
this.actions.mkdir(); this.actions.mkdir(panel);
break; break;
case 'F8': case 'F8':
case 'Delete': case 'Delete':
this.actions.delete(); this.actions.delete(panel);
break; break;
default: default:
return; // let unhandled keys through return; // let unhandled keys through
+4
View File
@@ -2,7 +2,9 @@
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline // NOTE: @ng-icons/mynaui's package root is empty — always import from /outline
// (or /solid, where names get a `Solid` suffix). // (or /solid, where names get a `Solid` suffix).
import { import {
mynaArrowLeft,
mynaArrowLeftRight, mynaArrowLeftRight,
mynaArrowRight,
mynaArrowUp, mynaArrowUp,
mynaCheck, mynaCheck,
mynaCheckCircle, mynaCheckCircle,
@@ -27,7 +29,9 @@ import {
} from "@ng-icons/mynaui/outline"; } from "@ng-icons/mynaui/outline";
export const APP_ICONS = { export const APP_ICONS = {
mynaArrowLeft,
mynaArrowLeftRight, mynaArrowLeftRight,
mynaArrowRight,
mynaArrowUp, mynaArrowUp,
mynaCheck, mynaCheck,
mynaCheckCircle, mynaCheckCircle,
+134 -40
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 type { ConnectionConfig, FileEntry } from '@shared/types';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { ToastService } from './toast.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' }) @Injectable({ providedIn: 'root' })
export class PanelsService { export class PanelsService {
readonly left: PanelState; readonly workspaces = signal<Workspace[]>([]);
readonly right: PanelState; readonly activeWorkspaceId = signal(0);
readonly activeSide = signal<Side>('left'); 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). */ /** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
readonly focusTick = signal(0); readonly focusTick = signal(0);
private nextWorkspaceId = 1;
constructor( constructor(
private rpc: RpcService, private rpc: RpcService,
private toast: ToastService, private toast: ToastService,
) { ) {
const deps = { rpc, toast }; this.newWorkspace();
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);
});
}
// a session died on its own — fall the affected panel(s) back to local // a session died on its own — reset every panel bound to it, in any tab
effect(() => { this.rpc.onSessionClosed((event) => {
const event = this.rpc.sessionClosed(); for (const workspace of this.workspaces()) {
if (!event) return; for (const panel of [workspace.left, workspace.right]) {
for (const panel of [this.left, this.right]) {
if (panel.sessionId() === event.sessionId) { if (panel.sessionId() === event.sessionId) {
this.toast.error(`${panel.title()}: disconnected (${event.reason})`); this.toast.error(`${panel.title()}: disconnected (${event.reason})`);
this.resetToLocal(panel); 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. */ /** Connect a panel to a server; replaces (and disconnects) its previous remote session. */
async connectPanel(side: Side, config: ConnectionConfig, title: string): Promise<boolean> { async connectPanel(side: Side, config: ConnectionConfig, title: string): Promise<boolean> {
const panel = this.get(side); const panel = this.get(side);
@@ -218,24 +332,4 @@ export class PanelsService {
panel.title.set('Local'); panel.title.set('Local');
void panel.load(null); 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);
}
} }
+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 { Electroview } from 'electrobun/view';
import type { AppRPC } from '@shared/rpc'; import type { AppRPC } from '@shared/rpc';
import type { TransferSnapshot } from '@shared/types'; 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. */ /** False when the page is opened in a plain browser instead of the app. */
readonly available: boolean; readonly available: boolean;
readonly transferUpdate = signal<TransferSnapshot | null>(null); // Push messages are delivered through plain callbacks, NOT signals: a signal
readonly sessionClosed = signal<SessionClosedEvent | null>(null); // 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 rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve(); private ready: Promise<void> = Promise.resolve();
@@ -27,8 +30,12 @@ export class RpcService {
maxRequestTime: MAX_REQUEST_TIME_MS, maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: { handlers: {
messages: { messages: {
transferUpdate: (snapshot) => this.transferUpdate.set(snapshot), transferUpdate: (snapshot) => {
sessionClosed: (event) => this.sessionClosed.set(event), 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 }); 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. */ /** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
+66
View File
@@ -0,0 +1,66 @@
import { Component, computed, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { TransferSnapshot } from '@shared/types';
import { PanelsService } from '../panels.service';
import { TransfersService } from '../transfers.service';
@Component({
selector: 'app-status-bar',
imports: [NgIcon],
template: `
<div
class="flex h-6 items-center justify-between gap-4 border-t border-border px-3 text-[11px] text-muted-foreground select-none"
>
<!-- transfers summary; click toggles the detailed list -->
<button
(click)="transfers.expanded.set(!transfers.expanded())"
class="flex min-w-0 items-center gap-1.5 hover:text-foreground"
title="Show transfer details"
>
@if (transfers.currentJob(); as job) {
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
<span class="truncate">
Copying {{ job.name }} · {{ percent(job) }}%
@if (transfers.queuedCount(); as queued) {
· {{ queued }} queued
}
</span>
} @else if (transfers.failedJobs().length; as failed) {
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
<span class="text-destructive">
{{ failed }} transfer{{ failed === 1 ? '' : 's' }} failed
</span>
} @else if (transfers.hasJobs()) {
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
<span>Transfers finished</span>
} @else {
<span>Ready</span>
}
</button>
<!-- active panel info -->
<div class="flex shrink-0 items-center gap-1.5">
<ng-icon
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'"
size="0.8rem"
[class.text-primary]="activePanel().sessionId() !== 'local'"
/>
<span class="max-w-56 truncate">{{ activePanel().title() }}</span>
<span>· {{ activePanel().sorted().length }} items</span>
@if (activePanel().selected().size; as count) {
<span>· {{ count }} selected</span>
}
</div>
</div>
`,
})
export class StatusBar {
readonly transfers = inject(TransfersService);
readonly panels = inject(PanelsService);
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
percent(job: TransferSnapshot): number {
if (!job.totalBytes) return 0;
return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100));
}
}
+63
View File
@@ -0,0 +1,63 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { PanelsService } from '../panels.service';
@Component({
selector: 'app-tab-bar',
imports: [NgIcon],
template: `
<div class="flex items-center gap-0.5 border-b border-border px-1.5 pt-1">
@for (ws of panels.workspaces(); track ws.id) {
<div
class="group relative flex max-w-52 items-center rounded-t-md"
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
>
<button
(click)="panels.setActiveWorkspace(ws.id)"
class="flex min-w-0 items-center gap-1.5 py-1.5 pr-1 pl-2.5 text-xs"
[class.text-foreground]="ws.id === panels.activeWorkspaceId()"
[class.text-muted-foreground]="ws.id !== panels.activeWorkspaceId()"
[title]="panels.workspaceName(ws)"
>
<ng-icon
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
size="0.85rem"
class="shrink-0"
[class.text-primary]="panels.workspaceIsRemote(ws)"
/>
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
</button>
@if (panels.workspaces().length > 1) {
<button
(click)="close($event, ws.id)"
title="Close tab (⌘W)"
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
>
<ng-icon name="mynaX" size="0.7rem" />
</button>
} @else {
<span class="mr-1 size-4 shrink-0"></span>
}
@if (ws.id === panels.activeWorkspaceId()) {
<div class="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-primary"></div>
}
</div>
}
<button
(click)="panels.newWorkspace()"
title="New tab (⌘T)"
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size="0.9rem" />
</button>
</div>
`,
})
export class TabBar {
readonly panels = inject(PanelsService);
close(event: MouseEvent, id: number): void {
event.stopPropagation();
this.panels.closeWorkspace(id);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
@if (transfers.hasJobs()) { @if (transfers.expanded() && transfers.hasJobs()) {
<div class="max-h-40 overflow-y-auto border-t border-border"> <div class="max-h-40 overflow-y-auto border-t border-border">
@for (job of transfers.jobs(); track job.id) { @for (job of transfers.jobs(); track job.id) {
<div class="flex items-center gap-2 px-3 py-1.5 text-xs"> <div class="flex items-center gap-2 px-3 py-1.5 text-xs">
+21 -12
View File
@@ -1,10 +1,11 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import type { TransferSnapshot } from '@shared/types'; import type { TransferSnapshot } from '@shared/types';
import { PanelsService } from './panels.service'; import { PanelsService, type Side } from './panels.service';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
const AUTO_DISMISS_COMPLETED_MS = 5000; const AUTO_DISMISS_COMPLETED_MS = 5000;
const ACTIVE_STATES = ['queued', 'scanning', 'running'];
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TransfersService { export class TransfersService {
@@ -15,19 +16,26 @@ export class TransfersService {
private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map()); private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map());
readonly jobs = computed(() => [...this.jobMap().values()]); readonly jobs = computed(() => [...this.jobMap().values()]);
readonly hasJobs = computed(() => this.jobMap().size > 0); readonly hasJobs = computed(() => this.jobMap().size > 0);
readonly activeJobs = computed(() => this.jobs().filter((j) => ACTIVE_STATES.includes(j.state)));
readonly currentJob = computed(
() => this.jobs().find((j) => j.state === 'running' || j.state === 'scanning') ?? null,
);
readonly queuedCount = computed(() => this.jobs().filter((j) => j.state === 'queued').length);
readonly failedJobs = computed(() => this.jobs().filter((j) => j.state === 'failed'));
/** Whether the detailed per-job list is shown (toggled from the status bar). */
readonly expanded = signal(false);
constructor() { constructor() {
effect(() => { if (this.rpc.available) {
const snapshot = this.rpc.transferUpdate(); this.rpc.onTransferUpdate((snapshot) => this.apply(snapshot));
if (snapshot) this.apply(snapshot); }
});
} }
/** Copy the active panel's targets into the other panel's cwd (the F5 action). */ /** Copy the given panel's targets into the other panel's cwd (F5 / toolbar). */
async copyToOtherPanel(): Promise<void> { async copyToOtherPanel(fromSide: Side): Promise<void> {
const side = this.panels.activeSide(); const src = this.panels.get(fromSide);
const src = this.panels.get(side); const dst = this.panels.other(fromSide);
const dst = this.panels.other(side);
const selected = src.selectedEntries(); const selected = src.selectedEntries();
const atCursor = src.sorted()[src.cursor()]; const atCursor = src.sorted()[src.cursor()];
@@ -76,6 +84,7 @@ export class TransfersService {
this.refreshPanelsOn(snapshot.dstSessionId); this.refreshPanelsOn(snapshot.dstSessionId);
setTimeout(() => void this.dismiss(snapshot.id), AUTO_DISMISS_COMPLETED_MS); setTimeout(() => void this.dismiss(snapshot.id), AUTO_DISMISS_COMPLETED_MS);
} else if (snapshot.state === 'failed') { } else if (snapshot.state === 'failed') {
this.expanded.set(true);
this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`); this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`);
this.refreshPanelsOn(snapshot.dstSessionId); this.refreshPanelsOn(snapshot.dstSessionId);
} else if (snapshot.state === 'cancelled') { } else if (snapshot.state === 'cancelled') {
@@ -84,7 +93,7 @@ export class TransfersService {
} }
private refreshPanelsOn(sessionId: string): void { private refreshPanelsOn(sessionId: string): void {
for (const panel of [this.panels.left, this.panels.right]) { for (const panel of this.panels.allPanels()) {
if (panel.sessionId() === sessionId) void panel.refresh(); if (panel.sessionId() === sessionId) void panel.refresh();
} }
} }