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:
+4
-62
@@ -1,19 +1,4 @@
|
||||
<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) {
|
||||
<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">
|
||||
@@ -25,58 +10,15 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<app-tab-bar />
|
||||
|
||||
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
||||
<app-file-panel side="left" />
|
||||
<app-file-panel side="right" />
|
||||
</main>
|
||||
|
||||
<app-transfer-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>
|
||||
<app-status-bar />
|
||||
}
|
||||
|
||||
<!-- dialogs -->
|
||||
@@ -94,7 +36,7 @@
|
||||
}
|
||||
|
||||
<!-- 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) {
|
||||
<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"
|
||||
|
||||
+22
-5
@@ -6,18 +6,21 @@ import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { FileActionsService } from './file-actions.service';
|
||||
import { FilePanel } from './file-panel/file-panel';
|
||||
import { PanelsService } from './panels.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 { TransferBar } from './transfer-bar/transfer-bar';
|
||||
import { TransfersService } from './transfers.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
NgIcon,
|
||||
FilePanel,
|
||||
TabBar,
|
||||
StatusBar,
|
||||
PromptDialog,
|
||||
ConfirmDialog,
|
||||
ChmodDialog,
|
||||
@@ -25,19 +28,33 @@ import { TransfersService } from './transfers.service';
|
||||
ConnectionManager,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
host: { '(window:keydown)': 'onWindowKeydown($event)' },
|
||||
})
|
||||
export class App {
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly rpc = inject(RpcService);
|
||||
readonly toast = inject(ToastService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
readonly actions = inject(FileActionsService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly panels = inject(PanelsService);
|
||||
|
||||
constructor() {
|
||||
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 {
|
||||
if (kind === 'error') return 'mynaXCircle';
|
||||
if (kind === 'success') return 'mynaCheckCircle';
|
||||
|
||||
@@ -2,17 +2,16 @@ import { Injectable, inject } from '@angular/core';
|
||||
import type { FileEntry } from '@shared/types';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { joinPath } from './format';
|
||||
import { PanelsService, type PanelState } from './panels.service';
|
||||
import { type PanelState } from './panels.service';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
/**
|
||||
* File operations invoked from the function bar / keyboard. They always act on
|
||||
* the ACTIVE panel: on the selection if there is one, else on the cursor row.
|
||||
* File operations invoked from a panel's toolbar / keyboard. They act on the
|
||||
* GIVEN panel: on the selection if there is one, else on the cursor row.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileActionsService {
|
||||
private readonly panels = inject(PanelsService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
@@ -25,8 +24,7 @@ export class FileActionsService {
|
||||
return atCursor ? [atCursor] : [];
|
||||
}
|
||||
|
||||
mkdir(): void {
|
||||
const panel = this.panels.active();
|
||||
mkdir(panel: PanelState): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: 'New folder',
|
||||
placeholder: 'folder name',
|
||||
@@ -42,8 +40,7 @@ export class FileActionsService {
|
||||
});
|
||||
}
|
||||
|
||||
createFile(): void {
|
||||
const panel = this.panels.active();
|
||||
createFile(panel: PanelState): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: 'New file',
|
||||
placeholder: 'file name',
|
||||
@@ -59,8 +56,7 @@ export class FileActionsService {
|
||||
});
|
||||
}
|
||||
|
||||
rename(): void {
|
||||
const panel = this.panels.active();
|
||||
rename(panel: PanelState): void {
|
||||
const entry = panel.sorted()[panel.cursor()];
|
||||
if (!entry) return;
|
||||
this.dialogs.prompt.set({
|
||||
@@ -80,8 +76,7 @@ export class FileActionsService {
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
const panel = this.panels.active();
|
||||
delete(panel: PanelState): void {
|
||||
const targets = this.targets(panel);
|
||||
if (!targets.length) return;
|
||||
const what =
|
||||
@@ -102,8 +97,7 @@ export class FileActionsService {
|
||||
});
|
||||
}
|
||||
|
||||
chmod(): void {
|
||||
const panel = this.panels.active();
|
||||
chmod(panel: PanelState): void {
|
||||
const targets = this.targets(panel);
|
||||
if (!targets.length) return;
|
||||
this.dialogs.chmod.set({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- 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
|
||||
(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"
|
||||
@@ -27,6 +27,52 @@
|
||||
</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()) {
|
||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
||||
<div
|
||||
@@ -135,7 +181,6 @@
|
||||
(keydown)="onKeydown($event)"
|
||||
(mousedown)="activate()"
|
||||
class="flex-1 overflow-y-auto outline-none"
|
||||
[class.bg-accent]="false"
|
||||
>
|
||||
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
||||
<div
|
||||
@@ -168,11 +213,3 @@
|
||||
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+134
-40
@@ -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]) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
@if (transfers.hasJobs()) {
|
||||
@if (transfers.expanded() && transfers.hasJobs()) {
|
||||
<div class="max-h-40 overflow-y-auto border-t border-border">
|
||||
@for (job of transfers.jobs(); track job.id) {
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||
|
||||
@@ -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<ReadonlyMap<string, TransferSnapshot>>(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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user