Tray icon with live transfer progress and tabs menu

- 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>
This commit is contained in:
Jokin
2026-07-11 21:41:51 +02:00
co-authored by Claude Fable 5
parent c218cc2104
commit 42e5e5bb9a
10 changed files with 228 additions and 6 deletions
+11 -2
View File
@@ -7,6 +7,7 @@ import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers";
import { ConnectionStore } from "./store/connections";
import { SettingsStore } from "./store/settings";
import { AppTray } from "./tray";
import { TransferEngine } from "./transfers/engine";
const PROD_URL = "views://app/index.html";
@@ -37,13 +38,14 @@ const edits = new EditManager(registry, engine, settings);
const events = {
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
connectionsChanged: () => {},
workspacesSync: (_sync: import("../shared/rpc").WorkspaceSyncInfo) => {},
};
const rpc = createAppRPC({ registry, engine, store, settings, edits, events });
const url = await getMainViewUrl();
const isMac = process.platform === "darwin";
new BrowserWindow({
const win = new BrowserWindow({
title: BRANDING.appName,
url,
frame: {
@@ -63,9 +65,16 @@ new BrowserWindow({
rpc,
});
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
const tray = new AppTray(win, engine);
tray.onAction = (action) => rpc.send.menuAction({ action });
engine.onUpdate = (snapshot) => {
rpc.send.transferUpdate(snapshot);
tray.updateProgress();
};
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
events.connectionsChanged = () => void applyApplicationMenu(store);
events.workspacesSync = (sync) => tray.updateTabs(sync);
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
void applyApplicationMenu(store);
+5 -1
View File
@@ -1,5 +1,5 @@
import { BrowserView, Utils } from "electrobun/bun";
import type { AppRPC } from "../shared/rpc";
import type { AppRPC, WorkspaceSyncInfo } from "../shared/rpc";
import { effectiveConcurrency, type Result } from "../shared/types";
import { readFileText, writeFileText, type EditManager } from "./edits";
import type { SessionRegistry } from "./fs/registry";
@@ -23,6 +23,7 @@ export interface AppDeps {
events: {
sessionClosed: (info: { sessionId: string; reason: string }) => void;
connectionsChanged: () => void;
workspacesSync: (sync: WorkspaceSyncInfo) => void;
};
}
@@ -202,6 +203,9 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
return {};
}),
},
messages: {
workspacesSync: (sync) => events.workspacesSync(sync),
},
},
});
}
+29
View File
@@ -146,6 +146,35 @@ export class TransferEngine {
if (job && TERMINAL.includes(job.snapshot.state)) this.jobs.delete(jobId);
}
/** Live totals over every non-terminal job (drives the tray progress icon). */
aggregateProgress(): {
activeCount: number;
doneBytes: number;
totalBytes: number;
bySession: Record<string, { count: number; done: number; total: number }>;
} {
const result = {
activeCount: 0,
doneBytes: 0,
totalBytes: 0,
bySession: {} as Record<string, { count: number; done: number; total: number }>,
};
for (const job of this.jobs.values()) {
const snap = job.snapshot;
if (TERMINAL.includes(snap.state)) continue;
result.activeCount++;
result.doneBytes += snap.doneBytes;
result.totalBytes += snap.totalBytes;
for (const sessionId of new Set([snap.srcSessionId, snap.dstSessionId])) {
const entry = (result.bySession[sessionId] ??= { count: 0, done: 0, total: 0 });
entry.count++;
entry.done += snap.doneBytes;
entry.total += snap.totalBytes;
}
}
return result;
}
/** Fail every non-terminal job that touches a dead session. */
failSession(sessionId: string, reason: string): void {
for (const job of this.jobs.values()) {
+130
View File
@@ -0,0 +1,130 @@
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);
}
}