diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 36938ab..304b462 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -1,19 +1,4 @@
-
- -

{{ appName }}

-
- @if (rpc.available) { - - } -
- @if (!rpc.available) {
@@ -25,58 +10,15 @@
} @else { + +
- - -
- - - - - - -
+ } @@ -94,7 +36,7 @@ } -
+
@for (t of toast.toasts(); track t.id) {
-
+
+ + + + + + @if (menuOpen()) {
@for (entry of panel().sorted(); track entry.name; let i = $index) {
Empty directory
}
- - -
- {{ panel().sorted().length }} items - @if (panel().selected().size; as count) { - · {{ count }} selected - } -
diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 527f926..9b4c04b 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -34,6 +34,8 @@ export class FilePanel { readonly panel = computed(() => this.panels.get(this.side())); readonly isActive = computed(() => this.panels.activeSide() === this.side()); 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 menuOpen = signal(false); @@ -55,7 +57,17 @@ export class FilePanel { } 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 { @@ -129,7 +141,7 @@ export class FilePanel { if (key === 'Tab') { event.preventDefault(); - this.panels.activeSide.set(this.panels.otherSide(this.side())); + this.panels.setActiveSide(this.panels.otherSide(this.side())); return; } if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') { @@ -171,17 +183,17 @@ export class FilePanel { panel.up(); break; case 'F2': - this.actions.rename(); + this.actions.rename(panel); break; case 'F5': - void this.transfers.copyToOtherPanel(); + void this.transfers.copyToOtherPanel(this.side()); break; case 'F7': - this.actions.mkdir(); + this.actions.mkdir(panel); break; case 'F8': case 'Delete': - this.actions.delete(); + this.actions.delete(panel); break; default: return; // let unhandled keys through diff --git a/ui/src/app/icons.ts b/ui/src/app/icons.ts index 3fbb59c..040727a 100644 --- a/ui/src/app/icons.ts +++ b/ui/src/app/icons.ts @@ -2,7 +2,9 @@ // NOTE: @ng-icons/mynaui's package root is empty — always import from /outline // (or /solid, where names get a `Solid` suffix). import { + mynaArrowLeft, mynaArrowLeftRight, + mynaArrowRight, mynaArrowUp, mynaCheck, mynaCheckCircle, @@ -27,7 +29,9 @@ import { } from "@ng-icons/mynaui/outline"; export const APP_ICONS = { + mynaArrowLeft, mynaArrowLeftRight, + mynaArrowRight, mynaArrowUp, mynaCheck, mynaCheckCircle, diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index 25138e7..93e63bb 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -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; +} + @Injectable({ providedIn: 'root' }) export class PanelsService { - readonly left: PanelState; - readonly right: PanelState; - readonly activeSide = signal('left'); + readonly workspaces = signal([]); + 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('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 { 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); - } } diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts index f7dde03..74d7cbd 100644 --- a/ui/src/app/rpc.service.ts +++ b/ui/src/app/rpc.service.ts @@ -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(null); - readonly sessionClosed = signal(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> | undefined; private ready: Promise = 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. */ diff --git a/ui/src/app/status-bar/status-bar.ts b/ui/src/app/status-bar/status-bar.ts new file mode 100644 index 0000000..102d384 --- /dev/null +++ b/ui/src/app/status-bar/status-bar.ts @@ -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: ` +
+ + + + +
+ + {{ activePanel().title() }} + · {{ activePanel().sorted().length }} items + @if (activePanel().selected().size; as count) { + · {{ count }} selected + } +
+
+ `, +}) +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)); + } +} diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts new file mode 100644 index 0000000..d643462 --- /dev/null +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -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: ` +
+ @for (ws of panels.workspaces(); track ws.id) { +
+ + @if (panels.workspaces().length > 1) { + + } @else { + + } + @if (ws.id === panels.activeWorkspaceId()) { +
+ } +
+ } + +
+ `, +}) +export class TabBar { + readonly panels = inject(PanelsService); + + close(event: MouseEvent, id: number): void { + event.stopPropagation(); + this.panels.closeWorkspace(id); + } +} diff --git a/ui/src/app/transfer-bar/transfer-bar.html b/ui/src/app/transfer-bar/transfer-bar.html index e4c6d97..f664460 100644 --- a/ui/src/app/transfer-bar/transfer-bar.html +++ b/ui/src/app/transfer-bar/transfer-bar.html @@ -1,4 +1,4 @@ -@if (transfers.hasJobs()) { +@if (transfers.expanded() && transfers.hasJobs()) {
@for (job of transfers.jobs(); track job.id) {
diff --git a/ui/src/app/transfers.service.ts b/ui/src/app/transfers.service.ts index 6a1b7be..842a939 100644 --- a/ui/src/app/transfers.service.ts +++ b/ui/src/app/transfers.service.ts @@ -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 { PanelsService } from './panels.service'; +import { PanelsService, type Side } from './panels.service'; import { RpcService } from './rpc.service'; import { ToastService } from './toast.service'; const AUTO_DISMISS_COMPLETED_MS = 5000; +const ACTIVE_STATES = ['queued', 'scanning', 'running']; @Injectable({ providedIn: 'root' }) export class TransfersService { @@ -15,19 +16,26 @@ export class TransfersService { private readonly jobMap = signal>(new Map()); readonly jobs = computed(() => [...this.jobMap().values()]); 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() { - effect(() => { - const snapshot = this.rpc.transferUpdate(); - if (snapshot) this.apply(snapshot); - }); + if (this.rpc.available) { + this.rpc.onTransferUpdate((snapshot) => this.apply(snapshot)); + } } - /** Copy the active panel's targets into the other panel's cwd (the F5 action). */ - async copyToOtherPanel(): Promise { - const side = this.panels.activeSide(); - const src = this.panels.get(side); - const dst = this.panels.other(side); + /** Copy the given panel's targets into the other panel's cwd (F5 / toolbar). */ + async copyToOtherPanel(fromSide: Side): Promise { + const src = this.panels.get(fromSide); + const dst = this.panels.other(fromSide); const selected = src.selectedEntries(); const atCursor = src.sorted()[src.cursor()]; @@ -76,6 +84,7 @@ export class TransfersService { this.refreshPanelsOn(snapshot.dstSessionId); setTimeout(() => void this.dismiss(snapshot.id), AUTO_DISMISS_COMPLETED_MS); } else if (snapshot.state === 'failed') { + this.expanded.set(true); this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`); this.refreshPanelsOn(snapshot.dstSessionId); } else if (snapshot.state === 'cancelled') { @@ -84,7 +93,7 @@ export class TransfersService { } 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(); } }