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">
|
<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
@@ -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';
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+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 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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">
|
<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">
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user