@happy-nut/monacori 0.1.20 → 0.1.21

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 } from "electron";
6
6
  import { buildDiffReview, performHttpRequest } from "./cli.js";
7
7
  import { sanitizeTerminalEnv } 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,185 @@ 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
193
  name: "xterm-color",
105
194
  cols: size?.cols ?? 80,
106
195
  rows: size?.rows ?? 24,
107
- cwd: options.root,
196
+ cwd: state.options.root,
108
197
  env: sanitizeTerminalEnv(process.env),
109
198
  });
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().
199
+ state.terms.set(id, t);
200
+ // Guard every relay with isDestroyed(): a pty can outlive its window (close races pty teardown), and
201
+ // sending to a closed window's webContents throws "Object has been destroyed".
114
202
  const deliver = (channel, payload) => {
115
- if (mainWindow && !mainWindow.isDestroyed())
116
- mainWindow.webContents.send(channel, payload);
203
+ if (!state.win.isDestroyed())
204
+ state.win.webContents.send(channel, payload);
117
205
  };
118
206
  // Relay pty output to the renderer immediately, one IPC per chunk. (A coalescing buffer was tried as an
119
207
  // optimization but it broke terminal I/O — the shell prompt and echo stopped appearing — so it's removed.)
120
208
  t.onData((data) => deliver("monacori:pty-data", { id, data }));
121
- t.onExit(() => { terms.delete(id); deliver("monacori:pty-exit", { id }); });
209
+ t.onExit(() => { state.terms.delete(id); deliver("monacori:pty-exit", { id }); });
122
210
  return { ok: true, id };
123
211
  });
124
- ipcMain.on("monacori:pty-write", (_event, msg) => { terms.get(msg?.id)?.write(msg.data); });
125
- ipcMain.on("monacori:pty-resize", (_event, msg) => {
212
+ ipcMain.on("monacori:pty-write", (event, msg) => { stateFromEvent(event)?.terms.get(msg?.id)?.write(msg.data); });
213
+ ipcMain.on("monacori:pty-resize", (event, msg) => {
126
214
  try {
127
- terms.get(msg?.id)?.resize(msg.cols, msg.rows);
215
+ stateFromEvent(event)?.terms.get(msg?.id)?.resize(msg.cols, msg.rows);
128
216
  }
129
217
  catch { /* resize can race the pty teardown — ignore */ }
130
218
  });
131
- ipcMain.on("monacori:pty-kill", (_event, msg) => {
132
- const t = terms.get(msg?.id);
219
+ ipcMain.on("monacori:pty-kill", (event, msg) => {
220
+ const state = stateFromEvent(event);
221
+ const t = state?.terms.get(msg?.id);
133
222
  if (t) {
134
223
  try {
135
224
  t.kill();
136
225
  }
137
226
  catch { /* already exited */ }
138
- terms.delete(msg.id);
227
+ state.terms.delete(msg.id);
139
228
  }
140
229
  });
141
230
  // Persisted global settings (locale, …) live in a JSON file under userData and reach the renderer
@@ -170,50 +259,101 @@ ipcMain.on("monacori:set-setting", (_event, msg) => {
170
259
  settings[msg.key] = msg.value;
171
260
  writeSettings(settings);
172
261
  });
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}`);
262
+ const RECENT_KEY = "monacori-recent-projects";
263
+ const RECENT_MAX = 12;
264
+ function readRecentProjects() {
265
+ const raw = readSettings()[RECENT_KEY];
266
+ if (!Array.isArray(raw))
267
+ return [];
268
+ return raw.filter((x) => !!x && typeof x === "object" && typeof x.path === "string");
269
+ }
270
+ function recordRecentProject(root) {
271
+ const path = resolve(root);
272
+ if (!isGitRepository(path))
273
+ return; // only remember real repos (bootWindow can build a non-git root)
274
+ const others = readRecentProjects().filter((p) => p.path !== path);
275
+ const next = [{ path, name: basename(path) || path, openedAt: Date.now() }, ...others].slice(0, RECENT_MAX);
276
+ const settings = readSettings();
277
+ settings[RECENT_KEY] = next;
278
+ writeSettings(settings);
279
+ }
280
+ function forgetRecentProject(root) {
281
+ const path = resolve(root);
282
+ const settings = readSettings();
283
+ settings[RECENT_KEY] = readRecentProjects().filter((p) => p.path !== path);
284
+ writeSettings(settings);
182
285
  }
183
286
  app.whenReady().then(async () => {
184
287
  // Foreground (`npm run dev` / `mo --foreground`) surfaces this in the terminal; detached `mo` drops
185
288
  // it. Either way the path disambiguates a local checkout from the installed package.
186
289
  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);
290
+ buildApplicationMenu();
291
+ const appIcon = nativeImage.createFromPath(iconPath);
292
+ if (process.platform === "darwin" && app.dock && !appIcon.isEmpty()) {
293
+ app.dock.setIcon(appIcon);
294
+ }
295
+ // First window uses the CLI-resolved root + flags. No chdir/mkdir here — each window scopes its own
296
+ // repo via options.root, and writeReviewFile() creates that root's .monacori dir on demand.
297
+ createWindow(options.root);
298
+ }).catch((error) => {
299
+ console.error(error instanceof Error ? error.message : String(error));
300
+ app.quit();
301
+ });
302
+ // Each window's "closed" handler already killed its ptys, cleared its timer, and deleted its state, so
303
+ // there's nothing global left to tear down — just quit once the last window is gone.
304
+ app.on("window-all-closed", () => {
305
+ app.quit();
306
+ });
307
+ // Keep the Ignore-whitespace menu checkbox honest as focus moves between windows (it's per-window state).
308
+ app.on("browser-window-focus", (_event, win) => {
309
+ const state = states.get(win.id);
310
+ const item = Menu.getApplicationMenu()?.getMenuItemById("ignore-whitespace");
311
+ if (item && state)
312
+ item.checked = state.options.ignoreWhitespace;
313
+ });
314
+ // Build the application menu once. Items act on the focused window (BrowserWindow.getFocusedWindow()),
315
+ // so a single global menu drives whichever window is in front.
316
+ function buildApplicationMenu() {
192
317
  const menuTemplate = [];
193
318
  if (process.platform === "darwin")
194
319
  menuTemplate.push({ role: "appMenu" });
320
+ // File menu: open a repo in the current window, or spawn a new window for it.
321
+ menuTemplate.push({
322
+ label: "File",
323
+ submenu: [
324
+ { label: "Open Folder…", accelerator: "CommandOrControl+O", click: () => void openFolderInCurrent() },
325
+ { label: "Open in New Window…", accelerator: "CommandOrControl+Shift+O", click: () => void openFolderInNewWindow() },
326
+ ],
327
+ });
328
+ // Keep the standard Edit/Window roles so Cmd+C/V/X/A (copy comments into prompts) and Cmd+Q work.
329
+ // The in-window menu bar stays hidden on Windows/Linux via autoHideMenuBar; macOS shows it in the top bar.
195
330
  menuTemplate.push({ role: "editMenu" });
196
331
  // Ctrl+Cmd+Shift+/ ("?") and Ctrl+Cmd+Shift+. (">") open the merged question / change-request views.
197
332
  // ? and > are Shift+/ and Shift+. so Shift is part of the combo; Ctrl+Cmd avoids macOS's Cmd+? Help grab.
198
333
  menuTemplate.push({
199
334
  label: "Review",
200
335
  submenu: [
201
- { label: "All questions", accelerator: "Control+Command+Shift+/", click: () => sendMerged("q") },
202
- { label: "All change requests", accelerator: "Control+Command+Shift+.", click: () => sendMerged("c") },
336
+ { label: "All questions", accelerator: "Control+Command+Shift+/", click: () => sendToFocused("monacori:merged-view", "q") },
337
+ { label: "All change requests", accelerator: "Control+Command+Shift+.", click: () => sendToFocused("monacori:merged-view", "c") },
203
338
  // 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") },
339
+ { label: "Prompt memo", accelerator: "CommandOrControl+Shift+N", click: () => sendToFocused("monacori:open-memo") },
205
340
  { type: "separator" },
206
341
  // 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).
342
+ // so a menu checkbox is simpler than a renderer IPC round-trip). Per-window: applies to the focused
343
+ // window only, and browser-window-focus syncs this checkbox to the focused window's state.
208
344
  {
345
+ id: "ignore-whitespace",
209
346
  label: "Ignore whitespace",
210
347
  type: "checkbox",
211
348
  checked: options.ignoreWhitespace,
212
349
  accelerator: "CommandOrControl+Shift+W",
213
350
  click: (item) => {
214
- options.ignoreWhitespace = item.checked;
215
- currentSignature = writeReviewFile(options).signature;
216
- mainWindow?.webContents.reloadIgnoringCache();
351
+ const state = focusedState();
352
+ if (!state)
353
+ return;
354
+ state.options.ignoreWhitespace = item.checked;
355
+ state.signature = writeReviewFile(state).signature;
356
+ state.win.webContents.reloadIgnoringCache();
217
357
  },
218
358
  },
219
359
  ],
@@ -226,8 +366,8 @@ app.whenReady().then(async () => {
226
366
  { role: "minimize" },
227
367
  { role: "zoom" },
228
368
  { type: "separator" },
229
- { label: "Close Tab", accelerator: "CommandOrControl+W", click: () => mainWindow?.webContents.send("monacori:close-tab") },
230
- { label: "Close Window", click: () => mainWindow?.close() },
369
+ { label: "Close Tab", accelerator: "CommandOrControl+W", click: () => sendToFocused("monacori:close-tab") },
370
+ { label: "Close Window", click: () => BrowserWindow.getFocusedWindow()?.close() },
231
371
  ],
232
372
  });
233
373
  // Terminal toggle/split as menu accelerators: Chromium swallows Cmd+D before it reaches the renderer
@@ -235,22 +375,22 @@ app.whenReady().then(async () => {
235
375
  menuTemplate.push({
236
376
  label: "Terminal",
237
377
  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") },
378
+ { label: "Toggle Terminal", accelerator: "Control+`", click: () => sendToFocused("monacori:terminal-toggle") },
379
+ { label: "Toggle Terminal (F12)", accelerator: "Alt+F12", click: () => sendToFocused("monacori:terminal-toggle") },
380
+ { label: "Split Terminal", accelerator: "CommandOrControl+D", click: () => sendToFocused("monacori:terminal-split") },
241
381
  { 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") },
382
+ { label: "Focus Previous Pane", accelerator: "CommandOrControl+Alt+[", click: () => sendToFocused("monacori:terminal-pane-focus", -1) },
383
+ { label: "Focus Next Pane", accelerator: "CommandOrControl+Alt+]", click: () => sendToFocused("monacori:terminal-pane-focus", 1) },
384
+ { label: "Rename Pane", accelerator: "CommandOrControl+Alt+R", click: () => sendToFocused("monacori:terminal-pane-rename") },
245
385
  ],
246
386
  });
247
387
  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
- }
388
+ }
389
+ // Create a window for `root`, register its WinState, wire teardown, and boot it (loading spinner ->
390
+ // first build, or the welcome screen for a packaged launch with no repo).
391
+ function createWindow(root) {
252
392
  const themeLight = isLightTheme();
253
- mainWindow = new BrowserWindow({
393
+ const win = new BrowserWindow({
254
394
  width: 1440,
255
395
  height: 960,
256
396
  minWidth: 960,
@@ -268,107 +408,215 @@ app.whenReady().then(async () => {
268
408
  spellcheck: false,
269
409
  },
270
410
  });
271
- mainWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
272
- mainWindow.once("ready-to-show", () => {
273
- mainWindow?.show();
411
+ const state = {
412
+ win,
413
+ options: makeOptions(root),
414
+ signature: "",
415
+ refreshing: false,
416
+ bodies: [],
417
+ sourceData: "[]",
418
+ lastDiffSig: "",
419
+ terms: new Map(),
420
+ };
421
+ const id = win.id;
422
+ states.set(id, state);
423
+ win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
424
+ win.once("ready-to-show", () => {
425
+ if (state.win.isDestroyed())
426
+ return;
427
+ state.win.show();
274
428
  if (DEV_BUILD)
275
- mainWindow?.webContents.openDevTools({ mode: "detach" });
429
+ state.win.webContents.openDevTools({ mode: "detach" });
276
430
  });
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.
431
+ // Window teardown: kill this window's ptys, clear its watch timer, and drop its state. This is the
432
+ // per-window analogue of the old global window-all-closed cleanup.
433
+ win.on("closed", () => {
434
+ if (state.refreshTimer)
435
+ clearInterval(state.refreshTimer);
436
+ for (const t of state.terms.values()) {
437
+ try {
438
+ t.kill();
439
+ }
440
+ catch { /* already exited */ }
441
+ }
442
+ state.terms.clear();
443
+ states.delete(id);
444
+ });
445
+ void bootWindow(state, themeLight);
446
+ return state;
447
+ }
448
+ // Paint the spinner immediately, then build the (potentially heavy) review off the first paint and swap it
449
+ // in. Building before the window exists left the screen blank for the first few seconds of startup.
450
+ async function bootWindow(state, themeLight) {
451
+ await state.win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(loadingHtml(themeLight)));
452
+ // Give the spinner a few frames to paint before the (synchronous) first build blocks the main process —
453
+ // otherwise the spinner looks frozen until the build finishes. The boot overlay in the review HTML then
454
+ // takes over, so there's no blank gap when loadFile swaps the page in.
284
455
  setTimeout(() => {
456
+ // Bail if the window was closed during the spinner delay — its closed handler already tore down state,
457
+ // so building/loading here is wasted and (critically) arming the watch timer below would re-create an
458
+ // interval that nothing will ever clear, leaking it and pinning the deleted WinState.
459
+ if (state.win.isDestroyed())
460
+ return;
285
461
  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);
462
+ // A packaged .app (double-clicked) can launch with no useful cwd repo. Show the welcome screen
463
+ // (an Open Folder button) instead of an empty diff. New windows always get a validated repo.
464
+ if (app.isPackaged && !isGitRepository(state.options.root)) {
465
+ void showWelcome(state);
466
+ return;
467
+ }
468
+ const firstBuild = writeReviewFile(state);
469
+ state.signature = firstBuild.signature;
470
+ recordRecentProject(state.options.root); // remember the launched/new-window repo for the welcome screen
471
+ if (!state.win.isDestroyed())
472
+ void state.win.loadFile(reviewPath(state.options.root));
473
+ if (state.options.watch)
474
+ state.refreshTimer = setInterval(() => void refreshIfChanged(state), WATCH_INTERVAL_MS);
292
475
  }
293
476
  catch (error) {
477
+ // One window's build failure shouldn't take down the whole app (other windows may be fine); log and
478
+ // leave this window on the spinner rather than quitting.
294
479
  console.error(error instanceof Error ? error.message : String(error));
295
- app.quit();
296
480
  }
297
481
  }, 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())
482
+ }
483
+ async function refreshIfChanged(state) {
484
+ if (state.refreshing || state.win.isDestroyed())
317
485
  return;
318
- refreshing = true;
486
+ state.refreshing = true;
319
487
  try {
320
488
  // Fast path: hash only the git diff (~120ms) before the full build (~1s). The vast majority of
321
489
  // watch ticks see no change, so skip the heavy buildDiffReview entirely then — keeping the main
322
490
  // process free for IPC/pty so the UI never stalls on an unchanged tree.
323
491
  const diffSig = createHash("sha1")
324
492
  .update(readUnifiedDiff({
325
- base: options.base,
326
- staged: options.staged,
327
- context: options.context,
328
- includeUntracked: options.includeUntracked,
329
- ignoreWhitespace: options.ignoreWhitespace,
493
+ base: state.options.base,
494
+ staged: state.options.staged,
495
+ context: state.options.context,
496
+ includeUntracked: state.options.includeUntracked,
497
+ ignoreWhitespace: state.options.ignoreWhitespace,
498
+ root: state.options.root,
330
499
  }))
331
500
  .digest("hex");
332
- if (diffSig === lastDiffSig)
501
+ if (diffSig === state.lastDiffSig)
333
502
  return;
334
- lastDiffSig = diffSig;
335
- const next = writeReviewFile(options);
336
- if (next.signature !== currentSignature) {
337
- currentSignature = next.signature;
503
+ state.lastDiffSig = diffSig;
504
+ const next = writeReviewFile(state);
505
+ if (next.signature !== state.signature) {
506
+ state.signature = next.signature;
338
507
  // Refresh the diff in place instead of reloading the window. A full reload re-runs the renderer,
339
508
  // whose beforeunload kills every pty — so an integrated terminal running claude/codex would die on
340
509
  // each working-tree change. We send only the compact update payload (diff/trees/status/data — no
341
510
  // 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).
511
+ // IPC (state.bodies/state.sourceData were just refreshed by writeReviewFile above).
343
512
  if (next.update)
344
- mainWindow.webContents.send("monacori:diff-update", next.update);
513
+ state.win.webContents.send("monacori:diff-update", next.update);
345
514
  }
346
515
  }
347
516
  catch (error) {
348
517
  console.error(error instanceof Error ? error.message : String(error));
349
518
  }
350
519
  finally {
351
- refreshing = false;
520
+ state.refreshing = false;
352
521
  }
353
522
  }
354
- function writeReviewFile(input) {
523
+ function writeReviewFile(state) {
355
524
  const build = buildDiffReview({
356
- base: input.base,
357
- staged: input.staged,
358
- includeUntracked: input.includeUntracked,
359
- context: input.context,
525
+ base: state.options.base,
526
+ staged: state.options.staged,
527
+ includeUntracked: state.options.includeUntracked,
528
+ context: state.options.context,
360
529
  title: APP_TITLE,
361
- ignoreWhitespace: input.ignoreWhitespace,
530
+ ignoreWhitespace: state.options.ignoreWhitespace,
362
531
  lazyLoad: true, // Electron streams per-file bodies/source over IPC (monacori:get-file / get-source)
363
532
  app: true, // gate the integrated terminal (xterm) into the HTML — Electron only
533
+ root: state.options.root, // review THIS window's repo (no process.chdir; root is threaded through)
364
534
  });
365
- writeFileSync(reviewPath(), build.html);
366
- currentBodies = build.lazyBodies ?? [];
367
- currentSourceData = build.lazySourceData ?? "[]";
535
+ // Two windows on the same repo share this path; the content is identical for the same git state, and
536
+ // each window loads its own freshly-written copy right after, so a same-repo race is benign.
537
+ mkdirSync(join(state.options.root, FLOW_DIR), { recursive: true });
538
+ writeFileSync(reviewPath(state.options.root), build.html);
539
+ state.bodies = build.lazyBodies ?? [];
540
+ state.sourceData = build.lazySourceData ?? "[]";
368
541
  return { signature: build.signature, html: build.html, update: build.update };
369
542
  }
370
- function reviewPath() {
371
- return join(options.root, FLOW_DIR, REVIEW_FILE);
543
+ function reviewPath(root) {
544
+ return join(root, FLOW_DIR, REVIEW_FILE);
545
+ }
546
+ // Welcome screen for the packaged .app (double-clicked, no cwd repo). Written to userData (we can't write
547
+ // the review file under "/") and loaded so preload exposes window.monacoriApp.openFolder to its button.
548
+ async function showWelcome(state) {
549
+ if (state.win.isDestroyed())
550
+ return;
551
+ const welcomePath = join(app.getPath("userData"), "welcome.html");
552
+ mkdirSync(dirname(welcomePath), { recursive: true });
553
+ const recent = readRecentProjects().filter((p) => existsSync(p.path)); // hide entries whose folder is gone
554
+ writeFileSync(welcomePath, renderWelcomeHtml(isLightTheme(), recent));
555
+ await state.win.loadFile(welcomePath);
556
+ }
557
+ // Load a chosen git repo into an existing window — the welcome screen's folder picker, or File > Open
558
+ // Folder. Repoints the window's root, (re)writes the review, swaps the page, and re-arms its watch timer.
559
+ // No process.chdir: root is threaded through writeReviewFile/refreshIfChanged per window.
560
+ async function openReview(state, root) {
561
+ state.options.root = resolve(root);
562
+ recordRecentProject(state.options.root); // remember it for the welcome screen's Recent Projects
563
+ state.lastDiffSig = ""; // new repo -> force the next watch tick to rebuild
564
+ if (state.refreshTimer) {
565
+ clearInterval(state.refreshTimer);
566
+ state.refreshTimer = undefined;
567
+ }
568
+ const build = writeReviewFile(state);
569
+ state.signature = build.signature;
570
+ if (!state.win.isDestroyed())
571
+ await state.win.loadFile(reviewPath(state.options.root));
572
+ if (state.options.watch)
573
+ state.refreshTimer = setInterval(() => void refreshIfChanged(state), WATCH_INTERVAL_MS);
574
+ }
575
+ // File > Open Folder (Cmd/Ctrl+O): pick a repo and load it into the focused window.
576
+ async function openFolderInCurrent() {
577
+ const state = focusedState();
578
+ if (!state)
579
+ return;
580
+ const root = await pickRepo(state.win, state.options.root);
581
+ if (root)
582
+ await openReview(state, root);
583
+ }
584
+ // File > Open in New Window (Cmd/Ctrl+Shift+O): pick a repo and open it in a brand-new window.
585
+ async function openFolderInNewWindow() {
586
+ const parent = BrowserWindow.getFocusedWindow() ?? undefined;
587
+ const root = await pickRepo(parent, focusedState()?.options.root);
588
+ if (root)
589
+ createWindow(root);
590
+ }
591
+ // Shared directory picker — just the dialog; returns the chosen path or undefined if canceled. Callers
592
+ // validate (the welcome flow reports not-git in-page; the File menu shows a native box via pickRepo).
593
+ async function pickDirectory(parent, defaultPath) {
594
+ const dialogOptions = {
595
+ properties: ["openDirectory"],
596
+ title: "Open a Git repository",
597
+ ...(defaultPath ? { defaultPath } : {}),
598
+ };
599
+ const result = parent
600
+ ? await dialog.showOpenDialog(parent, dialogOptions)
601
+ : await dialog.showOpenDialog(dialogOptions);
602
+ return result.canceled ? undefined : (result.filePaths[0] || undefined);
603
+ }
604
+ // File-menu picker: a directory that's a validated git repo, or undefined (canceled, or not-git with a
605
+ // native error box). Used by Open Folder / Open in New Window, which have no in-page error surface.
606
+ async function pickRepo(parent, defaultPath) {
607
+ const root = await pickDirectory(parent, defaultPath);
608
+ if (!root)
609
+ return undefined;
610
+ if (!isGitRepository(root)) {
611
+ dialog.showErrorBox("Not a Git repository", `${root} is not a Git repository.`);
612
+ return undefined;
613
+ }
614
+ return root;
615
+ }
616
+ // Clone the CLI-resolved flags for a new window, overriding only the repo root. root + ignoreWhitespace are
617
+ // then mutated per window without affecting other windows or the template.
618
+ function makeOptions(root) {
619
+ return { ...options, root: resolve(root) };
372
620
  }
373
621
  function parseArgs(args) {
374
622
  const root = readOption(args, "--cwd") ?? process.cwd();