A Keyboard section in the Settings window edits the shared keymap: rows by group with a search field, a chip per key (x removes), + to record, reset per row and Reset all. Recording takes the keydown before every other key in the window (Cmd+W records instead of closing), refuses reserved combos without preventDefault (Cmd+Q still quits), requires a chord or F-key for editor commands, and offers Replace when another command in an overlapping scope owns the key. Rows flag conflicts and plain character keys that would stop starting a type-to-filter query. Fixed keys are listed read-only, in the platform's spelling. Remaps are ordinary settings (only rows that differ from the defaults), so every window, the menu hints and the tooltips follow live — and the macOS menu bar: its accelerators are built from the keymap (only the combos Electrobun's parser expresses faithfully) and rebuilt on a remap. A main-window menu action fired from the editor or Settings window brings the main window forward first. Also: the "Enter / double-click on a file" setting under File list (do nothing / open local files in their default app), arrow keys in the Settings sidebar with focus on open and a hand-walked Tab, translations for all seven languages (the spec now checks every language labels every command), and a README shortcut table generated from the keymap. Co-Authored-By: Claude <noreply@anthropic.com>
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/main";
|
|
import { BRANDING } from "../shared/branding";
|
|
import type { EditorOpenContext } from "../shared/rpc";
|
|
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
|
import { EditManager } from "./edits";
|
|
import { EditorWindows } from "./editor-windows";
|
|
import { SessionRegistry } from "./fs/registry";
|
|
import { LocalDirWatcher } from "./fs/watcher";
|
|
import { ManagerWindow } from "./manager-window";
|
|
import { setLocale as setBackendLocale } from "./i18n";
|
|
import { applyApplicationMenu } from "./menu";
|
|
import { createAppRPC } from "./rpc-handlers";
|
|
import { SettingsWindow } from "./settings-window";
|
|
import { ConnectionStore } from "./store/connections";
|
|
import { migrateLegacyDataDir } from "./store/paths";
|
|
import { SettingsStore } from "./store/settings";
|
|
import { AppTray } from "./tray";
|
|
import { TransferEngine } from "./transfers/engine";
|
|
import { buildPreload, chromeOptions, showWhenReady } from "./windows";
|
|
|
|
const PROD_URL = "views://app/index.html";
|
|
|
|
// In dev, scripts/dev.ts sets DEV_SERVER_URL to the running ng serve instance
|
|
// so the UI gets HMR. Packaged builds always load the bundled Angular app.
|
|
async function getMainViewUrl(): Promise<string> {
|
|
const devServerUrl = process.env.DEV_SERVER_URL;
|
|
if (!devServerUrl) return PROD_URL;
|
|
|
|
const channel = await Updater.localInfo.channel();
|
|
if (channel !== "dev") return PROD_URL;
|
|
|
|
try {
|
|
await fetch(devServerUrl, { method: "HEAD" });
|
|
return devServerUrl;
|
|
} catch {
|
|
console.log(`Dev server ${devServerUrl} not reachable, using ${PROD_URL}`);
|
|
return PROD_URL;
|
|
}
|
|
}
|
|
|
|
// after a rebrand, adopt the previous name's data dir before stores read it
|
|
migrateLegacyDataDir();
|
|
|
|
const registry = new SessionRegistry();
|
|
const engine = new TransferEngine(registry);
|
|
const store = new ConnectionStore();
|
|
const settings = new SettingsStore();
|
|
const edits = new EditManager(registry, engine, settings);
|
|
const watcher = new LocalDirWatcher();
|
|
const events = {
|
|
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
|
connectionsChanged: () => {},
|
|
openEditor: (_ctx: EditorOpenContext) => {},
|
|
settingsChanged: (_settings: AppSettings) => {},
|
|
openConnectionManager: () => {},
|
|
openSettings: () => {},
|
|
showAbout: () => {},
|
|
applyUpdate: async () => {},
|
|
};
|
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
|
|
// late-bound like events: a watched local dir changed -> tell the main webview
|
|
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
|
const url = await getMainViewUrl();
|
|
|
|
// Loaded before any window exists: the persisted theme is baked into every
|
|
// window's preload so the first paint is already themed (no flash). Kept
|
|
// fresh via the saveSettings flow below.
|
|
let currentSettings = await settings.load();
|
|
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
|
|
|
const win = new BrowserWindow({
|
|
title: BRANDING.appName,
|
|
url,
|
|
frame: {
|
|
width: 1200,
|
|
height: 760,
|
|
x: 120,
|
|
y: 120,
|
|
},
|
|
// frame decision lives in chromeOptions (windows.ts); constructor-only
|
|
...chromeOptions(),
|
|
// lets the UI pick the fused layout + theme before first paint
|
|
preload: buildPreload("main", currentSettings),
|
|
rpc,
|
|
// revealed on first dom-ready — avoids the unthemed native flash
|
|
hidden: true,
|
|
});
|
|
showWhenReady(win);
|
|
|
|
const editors = new EditorWindows({
|
|
registry,
|
|
settings,
|
|
edits,
|
|
url,
|
|
currentSettings: () => currentSettings,
|
|
mainFrame: () => win.getFrame(),
|
|
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
|
|
onSettingsSaved: (s, key) => settingsSaved(s, { editor: key }),
|
|
});
|
|
const manager = new ManagerWindow({
|
|
store,
|
|
settings,
|
|
url,
|
|
currentSettings: () => currentSettings,
|
|
mainFrame: () => win.getFrame(),
|
|
notifyMain: {
|
|
managerState: (info) => rpc.send.managerState(info),
|
|
connectionsChanged: () => rpc.send.connectionsChanged({}),
|
|
menuAction: (info) => rpc.send.menuAction(info),
|
|
},
|
|
rebuildMenu: () => void applyApplicationMenu(store, () => currentSettings.keybindings),
|
|
onSettingsSaved: (s) => settingsSaved(s, "manager"),
|
|
});
|
|
const settingsWin = new SettingsWindow({
|
|
settings,
|
|
url,
|
|
currentSettings: () => currentSettings,
|
|
mainFrame: () => win.getFrame(),
|
|
onSettingsSaved: (s) => settingsSaved(s, "settings"),
|
|
showAbout: () => showAbout(),
|
|
});
|
|
|
|
events.openEditor = (ctx) => editors.open(ctx);
|
|
/**
|
|
* Every settings save lands here, whichever window made it (editor and manager
|
|
* windows save too — the scale shortcuts — and the Settings window is nothing
|
|
* but saves): side effects run once, then every OTHER window follows. The
|
|
* sender is skipped — it already shows the new values, and an echo arriving
|
|
* behind a newer local change (a held Cmd+) would drag it backwards.
|
|
*/
|
|
function settingsSaved(
|
|
s: AppSettings,
|
|
sender: "main" | "manager" | "settings" | { editor: string },
|
|
): void {
|
|
engine.setConcurrency(effectiveConcurrency(s));
|
|
const localeChanged = s.locale !== currentSettings.locale;
|
|
const keysChanged = JSON.stringify(s.keybindings) !== JSON.stringify(currentSettings.keybindings);
|
|
currentSettings = s; // future windows' preloads bake the fresh theme
|
|
if (localeChanged) {
|
|
setBackendLocale(s.locale);
|
|
settingsWin.retitle();
|
|
}
|
|
// relabel the native menu in the new language / give it the remapped keys
|
|
if (localeChanged || keysChanged) void applyApplicationMenu(store, () => currentSettings.keybindings);
|
|
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
|
|
if (sender !== "manager") manager.broadcastSettings(s);
|
|
if (sender !== "settings") settingsWin.broadcastSettings(s);
|
|
editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined);
|
|
}
|
|
events.settingsChanged = (s) => settingsSaved(s, "main");
|
|
events.openConnectionManager = () => manager.open();
|
|
events.openSettings = () => settingsWin.open();
|
|
|
|
function showAbout(): void {
|
|
const { build } = BRANDING;
|
|
void Utils.showMessageBox({
|
|
type: "info",
|
|
title: BRANDING.appName,
|
|
message: BRANDING.appName,
|
|
detail: `${BRANDING.buildLabel} (${build.commit}) · ${build.date}\n\nElectrobun ${build.electrobun} · Bun ${Bun.version}`,
|
|
buttons: ["OK"],
|
|
defaultId: 0,
|
|
cancelId: 0,
|
|
});
|
|
}
|
|
events.showAbout = showAbout;
|
|
// clean editors close with the main window; dirty ones get to ask first,
|
|
// and the modal manager has nothing to guard
|
|
win.on("close", () => {
|
|
manager.close();
|
|
settingsWin.close();
|
|
editors.closeAllForAppExit();
|
|
});
|
|
|
|
new AppTray(win);
|
|
|
|
// notify on finished transfers while the window is in the background
|
|
let windowFocused = true;
|
|
win.on("focus", () => {
|
|
windowFocused = true;
|
|
// modal bounce: the main window can't take focus while the manager is open
|
|
if (manager.isOpen()) manager.activate();
|
|
});
|
|
win.on("blur", () => (windowFocused = false));
|
|
const notifiedStates = new Map<string, string>();
|
|
|
|
engine.onUpdate = (snapshot) => {
|
|
rpc.send.transferUpdate(snapshot);
|
|
|
|
const previous = notifiedStates.get(snapshot.id);
|
|
if (previous !== snapshot.state) {
|
|
notifiedStates.set(snapshot.id, snapshot.state);
|
|
if (
|
|
!windowFocused &&
|
|
!editors.anyFocused() &&
|
|
!settingsWin.isFocused() &&
|
|
(snapshot.state === "completed" || snapshot.state === "failed")
|
|
) {
|
|
Utils.showNotification({
|
|
title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed",
|
|
body:
|
|
snapshot.state === "completed"
|
|
? snapshot.name
|
|
: `${snapshot.name}: ${snapshot.error ?? "unknown error"}`,
|
|
silent: snapshot.state === "completed",
|
|
});
|
|
}
|
|
if (snapshot.state === "completed" || snapshot.state === "failed" || snapshot.state === "cancelled") {
|
|
// keep the map from growing over long sessions
|
|
setTimeout(() => notifiedStates.delete(snapshot.id), 60_000);
|
|
}
|
|
}
|
|
};
|
|
|
|
// don't quit out from under running transfers or unsaved editors
|
|
let quitConfirmed = false;
|
|
// Updater.applyUpdate() asks this guard first (2.x), so remember the quit is
|
|
// update-initiated: "Quit Anyway" must re-run the apply, or a plain quit would
|
|
// drop the downloaded update.
|
|
let applyingUpdate = false;
|
|
events.applyUpdate = async () => {
|
|
applyingUpdate = true;
|
|
try {
|
|
await Updater.applyUpdate();
|
|
} finally {
|
|
applyingUpdate = false;
|
|
}
|
|
};
|
|
Electrobun.events.on("before-quit", (event) => {
|
|
const transfers = engine.activeJobCount();
|
|
const dirtyEditors = editors.dirtyCount();
|
|
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
|
|
(event as { response: { allow: boolean } }).response = { allow: false };
|
|
const updating = applyingUpdate; // read now: it clears once the vetoed apply returns
|
|
void (async () => {
|
|
const parts = [
|
|
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
|
|
dirtyEditors > 0 &&
|
|
`${dirtyEditors} file${dirtyEditors === 1 ? " has" : "s have"} unsaved changes`,
|
|
].filter(Boolean);
|
|
const { response } = await Utils.showMessageBox({
|
|
type: "warning",
|
|
title: BRANDING.appName,
|
|
message: `${parts.join(" and ")}.`,
|
|
detail: "Quitting now will abort transfers and discard unsaved changes.",
|
|
buttons: ["Quit Anyway", "Cancel"],
|
|
defaultId: 1,
|
|
cancelId: 1,
|
|
});
|
|
if (response === 0) {
|
|
quitConfirmed = true;
|
|
// the trailing quit is a no-op once the apply is quitting, and still
|
|
// quits if it bails (no prepared download)
|
|
if (updating) void Updater.applyUpdate().finally(() => Utils.quit());
|
|
else Utils.quit();
|
|
}
|
|
})();
|
|
});
|
|
events.sessionClosed = (info) => {
|
|
rpc.send.sessionClosed(info);
|
|
editors.notifySessionClosed(info);
|
|
};
|
|
events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
|
|
engine.setConcurrency(effectiveConcurrency(currentSettings));
|
|
|
|
void applyApplicationMenu(store, () => currentSettings.keybindings);
|
|
ApplicationMenu.on("application-menu-clicked", (event) => {
|
|
const action = (event as { data?: { action?: string } }).data?.action;
|
|
if (!action) return;
|
|
// Their pages claim close-window (⌘W) themselves — macOS hands key
|
|
// equivalents to the page first — so a close-tab that still arrives while
|
|
// the Settings window or an editor is focused is a ⌘W the page let through
|
|
// (or the menu item clicked): close THAT window (an editor with its dirty
|
|
// prompt), never a main-window tab behind it. Other menu actions
|
|
// deliberately still target the main window.
|
|
if (action === "close-tab") {
|
|
if (settingsWin.isFocused()) {
|
|
settingsWin.close();
|
|
return;
|
|
}
|
|
const focusedEditor = editors.focusedKey();
|
|
if (focusedEditor) {
|
|
editors.requestClose(focusedEditor);
|
|
return;
|
|
}
|
|
}
|
|
if (action === "manage-connections") {
|
|
manager.open();
|
|
return;
|
|
}
|
|
if (action === "settings") {
|
|
settingsWin.open();
|
|
return;
|
|
}
|
|
if (action === "about") {
|
|
showAbout();
|
|
return;
|
|
}
|
|
if (action === "quit") {
|
|
// Utils.quit() emits before-quit -> the guard runs (transfers/unsaved).
|
|
// Handled before the manager check so ⌘Q quits even with it open.
|
|
Utils.quit();
|
|
return;
|
|
}
|
|
// the manager is modal to the main window — don't drive main behind it
|
|
if (manager.isOpen()) return;
|
|
// everything below acts on the main window: from a focused editor or the
|
|
// Settings window (⌘T, ⌘N…), bring it forward so the effect is visible
|
|
if (settingsWin.isFocused() || editors.focusedKey()) win.activate();
|
|
rpc.send.menuAction({ action });
|
|
});
|
|
|
|
// ---- auto-updater: check on launch + every 6h, stream status to the tab bar ----
|
|
// Updater.checkForUpdate() self-guards the dev channel (returns updateAvailable:
|
|
// false), so the pill never appears during `bun run dev`.
|
|
Updater.onStatusChange((entry) => {
|
|
if (entry.status === "download-progress") {
|
|
rpc.send.updateStatus({ phase: "downloading", progress: entry.details?.progress ?? 0 });
|
|
} else if (entry.status === "error") {
|
|
rpc.send.updateStatus({ phase: "error", error: entry.message });
|
|
}
|
|
});
|
|
|
|
async function runUpdateCheck(): Promise<void> {
|
|
try {
|
|
const r = await Updater.checkForUpdate();
|
|
if (r.updateAvailable) rpc.send.updateStatus({ phase: "available", version: r.version });
|
|
} catch {
|
|
// offline / feed unreachable — stay quiet and retry on the next tick
|
|
}
|
|
}
|
|
setTimeout(() => void runUpdateCheck(), 5_000); // let the window settle first
|
|
setInterval(() => void runUpdateCheck(), 6 * 60 * 60 * 1000);
|
|
|
|
console.log(`${BRANDING.appName} started (${url})`);
|