@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/assets/icon.icns +0 -0
- package/dist/app-main.js +441 -158
- package/dist/assets.js +8 -1
- package/dist/build.d.ts +1 -0
- package/dist/build.js +13 -7
- package/dist/diff.d.ts +2 -1
- package/dist/diff.js +3 -3
- package/dist/i18n.js +56 -8
- package/dist/preload.cjs +15 -0
- package/dist/render.d.ts +5 -0
- package/dist/render.js +154 -29
- package/dist/util.d.ts +5 -0
- package/dist/util.js +21 -0
- package/dist/viewer.client.js +582 -153
- package/dist/viewer.client.min.js +1 -0
- package/dist/viewer.css +202 -72
- package/package.json +10 -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, 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
|
|
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
|
+
// 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
|
-
//
|
|
112
|
-
// throws "Object has been destroyed".
|
|
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 (
|
|
116
|
-
|
|
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", (
|
|
125
|
-
ipcMain.on("monacori:pty-resize", (
|
|
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", (
|
|
132
|
-
const
|
|
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
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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: () =>
|
|
202
|
-
{ label: "All change requests", accelerator: "Control+Command+Shift+.", click: () =>
|
|
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: () =>
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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: () =>
|
|
230
|
-
{ label: "Close Window", click: () =>
|
|
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: () =>
|
|
239
|
-
{ label: "Toggle Terminal (F12)", accelerator: "Alt+F12", click: () =>
|
|
240
|
-
{ label: "Split Terminal", accelerator: "CommandOrControl+D", click: () =>
|
|
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: () =>
|
|
243
|
-
{ label: "Focus Next Pane", accelerator: "CommandOrControl+Alt+]", click: () =>
|
|
244
|
-
{ label: "Rename Pane", accelerator: "CommandOrControl+Alt+R", click: () =>
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
464
|
+
state.win.webContents.openDevTools({ mode: "detach" });
|
|
276
465
|
});
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
void
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
}
|
|
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())
|
|
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(
|
|
336
|
-
if (next.signature !==
|
|
337
|
-
|
|
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 (
|
|
546
|
+
// IPC (state.bodies/state.sourceData were just refreshed by writeReviewFile above).
|
|
343
547
|
if (next.update)
|
|
344
|
-
|
|
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(
|
|
558
|
+
function writeReviewFile(state) {
|
|
355
559
|
const build = buildDiffReview({
|
|
356
|
-
base:
|
|
357
|
-
staged:
|
|
358
|
-
includeUntracked:
|
|
359
|
-
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:
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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(
|
|
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();
|