@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/assets/icon.icns +0 -0
- package/dist/app-main.js +403 -155
- package/dist/build.d.ts +1 -0
- package/dist/build.js +8 -6
- package/dist/diff.d.ts +2 -1
- package/dist/diff.js +3 -3
- package/dist/i18n.js +6 -0
- package/dist/preload.cjs +7 -0
- package/dist/render.d.ts +4 -0
- package/dist/render.js +84 -0
- package/dist/viewer.client.js +317 -126
- package/dist/viewer.css +52 -8
- package/package.json +9 -2
- package/scripts/patch-electron-name.mjs +23 -14
package/dist/app-main.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
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
|
|
49
|
-
// most recent writeReviewFile() build so navigation/scroll can materialize bodies
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 <
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
178
|
+
relaunch();
|
|
87
179
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
//
|
|
112
|
-
// throws "Object has been destroyed".
|
|
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 (
|
|
116
|
-
|
|
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", (
|
|
125
|
-
ipcMain.on("monacori:pty-resize", (
|
|
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", (
|
|
132
|
-
const
|
|
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
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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: () =>
|
|
202
|
-
{ label: "All change requests", accelerator: "Control+Command+Shift+.", click: () =>
|
|
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: () =>
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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: () =>
|
|
230
|
-
{ label: "Close Window", click: () =>
|
|
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: () =>
|
|
239
|
-
{ label: "Toggle Terminal (F12)", accelerator: "Alt+F12", click: () =>
|
|
240
|
-
{ label: "Split Terminal", accelerator: "CommandOrControl+D", click: () =>
|
|
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: () =>
|
|
243
|
-
{ label: "Focus Next Pane", accelerator: "CommandOrControl+Alt+]", click: () =>
|
|
244
|
-
{ label: "Rename Pane", accelerator: "CommandOrControl+Alt+R", click: () =>
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
429
|
+
state.win.webContents.openDevTools({ mode: "detach" });
|
|
276
430
|
});
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
void
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
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(
|
|
336
|
-
if (next.signature !==
|
|
337
|
-
|
|
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 (
|
|
511
|
+
// IPC (state.bodies/state.sourceData were just refreshed by writeReviewFile above).
|
|
343
512
|
if (next.update)
|
|
344
|
-
|
|
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(
|
|
523
|
+
function writeReviewFile(state) {
|
|
355
524
|
const build = buildDiffReview({
|
|
356
|
-
base:
|
|
357
|
-
staged:
|
|
358
|
-
includeUntracked:
|
|
359
|
-
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:
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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(
|
|
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();
|