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:
Jokin
2026-07-11 21:55:42 +02:00
co-authored by Claude Fable 5
parent 42e5e5bb9a
commit 3fb08fdc24
31 changed files with 17 additions and 229 deletions
+2 -4
View File
@@ -25,10 +25,8 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
(MB/s or Mbps), persisted across restarts. (MB/s or Mbps), persisted across restarts.
- On macOS the tab bar is fused with the window titlebar; other platforms keep - On macOS the tab bar is fused with the window titlebar; other platforms keep
a standard titlebar. a standard titlebar.
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; during - **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
transfers it fades and fills an aggregate progress bar (delivered/total bytes to bring the app to the front.
of the whole queue). Its menu lists your tabs with live per-tab transfer
info — click one to jump to it — plus Show and Quit.
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local - **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
and remote files directly; or pick an external app (Options → Editor → and remote files directly; or pick an external app (Options → Editor →
Custom → Choose…). External edits of remote files download to a temp folder, Custom → Choose…). External edits of remote files download to a temp folder,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 481 B

+2 -21
View File
@@ -1,7 +1,7 @@
// Regenerate every raster icon asset from the SVG sources (dev-time only). // Regenerate every raster icon asset from the SVG sources (dev-time only).
// bun scripts/gen-icons.ts // bun scripts/gen-icons.ts
// Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon), // Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon),
// assets/tray/tray-44-template.png, assets/tray/progress/p{0..100}.png. // assets/tray/tray-44-template.png.
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { Resvg } from "@resvg/resvg-js"; import { Resvg } from "@resvg/resvg-js";
@@ -31,23 +31,4 @@ for (const size of [16, 32, 128, 256, 512]) {
const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text(); const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text();
await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44); await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44);
// ---- tray progress frames: faded arrows + centered bar filled to pct ---- console.log("icons regenerated: app iconset + tray template");
function progressSvg(pct: number): string {
const trackX = 4;
const trackW = 16;
const fillW = (trackW * pct) / 100;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="none" stroke="#000" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" opacity="0.35">
<path d="M9.2 9.2 4.6 4.6m0 0v4.2m0-4.2h4.2"/>
<path d="m14.8 14.8 4.6 4.6m0 0v-4.2m0 4.2h-4.2"/>
</g>
<rect x="${trackX}" y="10.7" width="${trackW}" height="2.6" rx="1.3" fill="#000" opacity="0.25"/>
${fillW > 0 ? `<rect x="${trackX}" y="10.7" width="${fillW.toFixed(2)}" height="2.6" rx="1.3" fill="#000"/>` : ""}
</svg>`;
}
for (let pct = 0; pct <= 100; pct += 5) {
await writePng(path.join(assets, "tray", "progress", `p${pct}.png`), progressSvg(pct), 44);
}
console.log("icons regenerated: app iconset, tray template, 21 progress frames");
+2 -8
View File
@@ -38,7 +38,6 @@ const edits = new EditManager(registry, engine, settings);
const events = { const events = {
sessionClosed: (_info: { sessionId: string; reason: string }) => {}, sessionClosed: (_info: { sessionId: string; reason: string }) => {},
connectionsChanged: () => {}, connectionsChanged: () => {},
workspacesSync: (_sync: import("../shared/rpc").WorkspaceSyncInfo) => {},
}; };
const rpc = createAppRPC({ registry, engine, store, settings, edits, events }); const rpc = createAppRPC({ registry, engine, store, settings, edits, events });
const url = await getMainViewUrl(); const url = await getMainViewUrl();
@@ -65,16 +64,11 @@ const win = new BrowserWindow({
rpc, rpc,
}); });
const tray = new AppTray(win, engine); new AppTray(win);
tray.onAction = (action) => rpc.send.menuAction({ action });
engine.onUpdate = (snapshot) => { engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
rpc.send.transferUpdate(snapshot);
tray.updateProgress();
};
events.sessionClosed = (info) => rpc.send.sessionClosed(info); events.sessionClosed = (info) => rpc.send.sessionClosed(info);
events.connectionsChanged = () => void applyApplicationMenu(store); events.connectionsChanged = () => void applyApplicationMenu(store);
events.workspacesSync = (sync) => tray.updateTabs(sync);
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s))); void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
void applyApplicationMenu(store); void applyApplicationMenu(store);
+1 -5
View File
@@ -1,5 +1,5 @@
import { BrowserView, Utils } from "electrobun/bun"; 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 { effectiveConcurrency, type Result } from "../shared/types";
import { readFileText, writeFileText, type EditManager } from "./edits"; import { readFileText, writeFileText, type EditManager } from "./edits";
import type { SessionRegistry } from "./fs/registry"; import type { SessionRegistry } from "./fs/registry";
@@ -23,7 +23,6 @@ export interface AppDeps {
events: { events: {
sessionClosed: (info: { sessionId: string; reason: string }) => void; sessionClosed: (info: { sessionId: string; reason: string }) => void;
connectionsChanged: () => void; connectionsChanged: () => void;
workspacesSync: (sync: WorkspaceSyncInfo) => void;
}; };
} }
@@ -203,9 +202,6 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
return {}; return {};
}), }),
}, },
messages: {
workspacesSync: (sync) => events.workspacesSync(sync),
},
}, },
}); });
} }
-29
View File
@@ -146,35 +146,6 @@ export class TransferEngine {
if (job && TERMINAL.includes(job.snapshot.state)) this.jobs.delete(jobId); 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. */ /** Fail every non-terminal job that touches a dead session. */
failSession(sessionId: string, reason: string): void { failSession(sessionId: string, reason: string): void {
for (const job of this.jobs.values()) { for (const job of this.jobs.values()) {
+7 -115
View File
@@ -1,130 +1,22 @@
import { Tray, type BrowserWindow } from "electrobun/bun"; 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 * Menu-bar icon: the app glyph as a macOS template image (adapts to
* to pre-rendered frames (faded arrows + fill bar = queue doneBytes/totalBytes). * light/dark). Clicking it brings the app window to the front. No menu is ever
* The menu lists the tabs (synced from the webview) with per-tab job info. * set, so clicks arrive directly; no setImage swaps, so the template
* treatment from construction always applies.
*/ */
export class AppTray { export class AppTray {
private tray: Tray; 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( constructor(win: BrowserWindow) {
private win: BrowserWindow,
private engine: TransferEngine,
) {
this.tray = new Tray({ this.tray = new Tray({
title: "", title: "",
image: BASE_IMAGE, image: "views://assets/tray/tray-44-template.png",
template: true, template: true,
width: 22, width: 22,
height: 22, height: 22,
}); });
this.tray.on("tray-clicked", () => win.activate());
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);
} }
} }
+1 -9
View File
@@ -15,13 +15,7 @@ import type {
// pulling in bun-only type modules. // pulling in bun-only type modules.
// //
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts // bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
// bun.messages = fire-and-forget pushes from the webview (tray state)
// webview.messages = push events from bun, handled in ui rpc.service.ts // webview.messages = push events from bun, handled in ui rpc.service.ts
export interface WorkspaceSyncInfo {
workspaces: { id: number; name: string; remote: boolean; sessionIds: string[] }[];
activeId: number;
}
export type AppRPC = { export type AppRPC = {
bun: { bun: {
requests: { requests: {
@@ -103,9 +97,7 @@ export type AppRPC = {
}; };
clearEditLeftovers: { params: Record<string, never>; response: Result }; clearEditLeftovers: { params: Record<string, never>; response: Result };
}; };
messages: { messages: Record<never, never>;
workspacesSync: WorkspaceSyncInfo;
};
}; };
webview: { webview: {
requests: Record<never, never>; requests: Record<never, never>;
+1 -6
View File
@@ -67,13 +67,8 @@ export class App {
}); });
} }
/** Native application-menu / tray actions forwarded from the bun process. */ /** Native application-menu actions forwarded from the bun process. */
private handleMenuAction(action: string): void { private handleMenuAction(action: string): void {
if (action.startsWith('activate-tab:')) {
const id = Number(action.slice('activate-tab:'.length));
if (Number.isFinite(id)) this.panels.setActiveWorkspace(id);
return;
}
if (action.startsWith('connect:')) { if (action.startsWith('connect:')) {
const id = action.slice('connect:'.length); const id = action.slice('connect:'.length);
const connection = this.connections.connections().find((c) => c.id === id); const connection = this.connections.connections().find((c) => c.id === id);
+1 -16
View File
@@ -1,4 +1,4 @@
import { Injectable, computed, effect, signal, type WritableSignal } from '@angular/core'; import { Injectable, computed, signal, type WritableSignal } from '@angular/core';
import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types'; import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
@@ -180,21 +180,6 @@ export class PanelsService {
) { ) {
this.newWorkspace(); this.newWorkspace();
// keep the bun-side tray informed about tabs (names, sessions, active)
if (this.rpc.available) {
effect(() => {
this.rpc.send.workspacesSync({
workspaces: this.workspaces().map((ws) => ({
id: ws.id,
name: this.workspaceName(ws),
remote: this.workspaceIsRemote(ws),
sessionIds: [ws.left.sessionId(), ws.right.sessionId()],
})),
activeId: this.activeWorkspaceId(),
});
});
}
// a session died on its own — reset every panel bound to it, in any tab // a session died on its own — reset every panel bound to it, in any tab
this.rpc.onSessionClosed((event) => { this.rpc.onSessionClosed((event) => {
for (const workspace of this.workspaces()) { for (const workspace of this.workspaces()) {
-16
View File
@@ -87,20 +87,4 @@ export class RpcService {
}); });
} }
/** Fire-and-forget messages to the bun process (e.g. workspacesSync). */
get send() {
const rpc = this.rpc;
if (!rpc) throw new Error('RPC is only available inside the desktop app');
const ready = this.ready;
return new Proxy({} as (typeof rpc)['send'], {
get:
(_target, message: string) =>
(payload?: unknown) => {
void ready.then(() => {
const messages = rpc.send as unknown as Record<string, (p?: unknown) => void>;
messages[message](payload);
});
},
});
}
} }