Simplify tray: static template icon, click focuses the app
The setImage-swapped progress frames lost the template treatment on macOS and rendered black-on-dark once transfers started. Dial back per user decision: the tray is now just the glyph (template image set once at construction, never swapped) and clicking it activates the window — no menu, no progress effects. Removes everything that only served the fancy version: progress frames + generation, engine.aggregateProgress, the workspacesSync webview->bun channel (schema, send proxy, panels effect, handlers) and the activate-tab action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+2
-8
@@ -38,7 +38,6 @@ 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();
|
||||
@@ -65,16 +64,11 @@ const win = new BrowserWindow({
|
||||
rpc,
|
||||
});
|
||||
|
||||
const tray = new AppTray(win, engine);
|
||||
tray.onAction = (action) => rpc.send.menuAction({ action });
|
||||
new AppTray(win);
|
||||
|
||||
engine.onUpdate = (snapshot) => {
|
||||
rpc.send.transferUpdate(snapshot);
|
||||
tray.updateProgress();
|
||||
};
|
||||
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BrowserView, Utils } from "electrobun/bun";
|
||||
import type { AppRPC, WorkspaceSyncInfo } from "../shared/rpc";
|
||||
import type { AppRPC } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type Result } from "../shared/types";
|
||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||
import type { SessionRegistry } from "./fs/registry";
|
||||
@@ -23,7 +23,6 @@ export interface AppDeps {
|
||||
events: {
|
||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||
connectionsChanged: () => void;
|
||||
workspacesSync: (sync: WorkspaceSyncInfo) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,9 +202,6 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
messages: {
|
||||
workspacesSync: (sync) => events.workspacesSync(sync),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,35 +146,6 @@ 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()) {
|
||||
|
||||
+7
-115
@@ -1,130 +1,22 @@
|
||||
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.
|
||||
* Menu-bar icon: the app glyph as a macOS template image (adapts to
|
||||
* light/dark). Clicking it brings the app window to the front. No menu is ever
|
||||
* set, so clicks arrive directly; no setImage swaps, so the template
|
||||
* treatment from construction always applies.
|
||||
*/
|
||||
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,
|
||||
) {
|
||||
constructor(win: BrowserWindow) {
|
||||
this.tray = new Tray({
|
||||
title: "",
|
||||
image: BASE_IMAGE,
|
||||
image: "views://assets/tray/tray-44-template.png",
|
||||
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);
|
||||
this.tray.on("tray-clicked", () => win.activate());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user