+
+
+
+
+
+
+ {{ 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 (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();
}
}