@happy-nut/monacori 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app-main.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { spawnSync, spawn } from "node:child_process";
3
- import { dirname, join, resolve } from "node:path";
2
+ import { spawn } from "node:child_process";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { app, BrowserWindow, ipcMain, Menu, nativeImage } from "electron";
5
+ import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification } from "electron";
6
6
  import { buildDiffReview, performHttpRequest } from "./cli.js";
7
- import { sanitizeTerminalEnv } from "./util.js";
7
+ import { sanitizeTerminalEnv, ensureUtf8Locale } from "./util.js";
8
8
  import { readUnifiedDiff } from "./diff.js";
9
+ import { isGitRepository } from "./git.js";
10
+ import { renderWelcomeHtml } from "./render.js";
9
11
  import { createHash } from "node:crypto";
10
12
  import { spawn as spawnPty } from "node-pty";
11
13
  // `npm run dev` sets MONACORI_DEV=1 so a locally-built app announces itself — a window-title suffix
@@ -44,98 +46,216 @@ function isLightTheme() {
44
46
  }
45
47
  }
46
48
  app.setName("monacori");
49
+ // Best-effort re-brand at startup. macOS shows the Dock / Cmd+Tab / menu-bar name from Electron.app's
50
+ // CFBundleName + executable name, which app.setName() CANNOT change — only scripts/patch-electron-name.mjs
51
+ // (run at postinstall) renames them. That postinstall step can be skipped (npm --ignore-scripts) or fail on
52
+ // perms, leaving "Electron" everywhere. Re-run the patch here in a Node context (ELECTRON_RUN_AS_NODE) so a
53
+ // fresh install self-heals; it's idempotent and takes effect on the NEXT launch.
54
+ if (process.platform === "darwin") {
55
+ try {
56
+ const patchScript = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "patch-electron-name.mjs");
57
+ if (existsSync(patchScript)) {
58
+ spawn(process.execPath, [patchScript], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, stdio: "ignore", detached: true }).unref();
59
+ }
60
+ }
61
+ catch { /* best-effort — postinstall remains the primary path */ }
62
+ }
63
+ const iconPath = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "icon.png");
64
+ const preloadPath = join(dirname(fileURLToPath(import.meta.url)), "preload.cjs");
65
+ const options = parseArgs(process.argv.slice(2));
66
+ const states = new Map();
67
+ let nextPtyId = 0; // global so pty ids never collide across windows; each window holds only its own in WinState.terms
68
+ if (!existsSync(options.root)) {
69
+ throw new Error(`Repository path does not exist: ${options.root}`);
70
+ }
71
+ // Resolve the WinState for an IPC call by mapping its sender back to a window — this is how get-file /
72
+ // get-source-data / pty-* are routed to the right window's state instead of a shared global.
73
+ function stateFromEvent(event) {
74
+ const win = BrowserWindow.fromWebContents(event.sender);
75
+ return win ? states.get(win.id) : undefined;
76
+ }
77
+ function focusedState() {
78
+ const win = BrowserWindow.getFocusedWindow();
79
+ return win ? states.get(win.id) : undefined;
80
+ }
81
+ // Menu accelerators are application-global, so they act on whichever window is focused.
82
+ function sendToFocused(channel, payload) {
83
+ const win = BrowserWindow.getFocusedWindow();
84
+ if (win && !win.isDestroyed())
85
+ win.webContents.send(channel, payload);
86
+ }
47
87
  ipcMain.handle("monacori:http-send", (_event, request) => performHttpRequest(request));
48
- // Phase 2 lazy-LOAD: serve a single file's diff body to the renderer on demand. Retained from the
49
- // most recent writeReviewFile() build so navigation/scroll can materialize bodies without embedding.
50
- let currentBodies = [];
51
- let currentSourceData = "[]";
52
- ipcMain.handle("monacori:get-file", (_event, request) => {
88
+ // Phase 2 lazy-LOAD: serve a single file's diff body to the calling window's renderer on demand. Retained
89
+ // from that window's most recent writeReviewFile() build so navigation/scroll can materialize bodies.
90
+ ipcMain.handle("monacori:get-file", (event, request) => {
91
+ const state = stateFromEvent(event);
92
+ if (!state)
93
+ return "";
53
94
  const i = Number(request?.index);
54
- return Number.isInteger(i) && i >= 0 && i < currentBodies.length ? currentBodies[i] : "";
95
+ return Number.isInteger(i) && i >= 0 && i < state.bodies.length ? state.bodies[i] : "";
96
+ });
97
+ // Phase 2b lazy-LOAD: serve the full source files JSON (with content) for the calling window on demand.
98
+ ipcMain.handle("monacori:get-source-data", (event) => stateFromEvent(event)?.sourceData ?? "[]");
99
+ // Welcome screen's "Open Folder" button: pick a directory; load it into the window that asked if it's a
100
+ // git repo, else return the "not-git" code so the welcome renderer can show its inline hint (it keys off
101
+ // r.error === "not-git"). This flow reports errors in-page, so — unlike the File menu — no native box.
102
+ ipcMain.handle("monacori:open-folder", async (event) => {
103
+ const state = stateFromEvent(event);
104
+ if (!state || state.win.isDestroyed())
105
+ return { ok: false };
106
+ const root = await pickDirectory(state.win, state.options.root);
107
+ if (!root)
108
+ return { ok: false };
109
+ if (!isGitRepository(root))
110
+ return { ok: false, error: "not-git" };
111
+ await openReview(state, root);
112
+ return { ok: true };
113
+ });
114
+ // Welcome screen's Recent Projects list: open the clicked path into the calling window. If it's gone or no
115
+ // longer a git repo, drop it from the list and tell the renderer (error: "missing") to remove that row.
116
+ ipcMain.handle("monacori:open-recent", async (event, payload) => {
117
+ const state = stateFromEvent(event);
118
+ const path = typeof payload?.path === "string" ? payload.path : "";
119
+ if (!state || state.win.isDestroyed() || !path)
120
+ return { ok: false };
121
+ if (!existsSync(path) || !isGitRepository(path)) {
122
+ forgetRecentProject(path);
123
+ return { ok: false, error: "missing" };
124
+ }
125
+ await openReview(state, path);
126
+ return { ok: true };
55
127
  });
56
- // Phase 2b lazy-LOAD: serve the full source files JSON (with content) on demand.
57
- ipcMain.handle("monacori:get-source-data", () => currentSourceData);
58
128
  // Self-update: install the latest published package globally, then relaunch so the updated code loads.
59
129
  // Runs in the main process because the sandboxed renderer can't spawn npm. Returns {ok:true} (and
60
130
  // relaunches shortly after) or {ok:false,error} so the renderer can fall back to the manual command.
61
- ipcMain.handle("monacori:self-update", () => {
62
- const result = spawnSync("npm", ["install", "-g", "@happy-nut/monacori@latest"], {
63
- encoding: "utf8",
64
- shell: true,
65
- env: process.env,
66
- timeout: 5 * 60 * 1000,
67
- });
68
- if ((result.status ?? 1) === 0) {
69
- // Let the renderer paint "Restarting…", then start the freshly-installed CLI as a NEW detached
70
- // process and exit. app.relaunch() re-runs THIS process, but the global install just replaced our
71
- // on-disk dist (and possibly the bundled Electron), so the current process is stale — relaunching
72
- // it can boot the old code or fail outright. Spawning `mo` loads the new code; a sanitized env
73
- // keeps this update run's npm_* vars out of the fresh process. Falls back to relaunch if spawn fails.
131
+ ipcMain.handle("monacori:self-update", (event) => new Promise((resolve) => {
132
+ // Relaunch the freshly-installed `mo` in the calling window's repo so the user lands back where they were.
133
+ const cwd = stateFromEvent(event)?.options.root ?? options.root;
134
+ // Async, NOT spawnSync: spawnSync froze the ENTIRE main process for the whole npm install (up to
135
+ // minutes), so the app looked hung and "nothing happened" — even the renderer's "Updating…" couldn't
136
+ // paint and the user saw no restart. Stream it so the UI stays responsive; resolve on close.
137
+ let out = "";
138
+ let child;
139
+ try {
140
+ child = spawn("npm", ["install", "-g", "@happy-nut/monacori@latest"], { shell: true, env: process.env });
141
+ }
142
+ catch (error) {
143
+ resolve({ ok: false, error: error instanceof Error ? error.message : String(error) });
144
+ return;
145
+ }
146
+ child.stdout?.on("data", (d) => { out += String(d); });
147
+ child.stderr?.on("data", (d) => { out += String(d); });
148
+ child.on("error", (error) => resolve({ ok: false, error: (error instanceof Error ? error.message : String(error)).slice(-600) }));
149
+ child.on("close", (code) => {
150
+ if (code !== 0) {
151
+ resolve({ ok: false, error: (out || "npm install failed").trim().slice(-600) });
152
+ return;
153
+ }
154
+ resolve({ ok: true });
155
+ // The global install replaced our on-disk dist, so THIS process is stale. Start the freshly-installed
156
+ // CLI as a NEW detached process, then exit. If `mo` isn't on the (GUI) app's PATH it errors or exits
157
+ // non-zero — fall back to app.relaunch() so the user is never left without a restart (the bug: a failed
158
+ // `mo` spawn under detached/unref went unnoticed and the app just exited without relaunching).
74
159
  setTimeout(() => {
160
+ let done = false;
161
+ const relaunch = () => { if (done)
162
+ return; done = true; try {
163
+ app.relaunch();
164
+ }
165
+ catch { /* nothing else to try */ } app.exit(0); };
75
166
  try {
76
- const child = spawn("mo", [], {
77
- cwd: options.root,
78
- detached: true,
79
- stdio: "ignore",
80
- env: sanitizeTerminalEnv(process.env),
81
- shell: true,
82
- });
83
- child.unref();
167
+ const c = spawn("mo", [], { cwd, detached: true, stdio: "ignore", env: sanitizeTerminalEnv(process.env), shell: true });
168
+ c.on("error", relaunch);
169
+ c.on("exit", (exitCode) => { if (exitCode && exitCode !== 0)
170
+ relaunch(); });
171
+ c.unref();
172
+ setTimeout(() => { if (!done) {
173
+ done = true;
174
+ app.exit(0);
175
+ } }, 800); // `mo` launched fine -> hand off and exit
84
176
  }
85
177
  catch {
86
- app.relaunch();
178
+ relaunch();
87
179
  }
88
- app.exit(0);
89
- }, 400);
90
- return { ok: true };
91
- }
92
- const detail = (result.stderr || result.stdout || (result.error && result.error.message) || "npm install failed").trim();
93
- return { ok: false, error: detail.slice(-600) };
94
- });
180
+ }, 600);
181
+ });
182
+ }));
95
183
  // Integrated terminal: own node-pty sessions in the main process (the sandboxed renderer can't spawn
96
- // them) and relay bytes to the renderer's xterm panes. Each split pane gets its own pty, keyed by id, so
97
- // the renderer can route data/resize/kill per pane.
98
- const terms = new Map();
99
- let nextPtyId = 0;
100
- ipcMain.handle("monacori:pty-spawn", (_event, size) => {
184
+ // them) and relay bytes to the renderer's xterm panes. Each pty is owned by the window that spawned it
185
+ // (WinState.terms), so closing one window kills only its terminals and pty data routes back to it alone.
186
+ ipcMain.handle("monacori:pty-spawn", (event, size) => {
187
+ const state = stateFromEvent(event);
188
+ if (!state)
189
+ return { ok: false, id: -1 };
101
190
  const id = ++nextPtyId;
102
191
  const shell = process.env.SHELL || (process.platform === "win32" ? "powershell.exe" : "/bin/zsh");
103
192
  const t = spawnPty(shell, [], {
104
- name: "xterm-color",
193
+ // 256-color terminfo + COLORTERM=truecolor so TUIs (e.g. Claude Code's coral logo) emit 24-bit color and
194
+ // xterm.js renders the exact hue. "xterm-color" is 8-color, which downgraded the orange logo to ANSI red.
195
+ name: "xterm-256color",
105
196
  cols: size?.cols ?? 80,
106
197
  rows: size?.rows ?? 24,
107
- cwd: options.root,
108
- env: sanitizeTerminalEnv(process.env),
198
+ cwd: state.options.root,
199
+ env: ensureUtf8Locale({ ...sanitizeTerminalEnv(process.env), TERM: "xterm-256color", COLORTERM: "truecolor" }),
109
200
  });
110
- terms.set(id, t);
111
- // mainWindow?. only guards null, NOT a *destroyed* window sending to a closed window's webContents
112
- // throws "Object has been destroyed". The pty can outlive the window (close races pty teardown), so
113
- // guard every relay with isDestroyed().
201
+ state.terms.set(id, t);
202
+ // Guard every relay with isDestroyed(): a pty can outlive its window (close races pty teardown), and
203
+ // sending to a closed window's webContents throws "Object has been destroyed".
114
204
  const deliver = (channel, payload) => {
115
- if (mainWindow && !mainWindow.isDestroyed())
116
- mainWindow.webContents.send(channel, payload);
205
+ if (!state.win.isDestroyed())
206
+ state.win.webContents.send(channel, payload);
117
207
  };
118
208
  // Relay pty output to the renderer immediately, one IPC per chunk. (A coalescing buffer was tried as an
119
209
  // optimization but it broke terminal I/O — the shell prompt and echo stopped appearing — so it's removed.)
120
210
  t.onData((data) => deliver("monacori:pty-data", { id, data }));
121
- t.onExit(() => { terms.delete(id); deliver("monacori:pty-exit", { id }); });
211
+ t.onExit(() => { state.terms.delete(id); deliver("monacori:pty-exit", { id }); });
122
212
  return { ok: true, id };
123
213
  });
124
- ipcMain.on("monacori:pty-write", (_event, msg) => { terms.get(msg?.id)?.write(msg.data); });
125
- ipcMain.on("monacori:pty-resize", (_event, msg) => {
214
+ ipcMain.on("monacori:pty-write", (event, msg) => { stateFromEvent(event)?.terms.get(msg?.id)?.write(msg.data); });
215
+ ipcMain.on("monacori:pty-resize", (event, msg) => {
126
216
  try {
127
- terms.get(msg?.id)?.resize(msg.cols, msg.rows);
217
+ stateFromEvent(event)?.terms.get(msg?.id)?.resize(msg.cols, msg.rows);
128
218
  }
129
219
  catch { /* resize can race the pty teardown — ignore */ }
130
220
  });
131
- ipcMain.on("monacori:pty-kill", (_event, msg) => {
132
- const t = terms.get(msg?.id);
221
+ ipcMain.on("monacori:pty-kill", (event, msg) => {
222
+ const state = stateFromEvent(event);
223
+ const t = state?.terms.get(msg?.id);
133
224
  if (t) {
134
225
  try {
135
226
  t.kill();
136
227
  }
137
228
  catch { /* already exited */ }
138
- terms.delete(msg.id);
229
+ state.terms.delete(msg.id);
230
+ }
231
+ });
232
+ // A TUI in the integrated terminal rang the bell (e.g. Claude Code finished a turn / needs input). Raise a
233
+ // native notification when the window ISN'T focused — while you're watching, the bell itself is enough — plus
234
+ // a dock bounce / taskbar flash. Clicking the notification brings the window forward.
235
+ ipcMain.on("monacori:bell", (event, msg) => {
236
+ const win = BrowserWindow.fromWebContents(event.sender);
237
+ if (!win || win.isDestroyed() || win.isFocused())
238
+ return;
239
+ try {
240
+ if (Notification.isSupported()) {
241
+ const note = new Notification({ title: msg?.title || "monacori", body: msg?.body || "Terminal task finished" });
242
+ note.on("click", () => { if (!win.isDestroyed()) {
243
+ win.show();
244
+ win.focus();
245
+ } });
246
+ note.show();
247
+ }
248
+ }
249
+ catch { /* notifications are best-effort */ }
250
+ try {
251
+ win.flashFrame(true);
252
+ }
253
+ catch { /* taskbar flash — Windows/Linux */ }
254
+ if (process.platform === "darwin" && app.dock) {
255
+ try {
256
+ app.dock.bounce("informational");
257
+ }
258
+ catch { /* best-effort */ }
139
259
  }
140
260
  });
141
261
  // Persisted global settings (locale, …) live in a JSON file under userData and reach the renderer
@@ -170,50 +290,105 @@ ipcMain.on("monacori:set-setting", (_event, msg) => {
170
290
  settings[msg.key] = msg.value;
171
291
  writeSettings(settings);
172
292
  });
173
- const iconPath = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "icon.png");
174
- const preloadPath = join(dirname(fileURLToPath(import.meta.url)), "preload.cjs");
175
- const options = parseArgs(process.argv.slice(2));
176
- let mainWindow;
177
- let currentSignature = "";
178
- let refreshTimer;
179
- let refreshing = false;
180
- if (!existsSync(options.root)) {
181
- throw new Error(`Repository path does not exist: ${options.root}`);
293
+ const RECENT_KEY = "monacori-recent-projects";
294
+ const RECENT_MAX = 12;
295
+ function readRecentProjects() {
296
+ const raw = readSettings()[RECENT_KEY];
297
+ if (!Array.isArray(raw))
298
+ return [];
299
+ return raw.filter((x) => !!x && typeof x === "object" && typeof x.path === "string");
300
+ }
301
+ function recordRecentProject(root) {
302
+ const path = resolve(root);
303
+ if (!isGitRepository(path))
304
+ return; // only remember real repos (bootWindow can build a non-git root)
305
+ const others = readRecentProjects().filter((p) => p.path !== path);
306
+ const next = [{ path, name: basename(path) || path, openedAt: Date.now() }, ...others].slice(0, RECENT_MAX);
307
+ const settings = readSettings();
308
+ settings[RECENT_KEY] = next;
309
+ writeSettings(settings);
310
+ }
311
+ function forgetRecentProject(root) {
312
+ const path = resolve(root);
313
+ const settings = readSettings();
314
+ settings[RECENT_KEY] = readRecentProjects().filter((p) => p.path !== path);
315
+ writeSettings(settings);
182
316
  }
183
317
  app.whenReady().then(async () => {
184
318
  // Foreground (`npm run dev` / `mo --foreground`) surfaces this in the terminal; detached `mo` drops
185
319
  // it. Either way the path disambiguates a local checkout from the installed package.
186
320
  console.error(`[monacori] ${DEV_BUILD ? "DEV build" : "build"} — ${app.getAppPath()} (electron ${process.versions.electron})`);
187
- process.chdir(options.root);
188
- mkdirSync(FLOW_DIR, { recursive: true });
189
- // Keep the standard Edit/Window roles so Cmd+C/V/X/A (copy comments into prompts) and Cmd+Q work.
190
- // The in-window menu bar stays hidden on Windows/Linux via autoHideMenuBar; macOS shows it in the top bar.
191
- const sendMerged = (kind) => mainWindow?.webContents.send("monacori:merged-view", kind);
321
+ buildApplicationMenu();
322
+ const appIcon = nativeImage.createFromPath(iconPath);
323
+ if (process.platform === "darwin" && app.dock && !appIcon.isEmpty()) {
324
+ app.dock.setIcon(appIcon);
325
+ }
326
+ // First window uses the CLI-resolved root + flags. No chdir/mkdir here — each window scopes its own
327
+ // repo via options.root, and writeReviewFile() creates that root's .monacori dir on demand.
328
+ createWindow(options.root);
329
+ }).catch((error) => {
330
+ console.error(error instanceof Error ? error.message : String(error));
331
+ app.quit();
332
+ });
333
+ // Each window's "closed" handler already killed its ptys, cleared its timer, and deleted its state, so
334
+ // there's nothing global left to tear down — just quit once the last window is gone.
335
+ app.on("window-all-closed", () => {
336
+ app.quit();
337
+ });
338
+ // Keep the Ignore-whitespace menu checkbox honest as focus moves between windows (it's per-window state).
339
+ app.on("browser-window-focus", (_event, win) => {
340
+ try {
341
+ win.flashFrame(false);
342
+ }
343
+ catch { /* stop any terminal-bell taskbar flash once the user is back */ }
344
+ const state = states.get(win.id);
345
+ const item = Menu.getApplicationMenu()?.getMenuItemById("ignore-whitespace");
346
+ if (item && state)
347
+ item.checked = state.options.ignoreWhitespace;
348
+ });
349
+ // Build the application menu once. Items act on the focused window (BrowserWindow.getFocusedWindow()),
350
+ // so a single global menu drives whichever window is in front.
351
+ function buildApplicationMenu() {
192
352
  const menuTemplate = [];
193
353
  if (process.platform === "darwin")
194
354
  menuTemplate.push({ role: "appMenu" });
355
+ // File menu: open a repo in the current window, or spawn a new window for it.
356
+ menuTemplate.push({
357
+ label: "File",
358
+ submenu: [
359
+ { label: "Open Folder…", accelerator: "CommandOrControl+O", click: () => void openFolderInCurrent() },
360
+ { label: "Open in New Window…", accelerator: "CommandOrControl+Shift+O", click: () => void openFolderInNewWindow() },
361
+ ],
362
+ });
363
+ // Keep the standard Edit/Window roles so Cmd+C/V/X/A (copy comments into prompts) and Cmd+Q work.
364
+ // The in-window menu bar stays hidden on Windows/Linux via autoHideMenuBar; macOS shows it in the top bar.
195
365
  menuTemplate.push({ role: "editMenu" });
196
366
  // Ctrl+Cmd+Shift+/ ("?") and Ctrl+Cmd+Shift+. (">") open the merged question / change-request views.
197
367
  // ? and > are Shift+/ and Shift+. so Shift is part of the combo; Ctrl+Cmd avoids macOS's Cmd+? Help grab.
198
368
  menuTemplate.push({
199
369
  label: "Review",
200
370
  submenu: [
201
- { label: "All questions", accelerator: "Control+Command+Shift+/", click: () => sendMerged("q") },
202
- { label: "All change requests", accelerator: "Control+Command+Shift+.", click: () => sendMerged("c") },
371
+ { label: "All questions", accelerator: "Control+Command+Shift+/", click: () => sendToFocused("monacori:merged-view", "q") },
372
+ { label: "All change requests", accelerator: "Control+Command+Shift+.", click: () => sendToFocused("monacori:merged-view", "c") },
203
373
  // Cmd/Ctrl+Shift+N opens (and toggles) the single freeform prompt memo — a Markdown scratchpad.
204
- { label: "Prompt memo", accelerator: "CommandOrControl+Shift+N", click: () => mainWindow?.webContents.send("monacori:open-memo") },
374
+ { label: "Prompt memo", accelerator: "CommandOrControl+Shift+N", click: () => sendToFocused("monacori:open-memo") },
205
375
  { type: "separator" },
206
376
  // Whitespace-ignore re-runs git diff with --ignore-all-space and reloads (main-process action,
207
- // so a menu checkbox is simpler than a renderer IPC round-trip).
377
+ // so a menu checkbox is simpler than a renderer IPC round-trip). Per-window: applies to the focused
378
+ // window only, and browser-window-focus syncs this checkbox to the focused window's state.
208
379
  {
380
+ id: "ignore-whitespace",
209
381
  label: "Ignore whitespace",
210
382
  type: "checkbox",
211
383
  checked: options.ignoreWhitespace,
212
384
  accelerator: "CommandOrControl+Shift+W",
213
385
  click: (item) => {
214
- options.ignoreWhitespace = item.checked;
215
- currentSignature = writeReviewFile(options).signature;
216
- mainWindow?.webContents.reloadIgnoringCache();
386
+ const state = focusedState();
387
+ if (!state)
388
+ return;
389
+ state.options.ignoreWhitespace = item.checked;
390
+ state.signature = writeReviewFile(state).signature;
391
+ state.win.webContents.reloadIgnoringCache();
217
392
  },
218
393
  },
219
394
  ],
@@ -226,8 +401,8 @@ app.whenReady().then(async () => {
226
401
  { role: "minimize" },
227
402
  { role: "zoom" },
228
403
  { type: "separator" },
229
- { label: "Close Tab", accelerator: "CommandOrControl+W", click: () => mainWindow?.webContents.send("monacori:close-tab") },
230
- { label: "Close Window", click: () => mainWindow?.close() },
404
+ { label: "Close Tab", accelerator: "CommandOrControl+W", click: () => sendToFocused("monacori:close-tab") },
405
+ { label: "Close Window", click: () => BrowserWindow.getFocusedWindow()?.close() },
231
406
  ],
232
407
  });
233
408
  // Terminal toggle/split as menu accelerators: Chromium swallows Cmd+D before it reaches the renderer
@@ -235,22 +410,22 @@ app.whenReady().then(async () => {
235
410
  menuTemplate.push({
236
411
  label: "Terminal",
237
412
  submenu: [
238
- { label: "Toggle Terminal", accelerator: "Control+`", click: () => mainWindow?.webContents.send("monacori:terminal-toggle") },
239
- { label: "Toggle Terminal (F12)", accelerator: "Alt+F12", click: () => mainWindow?.webContents.send("monacori:terminal-toggle") },
240
- { label: "Split Terminal", accelerator: "CommandOrControl+D", click: () => mainWindow?.webContents.send("monacori:terminal-split") },
413
+ { label: "Toggle Terminal", accelerator: "Control+`", click: () => sendToFocused("monacori:terminal-toggle") },
414
+ { label: "Toggle Terminal (F12)", accelerator: "Alt+F12", click: () => sendToFocused("monacori:terminal-toggle") },
415
+ { label: "Split Terminal", accelerator: "CommandOrControl+D", click: () => sendToFocused("monacori:terminal-split") },
241
416
  { type: "separator" },
242
- { label: "Focus Previous Pane", accelerator: "CommandOrControl+Alt+[", click: () => mainWindow?.webContents.send("monacori:terminal-pane-focus", -1) },
243
- { label: "Focus Next Pane", accelerator: "CommandOrControl+Alt+]", click: () => mainWindow?.webContents.send("monacori:terminal-pane-focus", 1) },
244
- { label: "Rename Pane", accelerator: "CommandOrControl+Alt+R", click: () => mainWindow?.webContents.send("monacori:terminal-pane-rename") },
417
+ { label: "Focus Previous Pane", accelerator: "CommandOrControl+Alt+[", click: () => sendToFocused("monacori:terminal-pane-focus", -1) },
418
+ { label: "Focus Next Pane", accelerator: "CommandOrControl+Alt+]", click: () => sendToFocused("monacori:terminal-pane-focus", 1) },
419
+ { label: "Rename Pane", accelerator: "CommandOrControl+Alt+R", click: () => sendToFocused("monacori:terminal-pane-rename") },
245
420
  ],
246
421
  });
247
422
  Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate));
248
- const appIcon = nativeImage.createFromPath(iconPath);
249
- if (process.platform === "darwin" && app.dock && !appIcon.isEmpty()) {
250
- app.dock.setIcon(appIcon);
251
- }
423
+ }
424
+ // Create a window for `root`, register its WinState, wire teardown, and boot it (loading spinner ->
425
+ // first build, or the welcome screen for a packaged launch with no repo).
426
+ function createWindow(root) {
252
427
  const themeLight = isLightTheme();
253
- mainWindow = new BrowserWindow({
428
+ const win = new BrowserWindow({
254
429
  width: 1440,
255
430
  height: 960,
256
431
  minWidth: 960,
@@ -268,107 +443,215 @@ app.whenReady().then(async () => {
268
443
  spellcheck: false,
269
444
  },
270
445
  });
271
- mainWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
272
- mainWindow.once("ready-to-show", () => {
273
- mainWindow?.show();
446
+ const state = {
447
+ win,
448
+ options: makeOptions(root),
449
+ signature: "",
450
+ refreshing: false,
451
+ bodies: [],
452
+ sourceData: "[]",
453
+ lastDiffSig: "",
454
+ terms: new Map(),
455
+ };
456
+ const id = win.id;
457
+ states.set(id, state);
458
+ win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
459
+ win.once("ready-to-show", () => {
460
+ if (state.win.isDestroyed())
461
+ return;
462
+ state.win.show();
274
463
  if (DEV_BUILD)
275
- mainWindow?.webContents.openDevTools({ mode: "detach" });
464
+ state.win.webContents.openDevTools({ mode: "detach" });
276
465
  });
277
- // Paint the window with a spinner immediately, then build the (potentially heavy) review off the first
278
- // paint and swap it in. The first build used to run synchronously *before* the window existed, so the
279
- // screen stayed blank for the first few seconds of startup; now the user sees a loading screen instead.
280
- await mainWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(loadingHtml(themeLight)));
281
- // Give the loading spinner a few frames to actually paint before the (synchronous) first build blocks
282
- // the main process — otherwise the spinner looks frozen until the build finishes. The boot overlay in
283
- // the review HTML then takes over, so there's no blank gap when loadFile swaps the page in.
466
+ // Window teardown: kill this window's ptys, clear its watch timer, and drop its state. This is the
467
+ // per-window analogue of the old global window-all-closed cleanup.
468
+ win.on("closed", () => {
469
+ if (state.refreshTimer)
470
+ clearInterval(state.refreshTimer);
471
+ for (const t of state.terms.values()) {
472
+ try {
473
+ t.kill();
474
+ }
475
+ catch { /* already exited */ }
476
+ }
477
+ state.terms.clear();
478
+ states.delete(id);
479
+ });
480
+ void bootWindow(state, themeLight);
481
+ return state;
482
+ }
483
+ // Paint the spinner immediately, then build the (potentially heavy) review off the first paint and swap it
484
+ // in. Building before the window exists left the screen blank for the first few seconds of startup.
485
+ async function bootWindow(state, themeLight) {
486
+ await state.win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(loadingHtml(themeLight)));
487
+ // Give the spinner a few frames to paint before the (synchronous) first build blocks the main process —
488
+ // otherwise the spinner looks frozen until the build finishes. The boot overlay in the review HTML then
489
+ // takes over, so there's no blank gap when loadFile swaps the page in.
284
490
  setTimeout(() => {
491
+ // Bail if the window was closed during the spinner delay — its closed handler already tore down state,
492
+ // so building/loading here is wasted and (critically) arming the watch timer below would re-create an
493
+ // interval that nothing will ever clear, leaking it and pinning the deleted WinState.
494
+ if (state.win.isDestroyed())
495
+ return;
285
496
  try {
286
- const firstBuild = writeReviewFile(options);
287
- currentSignature = firstBuild.signature;
288
- if (mainWindow && !mainWindow.isDestroyed())
289
- void mainWindow.loadFile(reviewPath());
290
- if (options.watch)
291
- refreshTimer = setInterval(refreshIfChanged, WATCH_INTERVAL_MS);
497
+ // A packaged .app (double-clicked) can launch with no useful cwd repo. Show the welcome screen
498
+ // (an Open Folder button) instead of an empty diff. New windows always get a validated repo.
499
+ if (app.isPackaged && !isGitRepository(state.options.root)) {
500
+ void showWelcome(state);
501
+ return;
502
+ }
503
+ const firstBuild = writeReviewFile(state);
504
+ state.signature = firstBuild.signature;
505
+ recordRecentProject(state.options.root); // remember the launched/new-window repo for the welcome screen
506
+ if (!state.win.isDestroyed())
507
+ void state.win.loadFile(reviewPath(state.options.root));
508
+ if (state.options.watch)
509
+ state.refreshTimer = setInterval(() => void refreshIfChanged(state), WATCH_INTERVAL_MS);
292
510
  }
293
511
  catch (error) {
512
+ // One window's build failure shouldn't take down the whole app (other windows may be fine); log and
513
+ // leave this window on the spinner rather than quitting.
294
514
  console.error(error instanceof Error ? error.message : String(error));
295
- app.quit();
296
515
  }
297
516
  }, 60);
298
- }).catch((error) => {
299
- console.error(error instanceof Error ? error.message : String(error));
300
- app.quit();
301
- });
302
- app.on("window-all-closed", () => {
303
- if (refreshTimer)
304
- clearInterval(refreshTimer);
305
- for (const t of terms.values()) {
306
- try {
307
- t.kill();
308
- }
309
- catch { /* already exited */ }
310
- }
311
- terms.clear();
312
- app.quit();
313
- });
314
- let lastDiffSig = "";
315
- async function refreshIfChanged() {
316
- if (refreshing || !mainWindow || mainWindow.isDestroyed())
517
+ }
518
+ async function refreshIfChanged(state) {
519
+ if (state.refreshing || state.win.isDestroyed())
317
520
  return;
318
- refreshing = true;
521
+ state.refreshing = true;
319
522
  try {
320
523
  // Fast path: hash only the git diff (~120ms) before the full build (~1s). The vast majority of
321
524
  // watch ticks see no change, so skip the heavy buildDiffReview entirely then — keeping the main
322
525
  // process free for IPC/pty so the UI never stalls on an unchanged tree.
323
526
  const diffSig = createHash("sha1")
324
527
  .update(readUnifiedDiff({
325
- base: options.base,
326
- staged: options.staged,
327
- context: options.context,
328
- includeUntracked: options.includeUntracked,
329
- ignoreWhitespace: options.ignoreWhitespace,
528
+ base: state.options.base,
529
+ staged: state.options.staged,
530
+ context: state.options.context,
531
+ includeUntracked: state.options.includeUntracked,
532
+ ignoreWhitespace: state.options.ignoreWhitespace,
533
+ root: state.options.root,
330
534
  }))
331
535
  .digest("hex");
332
- if (diffSig === lastDiffSig)
536
+ if (diffSig === state.lastDiffSig)
333
537
  return;
334
- lastDiffSig = diffSig;
335
- const next = writeReviewFile(options);
336
- if (next.signature !== currentSignature) {
337
- currentSignature = next.signature;
538
+ state.lastDiffSig = diffSig;
539
+ const next = writeReviewFile(state);
540
+ if (next.signature !== state.signature) {
541
+ state.signature = next.signature;
338
542
  // Refresh the diff in place instead of reloading the window. A full reload re-runs the renderer,
339
543
  // whose beforeunload kills every pty — so an integrated terminal running claude/codex would die on
340
544
  // each working-tree change. We send only the compact update payload (diff/trees/status/data — no
341
545
  // xterm blob), and the renderer transplants it + re-fetches per-file bodies/source over the existing
342
- // IPC (currentBodies/currentSourceData were just refreshed by writeReviewFile above).
546
+ // IPC (state.bodies/state.sourceData were just refreshed by writeReviewFile above).
343
547
  if (next.update)
344
- mainWindow.webContents.send("monacori:diff-update", next.update);
548
+ state.win.webContents.send("monacori:diff-update", next.update);
345
549
  }
346
550
  }
347
551
  catch (error) {
348
552
  console.error(error instanceof Error ? error.message : String(error));
349
553
  }
350
554
  finally {
351
- refreshing = false;
555
+ state.refreshing = false;
352
556
  }
353
557
  }
354
- function writeReviewFile(input) {
558
+ function writeReviewFile(state) {
355
559
  const build = buildDiffReview({
356
- base: input.base,
357
- staged: input.staged,
358
- includeUntracked: input.includeUntracked,
359
- context: input.context,
560
+ base: state.options.base,
561
+ staged: state.options.staged,
562
+ includeUntracked: state.options.includeUntracked,
563
+ context: state.options.context,
360
564
  title: APP_TITLE,
361
- ignoreWhitespace: input.ignoreWhitespace,
565
+ ignoreWhitespace: state.options.ignoreWhitespace,
362
566
  lazyLoad: true, // Electron streams per-file bodies/source over IPC (monacori:get-file / get-source)
363
567
  app: true, // gate the integrated terminal (xterm) into the HTML — Electron only
568
+ root: state.options.root, // review THIS window's repo (no process.chdir; root is threaded through)
364
569
  });
365
- writeFileSync(reviewPath(), build.html);
366
- currentBodies = build.lazyBodies ?? [];
367
- currentSourceData = build.lazySourceData ?? "[]";
570
+ // Two windows on the same repo share this path; the content is identical for the same git state, and
571
+ // each window loads its own freshly-written copy right after, so a same-repo race is benign.
572
+ mkdirSync(join(state.options.root, FLOW_DIR), { recursive: true });
573
+ writeFileSync(reviewPath(state.options.root), build.html);
574
+ state.bodies = build.lazyBodies ?? [];
575
+ state.sourceData = build.lazySourceData ?? "[]";
368
576
  return { signature: build.signature, html: build.html, update: build.update };
369
577
  }
370
- function reviewPath() {
371
- return join(options.root, FLOW_DIR, REVIEW_FILE);
578
+ function reviewPath(root) {
579
+ return join(root, FLOW_DIR, REVIEW_FILE);
580
+ }
581
+ // Welcome screen for the packaged .app (double-clicked, no cwd repo). Written to userData (we can't write
582
+ // the review file under "/") and loaded so preload exposes window.monacoriApp.openFolder to its button.
583
+ async function showWelcome(state) {
584
+ if (state.win.isDestroyed())
585
+ return;
586
+ const welcomePath = join(app.getPath("userData"), "welcome.html");
587
+ mkdirSync(dirname(welcomePath), { recursive: true });
588
+ const recent = readRecentProjects().filter((p) => existsSync(p.path)); // hide entries whose folder is gone
589
+ writeFileSync(welcomePath, renderWelcomeHtml(isLightTheme(), recent));
590
+ await state.win.loadFile(welcomePath);
591
+ }
592
+ // Load a chosen git repo into an existing window — the welcome screen's folder picker, or File > Open
593
+ // Folder. Repoints the window's root, (re)writes the review, swaps the page, and re-arms its watch timer.
594
+ // No process.chdir: root is threaded through writeReviewFile/refreshIfChanged per window.
595
+ async function openReview(state, root) {
596
+ state.options.root = resolve(root);
597
+ recordRecentProject(state.options.root); // remember it for the welcome screen's Recent Projects
598
+ state.lastDiffSig = ""; // new repo -> force the next watch tick to rebuild
599
+ if (state.refreshTimer) {
600
+ clearInterval(state.refreshTimer);
601
+ state.refreshTimer = undefined;
602
+ }
603
+ const build = writeReviewFile(state);
604
+ state.signature = build.signature;
605
+ if (!state.win.isDestroyed())
606
+ await state.win.loadFile(reviewPath(state.options.root));
607
+ if (state.options.watch)
608
+ state.refreshTimer = setInterval(() => void refreshIfChanged(state), WATCH_INTERVAL_MS);
609
+ }
610
+ // File > Open Folder (Cmd/Ctrl+O): pick a repo and load it into the focused window.
611
+ async function openFolderInCurrent() {
612
+ const state = focusedState();
613
+ if (!state)
614
+ return;
615
+ const root = await pickRepo(state.win, state.options.root);
616
+ if (root)
617
+ await openReview(state, root);
618
+ }
619
+ // File > Open in New Window (Cmd/Ctrl+Shift+O): pick a repo and open it in a brand-new window.
620
+ async function openFolderInNewWindow() {
621
+ const parent = BrowserWindow.getFocusedWindow() ?? undefined;
622
+ const root = await pickRepo(parent, focusedState()?.options.root);
623
+ if (root)
624
+ createWindow(root);
625
+ }
626
+ // Shared directory picker — just the dialog; returns the chosen path or undefined if canceled. Callers
627
+ // validate (the welcome flow reports not-git in-page; the File menu shows a native box via pickRepo).
628
+ async function pickDirectory(parent, defaultPath) {
629
+ const dialogOptions = {
630
+ properties: ["openDirectory"],
631
+ title: "Open a Git repository",
632
+ ...(defaultPath ? { defaultPath } : {}),
633
+ };
634
+ const result = parent
635
+ ? await dialog.showOpenDialog(parent, dialogOptions)
636
+ : await dialog.showOpenDialog(dialogOptions);
637
+ return result.canceled ? undefined : (result.filePaths[0] || undefined);
638
+ }
639
+ // File-menu picker: a directory that's a validated git repo, or undefined (canceled, or not-git with a
640
+ // native error box). Used by Open Folder / Open in New Window, which have no in-page error surface.
641
+ async function pickRepo(parent, defaultPath) {
642
+ const root = await pickDirectory(parent, defaultPath);
643
+ if (!root)
644
+ return undefined;
645
+ if (!isGitRepository(root)) {
646
+ dialog.showErrorBox("Not a Git repository", `${root} is not a Git repository.`);
647
+ return undefined;
648
+ }
649
+ return root;
650
+ }
651
+ // Clone the CLI-resolved flags for a new window, overriding only the repo root. root + ignoreWhitespace are
652
+ // then mutated per window without affecting other windows or the template.
653
+ function makeOptions(root) {
654
+ return { ...options, root: resolve(root) };
372
655
  }
373
656
  function parseArgs(args) {
374
657
  const root = readOption(args, "--cwd") ?? process.cwd();