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:
@@ -25,6 +25,10 @@ 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
|
||||||
|
transfers it fades and fills an aggregate progress bar (delivered/total bytes
|
||||||
|
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,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default {
|
|||||||
},
|
},
|
||||||
copy: {
|
copy: {
|
||||||
"ui/dist/ui/browser": "views/app",
|
"ui/dist/ui/browser": "views/app",
|
||||||
|
"assets/tray": "views/assets/tray",
|
||||||
},
|
},
|
||||||
watchIgnore: ["ui/**", "dist/**"],
|
watchIgnore: ["ui/**", "dist/**"],
|
||||||
mac: {
|
mac: {
|
||||||
|
|||||||
+11
-2
@@ -7,6 +7,7 @@ import { applyApplicationMenu } from "./menu";
|
|||||||
import { createAppRPC } from "./rpc-handlers";
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
import { ConnectionStore } from "./store/connections";
|
import { ConnectionStore } from "./store/connections";
|
||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
|
import { AppTray } from "./tray";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
import { TransferEngine } from "./transfers/engine";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
@@ -37,13 +38,14 @@ 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();
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
|
|
||||||
new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
url,
|
url,
|
||||||
frame: {
|
frame: {
|
||||||
@@ -63,9 +65,16 @@ new BrowserWindow({
|
|||||||
rpc,
|
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.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 +1,5 @@
|
|||||||
import { BrowserView, Utils } from "electrobun/bun";
|
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 { 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,6 +23,7 @@ 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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +203,9 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
messages: {
|
||||||
|
workspacesSync: (sync) => events.workspacesSync(sync),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,35 @@ 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()) {
|
||||||
|
|||||||
+130
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -15,7 +15,13 @@ 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: {
|
||||||
@@ -97,7 +103,9 @@ export type AppRPC = {
|
|||||||
};
|
};
|
||||||
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
||||||
};
|
};
|
||||||
messages: Record<never, never>;
|
messages: {
|
||||||
|
workspacesSync: WorkspaceSyncInfo;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
webview: {
|
webview: {
|
||||||
requests: Record<never, never>;
|
requests: Record<never, never>;
|
||||||
|
|||||||
+6
-1
@@ -67,8 +67,13 @@ export class App {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Native application-menu actions forwarded from the bun process. */
|
/** Native application-menu / tray 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,4 +1,4 @@
|
|||||||
import { Injectable, computed, signal, type WritableSignal } from '@angular/core';
|
import { Injectable, computed, effect, 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,6 +180,21 @@ 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()) {
|
||||||
|
|||||||
@@ -86,4 +86,21 @@ 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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user