- AppTray: template menu-bar icon (22pt, adapts to light/dark). While transfers run it swaps to pre-rendered frames — faded arrows + a fill bar showing aggregate doneBytes/totalBytes of the queue (nearest 5%, throttled 500ms); back to the glyph when idle. No runtime image encoding. - Menu: one entry per tab (checkmark on the active one) with live per-tab transfer count and percentage (jobs mapped via session ids), an overall progress row while transferring, Show <app>, Quit. Selecting a tab activates the window and switches to it. - First use of webview->bun messages: PanelsService pushes workspacesSync (tab names, sessions, active id) via a new ready-gated send proxy; verified end-to-end in the running app. - engine.aggregateProgress(): totals + per-session breakdown over non-terminal jobs (unit-verified idle/mid-transfer/idle). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
131 lines
4.0 KiB
TypeScript
131 lines
4.0 KiB
TypeScript
import { Tray, type BrowserWindow } from "electrobun/bun";
|
|
import { BRANDING } from "../shared/branding";
|
|
import type { WorkspaceSyncInfo } from "../shared/rpc";
|
|
import type { TransferEngine } from "./transfers/engine";
|
|
|
|
const BASE_IMAGE = "views://assets/tray/tray-44-template.png";
|
|
const PROGRESS_IMAGE = (pct: number) => `views://assets/tray/progress/p${pct}.png`;
|
|
const REFRESH_THROTTLE_MS = 500;
|
|
|
|
/**
|
|
* Menu-bar icon: the app glyph when idle; while transfers run the icon swaps
|
|
* to pre-rendered frames (faded arrows + fill bar = queue doneBytes/totalBytes).
|
|
* The menu lists the tabs (synced from the webview) with per-tab job info.
|
|
*/
|
|
export class AppTray {
|
|
private tray: Tray;
|
|
private tabs: WorkspaceSyncInfo = { workspaces: [], activeId: 0 };
|
|
private lastRefresh = 0;
|
|
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private currentImage = BASE_IMAGE;
|
|
/** Forwards tray menu actions the UI must handle (activate-tab:<id>). */
|
|
onAction: (action: string) => void = () => {};
|
|
|
|
constructor(
|
|
private win: BrowserWindow,
|
|
private engine: TransferEngine,
|
|
) {
|
|
this.tray = new Tray({
|
|
title: "",
|
|
image: BASE_IMAGE,
|
|
template: true,
|
|
width: 22,
|
|
height: 22,
|
|
});
|
|
|
|
this.tray.on("tray-clicked", (event) => {
|
|
const { action } = (event as { data: { action: string } }).data;
|
|
if (!action) return; // icon click with a menu set -> macOS opens the menu
|
|
if (action === "show-app") {
|
|
this.win.activate();
|
|
} else if (action === "quit") {
|
|
process.exit(0); // electrobun's clean-quit override
|
|
} else if (action.startsWith("activate-tab:")) {
|
|
this.win.activate();
|
|
this.onAction(action);
|
|
}
|
|
});
|
|
|
|
this.rebuildMenu();
|
|
}
|
|
|
|
updateTabs(sync: WorkspaceSyncInfo): void {
|
|
this.tabs = sync;
|
|
this.scheduleRefresh();
|
|
}
|
|
|
|
/** Called on every engine update; throttled internally. */
|
|
updateProgress(): void {
|
|
this.scheduleRefresh();
|
|
}
|
|
|
|
private scheduleRefresh(): void {
|
|
const now = Date.now();
|
|
const wait = Math.max(0, REFRESH_THROTTLE_MS - (now - this.lastRefresh));
|
|
if (this.refreshTimer) return;
|
|
this.refreshTimer = setTimeout(() => {
|
|
this.refreshTimer = null;
|
|
this.lastRefresh = Date.now();
|
|
this.refresh();
|
|
}, wait);
|
|
}
|
|
|
|
private refresh(): void {
|
|
const progress = this.engine.aggregateProgress();
|
|
|
|
// icon: nearest 5% frame while transferring, base glyph when idle
|
|
let image = BASE_IMAGE;
|
|
if (progress.activeCount > 0 && progress.totalBytes > 0) {
|
|
const pct = Math.round(((progress.doneBytes / progress.totalBytes) * 100) / 5) * 5;
|
|
image = PROGRESS_IMAGE(Math.max(0, Math.min(100, pct)));
|
|
}
|
|
if (image !== this.currentImage) {
|
|
this.currentImage = image;
|
|
this.tray.setImage(image);
|
|
}
|
|
|
|
this.rebuildMenu(progress);
|
|
}
|
|
|
|
private rebuildMenu(progress = this.engine.aggregateProgress()): void {
|
|
const items: Parameters<Tray["setMenu"]>[0] = [];
|
|
|
|
for (const ws of this.tabs.workspaces) {
|
|
const jobs = ws.sessionIds
|
|
.map((id) => progress.bySession[id])
|
|
.filter((v): v is NonNullable<typeof v> => !!v);
|
|
const count = jobs.reduce((n, j) => n + j.count, 0);
|
|
const total = jobs.reduce((n, j) => n + j.total, 0);
|
|
const done = jobs.reduce((n, j) => n + j.done, 0);
|
|
const suffix = count
|
|
? ` — ${count} transfer${count === 1 ? "" : "s"} · ${total ? Math.round((done / total) * 100) : 0}%`
|
|
: "";
|
|
items.push({
|
|
type: "normal",
|
|
label: `${ws.name}${suffix}`,
|
|
action: `activate-tab:${ws.id}`,
|
|
checked: ws.id === this.tabs.activeId,
|
|
});
|
|
}
|
|
|
|
if (progress.activeCount > 0) {
|
|
items.push({ type: "divider" });
|
|
const pct = progress.totalBytes
|
|
? Math.round((progress.doneBytes / progress.totalBytes) * 100)
|
|
: 0;
|
|
items.push({
|
|
type: "normal",
|
|
label: `${progress.activeCount} active transfer${progress.activeCount === 1 ? "" : "s"} · ${pct}%`,
|
|
action: "noop",
|
|
enabled: false,
|
|
});
|
|
}
|
|
|
|
items.push({ type: "divider" });
|
|
items.push({ type: "normal", label: `Show ${BRANDING.appName}`, action: "show-app" });
|
|
items.push({ type: "normal", label: "Quit", action: "quit" });
|
|
|
|
this.tray.setMenu(items);
|
|
}
|
|
}
|