diff --git a/README.md b/README.md index ca73743..0ab8784 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ remote server over SSH/SFTP, so you can move files **local↔remote**, (MB/s or Mbps), persisted across restarts. - On macOS the tab bar is fused with the window titlebar; other platforms keep 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 and remote files directly; or pick an external app (Options → Editor → Custom → Choose…). External edits of remote files download to a temp folder, diff --git a/electrobun.config.ts b/electrobun.config.ts index b9155f4..18930be 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -16,6 +16,7 @@ export default { }, copy: { "ui/dist/ui/browser": "views/app", + "assets/tray": "views/assets/tray", }, watchIgnore: ["ui/**", "dist/**"], mac: { diff --git a/src/bun/index.ts b/src/bun/index.ts index 32fe4ea..0ff2ce0 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -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); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index fcf367a..bf3262e 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -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), + }, }, }); } diff --git a/src/bun/transfers/engine.ts b/src/bun/transfers/engine.ts index db9a0c0..6563a54 100644 --- a/src/bun/transfers/engine.ts +++ b/src/bun/transfers/engine.ts @@ -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; + } { + const result = { + activeCount: 0, + doneBytes: 0, + totalBytes: 0, + bySession: {} as Record, + }; + 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()) { diff --git a/src/bun/tray.ts b/src/bun/tray.ts new file mode 100644 index 0000000..4a97d45 --- /dev/null +++ b/src/bun/tray.ts @@ -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 | null = null; + private currentImage = BASE_IMAGE; + /** Forwards tray menu actions the UI must handle (activate-tab:). */ + 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[0] = []; + + for (const ws of this.tabs.workspaces) { + const jobs = ws.sessionIds + .map((id) => progress.bySession[id]) + .filter((v): v is NonNullable => !!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); + } +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 3c2ff18..ce9de9b 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -15,7 +15,13 @@ import type { // pulling in bun-only type modules. // // 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 + +export interface WorkspaceSyncInfo { + workspaces: { id: number; name: string; remote: boolean; sessionIds: string[] }[]; + activeId: number; +} export type AppRPC = { bun: { requests: { @@ -97,7 +103,9 @@ export type AppRPC = { }; clearEditLeftovers: { params: Record; response: Result }; }; - messages: Record; + messages: { + workspacesSync: WorkspaceSyncInfo; + }; }; webview: { requests: Record; diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index a45f3c1..c63fb78 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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 { + 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:')) { const id = action.slice('connect:'.length); const connection = this.connections.connections().find((c) => c.id === id); diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index d5785f1..a864536 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -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 { RpcService } from './rpc.service'; import { ToastService } from './toast.service'; @@ -180,6 +180,21 @@ export class PanelsService { ) { 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 this.rpc.onSessionClosed((event) => { for (const workspace of this.workspaces()) { diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts index b787ddf..cdd00cd 100644 --- a/ui/src/app/rpc.service.ts +++ b/ui/src/app/rpc.service.ts @@ -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 void>; + messages[message](payload); + }); + }, + }); + } }