Move settings into a standalone Settings window

The tab-bar popover had outgrown itself. Settings now open in their own
native window (a fourth window kind, built like the connection manager but
not modal): a sidebar with Appearance, Transfers, Editor, File list and
About, holding every popover row plus the visible-columns choice. Each
control is an ordinary settings save, so the other windows follow live
while it stays open.

The gear button in the tab bar, Ctrl+, and the macOS app menu's
"Settings..." (Cmd+,) open it. On macOS Cmd+W arrives as the main window's
close-tab menu action, so bun closes the focused Settings window first
instead of a main-window tab.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:55:15 +02:00
co-authored by Claude
parent f3bec7aeaf
commit 141d675bf7
28 changed files with 743 additions and 405 deletions
+34 -8
View File
@@ -10,6 +10,7 @@ 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";
@@ -52,6 +53,7 @@ const events = {
openEditor: (_ctx: EditorOpenContext) => {},
settingsChanged: (_settings: AppSettings) => {},
openConnectionManager: () => {},
openSettings: () => {},
showAbout: () => {},
};
const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
@@ -108,29 +110,43 @@ const manager = new ManagerWindow({
rebuildMenu: () => void applyApplicationMenu(store),
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): 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.
* 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" | { editor: string }): void {
function settingsSaved(
s: AppSettings,
sender: "main" | "manager" | "settings" | { editor: string },
): void {
engine.setConcurrency(effectiveConcurrency(s));
const localeChanged = s.locale !== currentSettings.locale;
currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) {
setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language
settingsWin.retitle();
}
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;
@@ -149,6 +165,7 @@ events.showAbout = showAbout;
// and the modal manager has nothing to guard
win.on("close", () => {
manager.close();
settingsWin.close();
editors.closeAllForAppExit();
});
@@ -173,6 +190,7 @@ engine.onUpdate = (snapshot) => {
if (
!windowFocused &&
!editors.anyFocused() &&
!settingsWin.isFocused() &&
(snapshot.state === "completed" || snapshot.state === "failed")
) {
Utils.showNotification({
@@ -230,10 +248,14 @@ void applyApplicationMenu(store);
ApplicationMenu.on("application-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action;
if (!action) return;
// ⌘W while an editor window is focused closes that editor (with its dirty
// prompt), not a main-window tab. Other menu actions deliberately still
// target the main window.
// ⌘W while the Settings window or an editor window is focused closes THAT
// window (an editor with its dirty prompt), not a main-window tab. 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);
@@ -244,6 +266,10 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
manager.open();
return;
}
if (action === "settings") {
settingsWin.open();
return;
}
if (action === "about") {
showAbout();
return;
+2
View File
@@ -36,6 +36,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
submenu: [
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" },
{ label: t("menu.settings"), action: "settings", accelerator: "," },
{ type: "separator" },
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
{ type: "separator" },
{ role: "hide" },
+56 -20
View File
@@ -31,6 +31,7 @@ export interface AppDeps {
openEditor: (ctx: EditorOpenContext) => void;
settingsChanged: (settings: AppSettings) => void;
openConnectionManager: () => void;
openSettings: () => void;
showAbout: () => void;
};
}
@@ -130,6 +131,28 @@ const pickKeyFileHandler = () =>
return { path };
});
/** External-editor picker, shared by the main and settings window rpcs. */
const pickEditorAppHandler = () =>
wrap(async () => {
// PORTABILITY: apps live in different places per OS
const { homedir } = await import("node:os");
const [startingFolder, allowedFileTypes] =
process.platform === "darwin"
? ["/Applications", "app"]
: process.platform === "win32"
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
: [homedir(), "*"];
const picked = await Utils.openFileDialog({
startingFolder,
allowedFileTypes,
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
});
export function createAppRPC({
registry,
engine,
@@ -257,26 +280,7 @@ export function createAppRPC({
editExternal: ({ sessionId, path }) =>
wrap(() => edits.editExternal(sessionId, path)),
pickEditorApp: () =>
wrap(async () => {
// PORTABILITY: apps live in different places per OS
const { homedir } = await import("node:os");
const [startingFolder, allowedFileTypes] =
process.platform === "darwin"
? ["/Applications", "app"]
: process.platform === "win32"
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
: [homedir(), "*"];
const picked = await Utils.openFileDialog({
startingFolder,
allowedFileTypes,
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
}),
pickEditorApp: pickEditorAppHandler,
pickKeyFile: pickKeyFileHandler,
@@ -318,6 +322,13 @@ export function createAppRPC({
return {};
}),
// ---- settings window ----
openSettings: () =>
wrap(async () => {
events.openSettings();
return {};
}),
showAbout: () =>
wrap(async () => {
events.showAbout();
@@ -449,3 +460,28 @@ export function createManagerRPC(
},
});
}
export interface SettingsRpcHooks {
/** Every edit here is a settings save — rebroadcast it to the other windows. */
onSettingsSaved: (next: AppSettings) => void;
showAbout: () => void;
}
/** RPC for the single Settings window (per-window instance). */
export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: SettingsRpcHooks) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
requests: {
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
pickEditorApp: pickEditorAppHandler,
showAbout: () =>
wrap(async () => {
hooks.showAbout();
return {};
}),
},
},
});
}
+91
View File
@@ -0,0 +1,91 @@
import type { BrowserWindow } from "electrobun/bun";
import type { AppSettings } from "../shared/types";
import { t } from "./i18n";
import { createSettingsRPC } from "./rpc-handlers";
import type { SettingsStore } from "./store/settings";
import { openSecondaryWindow } from "./windows";
const SETTINGS_WIDTH = 680;
const SETTINGS_HEIGHT = 480;
export interface SettingsWindowDeps {
settings: SettingsStore;
/** The once-resolved dev/prod view url. */
url: string;
/** Live settings snapshot — baked into the window's preload (theme flash). */
currentSettings: () => AppSettings;
mainFrame: () => { x: number; y: number; width: number; height: number };
/** This window saved settings — rebroadcast them to every other window. */
onSettingsSaved: (settings: AppSettings) => void;
showAbout: () => void;
}
/**
* The single Settings window. Unlike the connection manager it is NOT modal:
* every edit is an ordinary settings save, so the other windows follow live
* while it stays open. On macOS ⌘W reaches bun as the main window's close-tab
* menu action, so index.ts asks isFocused() first and closes this window
* instead of a main-window tab.
*/
export class SettingsWindow {
private win: BrowserWindow | null = null;
private rpc: ReturnType<typeof createSettingsRPC> | null = null;
private focused = false;
constructor(private deps: SettingsWindowDeps) {}
isFocused(): boolean {
return this.focused;
}
open(): void {
if (this.win) {
this.win.activate();
return;
}
this.rpc = createSettingsRPC(
{ settings: this.deps.settings },
{
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
showAbout: () => this.deps.showAbout(),
},
);
const main = this.deps.mainFrame();
this.win = openSecondaryWindow({
title: t("options.title"),
url: this.deps.url,
mode: "settings",
settings: this.deps.currentSettings(),
frame: {
x: Math.max(0, main.x + (main.width - SETTINGS_WIDTH) / 2),
y: Math.max(0, main.y + (main.height - SETTINGS_HEIGHT) / 2),
width: SETTINGS_WIDTH,
height: SETTINGS_HEIGHT,
},
rpc: this.rpc,
});
this.win.on("focus", () => (this.focused = true));
this.win.on("blur", () => (this.focused = false));
this.win.on("close", () => {
this.win = null;
this.rpc = null;
this.focused = false;
});
console.log("[settings] window opened");
}
close(): void {
this.win?.close(); // the close event does the cleanup
}
/** The native title follows a language switch (the header row is the UI's own). */
retitle(): void {
this.win?.setTitle(t("options.title"));
}
broadcastSettings(settings: AppSettings): void {
this.rpc?.send.settingsChanged({ settings });
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ import type { AppSettings } from "../shared/types";
* windows flash the wrong background.
*/
export function buildPreload(
mode: "main" | "editor" | "manager",
mode: "main" | "editor" | "manager" | "settings",
settings: AppSettings,
ctx?: unknown,
): string {
@@ -76,7 +76,7 @@ export interface SecondaryWindowOptions {
title: string;
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
url: string;
mode: "editor" | "manager";
mode: "editor" | "manager" | "settings";
/** Current persisted settings — baked into the preload for flash-free paint. */
settings: AppSettings;
ctx?: unknown;