@happy-nut/monacori 0.1.0 → 0.1.3

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/render.js ADDED
@@ -0,0 +1,406 @@
1
+ import { createRequire } from "node:module";
2
+ import { escapeAttr, escapeHtml, jsonForScript } from "./util.js";
3
+ import { diff2HtmlCss, diffCss, diffScript, xtermCss, xtermScript } from "./assets.js";
4
+ import { MESSAGES } from "./i18n.js";
5
+ const nodeRequire = createRequire(import.meta.url);
6
+ const packageVersion = (() => {
7
+ try {
8
+ const pkg = nodeRequire("../package.json");
9
+ return typeof pkg.version === "string" ? pkg.version : "";
10
+ }
11
+ catch {
12
+ return "";
13
+ }
14
+ })();
15
+ export function renderNotGitRepoHtml(root) {
16
+ return [
17
+ "<!doctype html>",
18
+ '<html lang="en">',
19
+ "<head>",
20
+ '<meta charset="utf-8">',
21
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
22
+ "<title>monacori</title>",
23
+ "<style>",
24
+ "* { box-sizing: border-box; }",
25
+ "body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #2b2b2b; color: #a9b7c6; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }",
26
+ ".card { max-width: 560px; padding: 40px; text-align: center; }",
27
+ ".card .badge { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: #808080; }",
28
+ ".card h1 { font-size: 22px; margin: 10px 0 16px; color: #ffc66d; }",
29
+ ".card p { font-size: 14px; line-height: 1.7; margin: 10px 0; }",
30
+ ".card code { background: #3c3f41; padding: 3px 9px; border-radius: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #6a8759; }",
31
+ ".card .path { color: #808080; font-size: 12px; word-break: break-all; margin-top: 22px; }",
32
+ "</style>",
33
+ "</head>",
34
+ "<body>",
35
+ '<div class="card">',
36
+ '<div class="badge">monacori</div>',
37
+ "<h1>Not a Git repository</h1>",
38
+ "<p>monacori reviews changes tracked by Git, but this folder isn't a Git repository yet.</p>",
39
+ "<p>Open a terminal here, run <code>git init</code>, then reopen monacori.</p>",
40
+ `<p class="path">${escapeHtml(root)}</p>`,
41
+ "</div>",
42
+ "</body>",
43
+ "</html>",
44
+ ].join("\n");
45
+ }
46
+ // Above a size threshold the diff is rendered "lazily": each file's heavy body
47
+ // (the side-by-side tables — hundreds of thousands of rows on big repos) is moved
48
+ // out of the live DOM into an inert <script type="text/html"> island, leaving only
49
+ // a lightweight wrapper + header. The renderer materializes a file's body on demand
50
+ // (scroll-into-view / navigation), so the browser never parses + lays out a giant DOM
51
+ // up front; the UI opens instantly and shortcuts work immediately. Small repos and
52
+ // tests stay on the eager path (below threshold) and are byte-for-byte unchanged.
53
+ export function shouldLazyRender(fileCount, totalLines) {
54
+ return fileCount > 60 || totalLines > 4000;
55
+ }
56
+ export function splitDiffForLazy(diffHtml, files) {
57
+ const parts = diffHtml.split(/(?=<div [^>]*class="d2h-file-wrapper")/).filter((p) => p.includes('class="d2h-file-wrapper"'));
58
+ const shells = [];
59
+ const islands = [];
60
+ const bodies = []; // dense, one per file index — used by lazy-LOAD (served on demand)
61
+ let hunkIndex = 0;
62
+ parts.forEach((part, i) => {
63
+ const file = files[i];
64
+ const firstHunk = hunkIndex;
65
+ const hunkCount = file ? file.hunks.length : 0;
66
+ hunkIndex += hunkCount;
67
+ const marker = '<div class="d2h-files-diff">';
68
+ const open = part.indexOf(marker);
69
+ if (open < 0) {
70
+ shells.push(part); // no diff body (e.g. binary / pure rename) — leave it materialized
71
+ bodies.push("");
72
+ return;
73
+ }
74
+ const before = part.slice(0, open);
75
+ const after = part.slice(open + marker.length);
76
+ const body = after.replace(/<\/div>\s*<\/div>\s*$/, "");
77
+ const path = file ? file.displayPath : "";
78
+ const shell = before.replace(/<div id="[^"]*" class="d2h-file-wrapper"/, `<div id="file-${i}" class="d2h-file-wrapper" data-path="${escapeAttr(path)}" data-first-hunk="${firstHunk}" data-hunk-count="${hunkCount}"`) + '<div class="d2h-files-diff" data-lazy="1"></div></div>';
79
+ shells.push(shell);
80
+ bodies.push(body);
81
+ islands.push(`<script type="text/html" id="diff-body-${i}">${body}</script>`);
82
+ });
83
+ return { container: shells.join("\n"), islands: islands.join("\n"), bodies };
84
+ }
85
+ export function renderDiffHtml(input) {
86
+ const totalHunks = input.files.reduce((sum, file) => sum + file.hunks.length, 0);
87
+ const fileNav = renderDiffTree(input.files);
88
+ const sourceNav = renderSourceTree(input.sourceFiles);
89
+ const embeddedFiles = input.sourceFiles.filter((file) => file.embedded).length;
90
+ return [
91
+ "<!doctype html>",
92
+ '<html lang="en">',
93
+ "<head>",
94
+ '<meta charset="utf-8">',
95
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
96
+ '<link rel="icon" href="data:,">',
97
+ `<title>${escapeHtml(input.title)} - ${escapeHtml(input.projectName)}</title>`,
98
+ "<style>",
99
+ diff2HtmlCss(),
100
+ diffCss(),
101
+ input.app ? xtermCss() : "",
102
+ "</style>",
103
+ "</head>",
104
+ "<body>",
105
+ '<aside class="sidebar" aria-label="Review navigation">',
106
+ '<div class="sidebar-scroll">',
107
+ `<div class="sidebar-brand" title="${escapeAttr(input.projectPath)}"><span class="brand-mark">monacori</span><span class="brand-project">${escapeHtml(input.projectName)}</span></div>`,
108
+ input.lazy
109
+ ? '<div class="tabs"><button type="button" class="tab active" data-tab="changes" data-i18n="tab.changes">Changes</button><button type="button" class="tab" data-tab="files" data-i18n="tab.files">Files</button></div>'
110
+ : '<div class="tabs"><button type="button" class="tab" data-tab="changes" data-i18n="tab.changes">Changes</button><button type="button" class="tab active" data-tab="files" data-i18n="tab.files">Files</button></div>',
111
+ `<div class="tab-panel${input.lazy ? "" : " hidden"}" id="changes-panel">${fileNav}</div>`,
112
+ // Big repos: defer the (potentially huge) source tree — ship it as an inert island, materialized on
113
+ // the first Files-tab open, so it never builds/lays-out at startup. Small repos render it inline.
114
+ input.lazy
115
+ ? `<div class="tab-panel hidden" id="files-panel"></div><script type="text/html" id="files-tree-html">${sourceNav}</script>`
116
+ : `<div class="tab-panel" id="files-panel">${sourceNav}</div>`,
117
+ "</div>",
118
+ `<div class="sidebar-footer"><span class="app-version">monacori${packageVersion ? " v" + escapeHtml(packageVersion) : ""}</span><span id="app-update-flag" class="app-update-flag hidden" data-i18n="sidebar.updateAvailable" data-i18n-title="settings.updateAvailable" title="Update available">update available</span><button type="button" id="terminal-toggle" class="settings-btn terminal-toggle hidden" data-i18n-title="terminal.toggle" title="Toggle terminal (Ctrl+\`)" aria-label="Toggle terminal"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 7l4 5-4 5"/><path d="M13 17h6"/></svg></button><button type="button" id="app-info-btn" class="settings-btn" aria-haspopup="dialog" data-i18n-aria="about.title" data-i18n-title="about.title" aria-label="About monacori" title="About monacori">⚙</button></div>`,
119
+ "</aside>",
120
+ '<div class="sidebar-resizer" aria-hidden="true"></div>',
121
+ '<main class="content">',
122
+ '<section id="diff-view" class="hidden">',
123
+ '<div class="toolbar">',
124
+ '<div class="breadcrumb" id="diff-breadcrumb"></div>',
125
+ `<div class="review-status"><span>${input.files.length} <span data-i18n="status.files">files</span></span><span>${totalHunks} <span data-i18n="status.hunks">hunks</span></span>${input.ignoreWhitespace ? '<span class="ws-ignored" data-i18n="status.wsIgnored" data-i18n-title="status.wsIgnored.title" title="Whitespace ignored — Cmd/Ctrl+Shift+W">ws ignored</span>' : ""}<span class="index-status" id="index-status" data-i18n-title="status.index.title" title="Go-to-definition index">${embeddedFiles}/${input.sourceFiles.length} indexed</span><span class="index-progress hidden" id="index-progress" aria-hidden="true"><span class="index-progress-bar"></span></span><span class="live-status ${input.watch ? "watching" : ""}" id="live-status"${input.watch ? ' data-i18n="status.watching"' : ""}>${input.watch ? "watching" : escapeHtml(input.generatedAt ?? new Date().toISOString())}</span></div>`,
126
+ '<button type="button" id="diff-viewed-toggle" class="diff-viewed-toggle" aria-pressed="false" data-i18n="btn.viewed" data-i18n-title="btn.viewed.title" title="Toggle viewed (<)" hidden>Viewed</button>',
127
+ "</div>",
128
+ `<div id="diff2html-container" class="diff2html-container">${input.diffHtml || '<div class="empty" data-i18n="diff.noDiff">No diff to review.</div>'}</div>`,
129
+ "</section>",
130
+ '<section id="source-viewer" class="source-viewer">',
131
+ '<div id="source-tabs" class="source-tabs hidden" role="tablist"></div>',
132
+ '<div class="toolbar source-toolbar">',
133
+ '<div class="source-file-meta"><span id="source-type-icon" class="source-type-icon" aria-hidden="true"></span><span id="source-title" data-i18n="source.title">Source</span><span id="source-meta" data-i18n="source.selectFile">Select a file from the Files tab.</span></div>',
134
+ '<select id="http-env-select" class="http-env-select hidden" data-i18n-title="http.env.title" data-i18n-aria="http.env.aria" title="HTTP Client environment" aria-label="HTTP environment"></select>',
135
+ '<button type="button" id="render-toggle" class="plain-button hidden" aria-pressed="false">Raw</button>',
136
+ '<button type="button" id="back-to-diff" class="plain-button" data-i18n="btn.diff">Diff</button>',
137
+ "</div>",
138
+ '<div id="source-body" class="source-body empty" data-i18n="source.selectFile">Select a file from the Files tab.</div>',
139
+ "</section>",
140
+ "</main>",
141
+ // Integrated terminal panel (Electron only — shown when window.monacoriPty exists). Fixed to the
142
+ // content column's bottom; a top resizer drags its height. The merged prompt is sent here.
143
+ input.app
144
+ ? '<div id="terminal-panel" class="terminal-panel hidden"><div class="terminal-resizer" aria-hidden="true"></div><div class="terminal-bar"><span class="terminal-title" data-i18n="terminal.title">Terminal</span><button type="button" id="terminal-close" class="terminal-x" data-i18n-title="terminal.close" title="Close terminal" aria-label="Close terminal">&times;</button></div><div id="terminal-host" class="terminal-host"></div></div>'
145
+ : "",
146
+ '<div id="quick-open" class="quick-open hidden" role="dialog" aria-modal="true" data-i18n-aria="quickopen.aria" aria-label="Quick open">',
147
+ '<div class="quick-open-panel">',
148
+ '<div class="quick-open-title"><span id="quick-open-mode" data-i18n="quickopen.searchFiles">Search files</span></div>',
149
+ '<input id="quick-open-input" type="search" autocomplete="off" spellcheck="false" data-i18n-ph="quickopen.searchFiles" placeholder="Search files">',
150
+ '<div id="quick-open-results" class="quick-open-results"></div>',
151
+ '<div id="quick-open-preview" class="quick-open-preview"></div>',
152
+ "</div>",
153
+ "</div>",
154
+ '<div id="usages" class="quick-open hidden" role="dialog" aria-modal="true" data-i18n-aria="usages.aria" aria-label="Usages">',
155
+ '<div class="quick-open-panel">',
156
+ '<div class="quick-open-title"><span id="usages-title" data-i18n="usages.title">Usages</span></div>',
157
+ '<div id="usages-results" class="quick-open-results"></div>',
158
+ "</div>",
159
+ "</div>",
160
+ '<div id="settings-modal" class="settings-modal hidden" role="dialog" aria-modal="true" data-i18n-aria="settings.aria" aria-label="Settings">',
161
+ '<div class="settings-panel">',
162
+ '<aside class="settings-nav"><div class="settings-nav-title" data-i18n="settings.title">Settings</div><button type="button" class="settings-cat active" data-cat="general" data-i18n="settings.cat.general">General</button><button type="button" class="settings-cat" data-cat="prompts" data-i18n="settings.cat.prompts">Merge prompts</button></aside>',
163
+ '<div class="settings-body">',
164
+ '<section class="settings-section" data-cat="general">',
165
+ `<div class="settings-h">monacori <span class="settings-ver">${packageVersion ? "v" + escapeHtml(packageVersion) : ""}</span></div>`,
166
+ '<div id="app-info-status" class="app-info-status" data-i18n="settings.checkingUpdates">Checking for updates…</div>',
167
+ '<button type="button" id="app-info-update" class="plain-button app-info-update hidden" data-i18n="settings.updateRestart">Update &amp; Restart</button>',
168
+ '<label class="settings-label" for="settings-language" data-i18n="settings.language">Language</label>',
169
+ '<select id="settings-language" class="settings-select"><option value="en">English</option><option value="ko">한국어</option></select>',
170
+ '<div class="app-info-keys">' +
171
+ '<div class="app-info-keys-h" data-i18n="settings.kbd.title">Keyboard shortcuts</div>' +
172
+ '<div class="keys-cat" data-i18n="settings.kbd.cat.nav">Navigation</div>' +
173
+ '<div class="keys-grid">' +
174
+ '<kbd>F7</kbd><span data-i18n="kbd.nextChange">Next change</span>' +
175
+ '<kbd>Shift+F7</kbd><span data-i18n="kbd.prevChange">Previous change</span>' +
176
+ '<kbd>Cmd/Ctrl+1 / 0</kbd><span data-i18n="kbd.filesChangesTab">Files / Changes tab</span>' +
177
+ '<kbd>Tab</kbd><span data-i18n="kbd.sidebarContent">Sidebar &harr; content</span>' +
178
+ '<kbd>Shift Shift</kbd><span data-i18n="kbd.findFile">Find file</span>' +
179
+ '<kbd>Cmd/Ctrl+Shift+F</kbd><span data-i18n="kbd.findInFiles">Find in files</span>' +
180
+ '<kbd>Cmd/Ctrl+E</kbd><span data-i18n="kbd.recentFiles">Recent files</span>' +
181
+ '<kbd>Cmd/Ctrl+B</kbd><span data-i18n="kbd.defUsages">Definition / usages</span>' +
182
+ '<kbd>Cmd/Ctrl+&darr;</kbd><span data-i18n="kbd.goToDef">Go to definition</span>' +
183
+ '<kbd>Cmd/Ctrl+Shift+[ / ]</kbd><span data-i18n="kbd.prevNextTab">Prev / next tab</span>' +
184
+ '<kbd>Cmd/Ctrl+[ / ]</kbd><span data-i18n="kbd.cursorBackForward">Cursor back / forward</span>' +
185
+ '<kbd>Opt/Alt+&larr;/&rarr;</kbd><span data-i18n="kbd.wordJump">Word jump (vim w)</span>' +
186
+ '<kbd>Cmd/Ctrl+&larr;/&rarr;</kbd><span data-i18n="kbd.lineStartEnd">Line start / end</span>' +
187
+ '<kbd>Shift+arrows</kbd><span data-i18n="kbd.extendSelection">Extend selection</span>' +
188
+ '<kbd>Cmd/Ctrl+W</kbd><span data-i18n="kbd.closeTab">Close tab</span>' +
189
+ '</div>' +
190
+ '<div class="keys-cat" data-i18n="settings.kbd.cat.review">Review</div>' +
191
+ '<div class="keys-grid">' +
192
+ '<kbd>&lt;</kbd><span data-i18n="kbd.toggleViewed">Toggle viewed</span>' +
193
+ '<kbd>? &nbsp;&gt;</kbd><span data-i18n="kbd.addQuestionChange">Add question / change</span>' +
194
+ '<kbd>Cmd/Ctrl+Shift+/ .</kbd><span data-i18n="kbd.allQuestionsChanges">All questions / changes</span>' +
195
+ '<kbd>Cmd/Ctrl+Shift+W</kbd><span data-i18n="kbd.ignoreWhitespace">Ignore whitespace</span>' +
196
+ '<kbd>Cmd/Ctrl+Enter</kbd><span data-i18n="kbd.saveComment">Save comment</span>' +
197
+ '</div>' +
198
+ '<div class="keys-cat" data-i18n="settings.kbd.cat.terminal">Terminal</div>' +
199
+ '<div class="keys-grid">' +
200
+ '<kbd>Ctrl+`</kbd><span data-i18n="kbd.toggleTerminal">Toggle terminal</span>' +
201
+ '<kbd>Cmd/Ctrl+D</kbd><span data-i18n="kbd.splitPane">Split pane</span>' +
202
+ '<kbd>Cmd/Ctrl+Alt+[ / ]</kbd><span data-i18n="kbd.focusPane">Focus prev / next pane</span>' +
203
+ '<kbd>F2</kbd><span data-i18n="kbd.renamePane">Rename pane</span>' +
204
+ '<kbd>Cmd/Ctrl+W</kbd><span data-i18n="kbd.closeTerminal">Close terminal (when focused)</span>' +
205
+ '</div>' +
206
+ '</div>',
207
+ "</section>",
208
+ '<section class="settings-section hidden" data-cat="prompts">',
209
+ '<div class="settings-h" data-i18n="mergePrompts.title">Merge prompts</div>',
210
+ '<div class="settings-desc" data-i18n="mergePrompts.desc">Heading prepended to the merged prompt opened with Cmd/Ctrl+Shift+/ (questions) and Cmd/Ctrl+Shift+. (change requests). Leave blank to use the default.</div>',
211
+ '<label class="settings-label" for="settings-prompt-q" data-i18n="mergePrompts.qHeading">Questions heading</label>',
212
+ '<textarea id="settings-prompt-q" class="settings-textarea" rows="4" spellcheck="false"></textarea>',
213
+ '<label class="settings-label" for="settings-prompt-c" data-i18n="mergePrompts.cHeading">Change-requests heading</label>',
214
+ '<textarea id="settings-prompt-c" class="settings-textarea" rows="4" spellcheck="false"></textarea>',
215
+ '<div class="settings-actions"><button type="button" id="settings-reset" class="plain-button" data-i18n="mergePrompts.reset">Reset to defaults</button><span id="settings-saved" class="settings-saved"></span></div>',
216
+ "</section>",
217
+ "</div>",
218
+ "</div>",
219
+ "</div>",
220
+ input.diffIslands || "",
221
+ `<script type="application/json" id="review-meta" data-watch="${input.watch ? "true" : "false"}" data-signature="${escapeAttr(input.signature ?? "")}" data-generated-at="${escapeAttr(input.generatedAt ?? "")}" data-lazy="${input.lazy ? "true" : "false"}" data-lazy-load="${input.lazyLoad ? "true" : "false"}">{}</script>`,
222
+ `<script type="application/json" id="i18n-data">${jsonForScript(MESSAGES)}</script>`,
223
+ `<script type="application/json" id="source-files-data">${jsonForScript(input.lazyLoad ? input.sourceFiles.map((f) => ({ ...f, content: "", image: "" })) : input.sourceFiles)}</script>`,
224
+ `<script type="application/json" id="file-state-data">${jsonForScript(input.fileStates)}</script>`,
225
+ `<script type="application/json" id="http-env-data">${jsonForScript(input.httpEnvironments)}</script>`,
226
+ `<script>window.__MONACORI_VERSION__=${JSON.stringify(packageVersion)};</script>`,
227
+ // xterm ships as an inert island (type=text/html, not parsed/compiled at startup) and is injected on
228
+ // the first terminal open — ~490KB the renderer would otherwise parse on every launch even unused.
229
+ input.app ? `<script type="text/html" id="xterm-code">${xtermScript()}</script>` : "",
230
+ "<script>",
231
+ diffScript(),
232
+ "</script>",
233
+ "</body>",
234
+ "</html>",
235
+ ].join("\n");
236
+ }
237
+ function renderDiffTree(files) {
238
+ if (files.length === 0) {
239
+ return '<div class="empty-nav">No changed files</div>';
240
+ }
241
+ let hunkIndex = 0;
242
+ const rows = files.map((file, fileIndex) => {
243
+ const firstHunk = hunkIndex;
244
+ hunkIndex += file.hunks.length;
245
+ let adds = 0;
246
+ let dels = 0;
247
+ for (const hunk of file.hunks) {
248
+ for (const line of hunk.lines) {
249
+ if (line.kind === "add")
250
+ adds += 1;
251
+ else if (line.kind === "delete")
252
+ dels += 1;
253
+ }
254
+ }
255
+ const slash = file.displayPath.lastIndexOf("/");
256
+ const name = slash >= 0 ? file.displayPath.slice(slash + 1) : file.displayPath;
257
+ const dir = slash > 0 ? file.displayPath.slice(0, slash) : "";
258
+ return [
259
+ `<a class="file-link change-row${file.vcs ? " vcs-" + file.vcs : ""}" href="#file-${fileIndex}" data-hunk="${firstHunk}" data-file="${escapeAttr(file.displayPath)}" title="${escapeAttr(file.displayPath + " — " + file.status)}">`,
260
+ fileTypeIcon(file.displayPath),
261
+ `<span class="status status-${escapeAttr(file.status)}">${escapeHtml(file.status)}</span>`,
262
+ `<span class="change-name"><span class="path" title="${escapeAttr(file.displayPath)}">${escapeHtml(name)}</span>${dir ? `<span class="change-dir">${escapeHtml(dir)}</span>` : ""}</span>`,
263
+ `<span class="diffstat">${adds ? `<span class="adds">+${adds}</span>` : ""}${dels ? `<span class="dels">−${dels}</span>` : ""}</span>`,
264
+ "</a>",
265
+ ].join("");
266
+ });
267
+ return `<nav class="tree changes-flat">${rows.join("")}</nav>`;
268
+ }
269
+ function renderSourceTree(files) {
270
+ if (files.length === 0) {
271
+ return '<div class="empty-nav">No source files indexed</div>';
272
+ }
273
+ const root = { name: "", path: "", children: new Map() };
274
+ files.forEach((file) => {
275
+ const parts = file.path.split("/").filter(Boolean);
276
+ let node = root;
277
+ let currentPath = "";
278
+ for (const part of parts.slice(0, -1)) {
279
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
280
+ let child = node.children.get(part);
281
+ if (!child) {
282
+ child = { name: part, path: currentPath, children: new Map() };
283
+ node.children.set(part, child);
284
+ }
285
+ node = child;
286
+ }
287
+ const leafName = parts[parts.length - 1] ?? file.path;
288
+ node.children.set(`${leafName}\0${file.path}`, {
289
+ name: leafName,
290
+ path: file.path,
291
+ children: new Map(),
292
+ file,
293
+ });
294
+ });
295
+ return `<nav class="tree source-tree">${renderSourceChildren(root, 0)}</nav>`;
296
+ }
297
+ function renderSourceChildren(node, depth) {
298
+ return Array.from(node.children.values())
299
+ .sort((a, b) => {
300
+ if (Boolean(a.file) !== Boolean(b.file)) {
301
+ return a.file ? 1 : -1;
302
+ }
303
+ return a.name.localeCompare(b.name);
304
+ })
305
+ .map((child) => renderSourceNode(child, depth))
306
+ .join("\n");
307
+ }
308
+ function fileTypeColor(ext) {
309
+ const map = {
310
+ ts: "#3178c6", tsx: "#3178c6", mts: "#3178c6", cts: "#3178c6", "d.ts": "#3178c6",
311
+ js: "#e8bf6a", jsx: "#e8bf6a", mjs: "#e8bf6a", cjs: "#e8bf6a",
312
+ json: "#cbcb41", jsonc: "#cbcb41",
313
+ yaml: "#cb9b41", yml: "#cb9b41", toml: "#cb9b41", ini: "#cb9b41", env: "#cb9b41", conf: "#cb9b41",
314
+ lock: "#9aa0a6", gitignore: "#9aa0a6", npmrc: "#9aa0a6", editorconfig: "#9aa0a6",
315
+ html: "#e44d26", htm: "#e44d26", vue: "#41b883", svelte: "#ff3e00", xml: "#e8bf6a", svg: "#e8bf6a",
316
+ css: "#42a5f5", scss: "#c6538c", sass: "#c6538c", less: "#2a6db5",
317
+ md: "#9aa0a6", mdx: "#9aa0a6", txt: "#9aa0a6", rst: "#9aa0a6",
318
+ go: "#00add8", rs: "#dea584", py: "#3572a5", rb: "#cc342d", java: "#b07219",
319
+ kt: "#a97bff", kts: "#a97bff", php: "#8892bf", swift: "#ff8a00", cs: "#9b59b6",
320
+ c: "#7aa6da", h: "#7aa6da", cpp: "#f34b7d", hpp: "#f34b7d",
321
+ sh: "#89e051", bash: "#89e051", zsh: "#89e051",
322
+ png: "#26a269", jpg: "#26a269", jpeg: "#26a269", gif: "#26a269", webp: "#26a269", ico: "#26a269", bmp: "#26a269",
323
+ };
324
+ return map[ext] || "#7f868d";
325
+ }
326
+ // Small file-type glyph (a tinted folded-corner document) for the Files tree, in place of a text badge.
327
+ function fileTypeCategory(ext) {
328
+ const sets = {
329
+ code: ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "go", "rs", "py", "rb", "java", "kt", "kts", "php", "c", "h", "cpp", "hpp", "cs", "swift", "sh", "bash", "zsh"],
330
+ data: ["json", "jsonc", "yaml", "yml", "toml", "ini", "env", "conf", "lock", "xml"],
331
+ markup: ["html", "htm", "vue", "svelte"],
332
+ style: ["css", "scss", "sass", "less"],
333
+ doc: ["md", "mdx", "txt", "rst"],
334
+ image: ["png", "jpg", "jpeg", "gif", "webp", "ico", "bmp", "svg"],
335
+ };
336
+ for (const cat of Object.keys(sets)) {
337
+ if (sets[cat].includes(ext))
338
+ return cat;
339
+ }
340
+ return "generic";
341
+ }
342
+ // A small, distinct glyph per file-type category, tinted with the language color, for the file lists.
343
+ function fileTypeIcon(path) {
344
+ const base = (path.split("/").pop() || path);
345
+ const dot = base.lastIndexOf(".");
346
+ const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : (base.startsWith(".") ? base.slice(1).toLowerCase() : "");
347
+ const c = fileTypeColor(ext);
348
+ const stroke = `fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"`;
349
+ let inner;
350
+ switch (fileTypeCategory(ext)) {
351
+ case "code": // < >
352
+ inner = `<path d="M6 4.6 3 8l3 3.4M10 4.6 13 8l-3 3.4" ${stroke}/>`;
353
+ break;
354
+ case "markup": // </>
355
+ inner = `<path d="M5.6 4.6 2.8 8l2.8 3.4M10.4 4.6 13.2 8l-2.8 3.4M9.3 3.6 6.7 12.4" ${stroke}/>`;
356
+ break;
357
+ case "data": // { }
358
+ inner = `<path d="M7.4 3.6C6.3 3.6 6.3 4.8 6.3 5.8 6.3 6.8 5.6 7.4 4.8 7.4 5.6 7.4 6.3 8 6.3 9 6.3 10 6.3 11.4 7.4 11.4M8.6 3.6C9.7 3.6 9.7 4.8 9.7 5.8 9.7 6.8 10.4 7.4 11.2 7.4 10.4 7.4 9.7 8 9.7 9 9.7 10 9.7 11.4 8.6 11.4" fill="none" stroke="${c}" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>`;
359
+ break;
360
+ case "style": // #
361
+ inner = `<path d="M6.4 4 5.2 12M10.2 4 9 12M3.9 6.6 12 6.6M3.4 9.4 11.5 9.4" ${stroke}/>`;
362
+ break;
363
+ case "doc": // page with text lines
364
+ inner = `<path d="M4.5 2.5h4.4L11.5 5v8a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1z" fill="${c}" fill-opacity="0.16" stroke="${c}" stroke-width="1.2" stroke-linejoin="round"/><path d="M8.8 2.6V5h2.6M5.8 8h4M5.8 10.2h2.7" fill="none" stroke="${c}" stroke-width="1.2" stroke-linecap="round"/>`;
365
+ break;
366
+ case "image": // framed picture
367
+ inner = `<rect x="3" y="3.6" width="10" height="8.8" rx="1.4" fill="${c}" fill-opacity="0.14" stroke="${c}" stroke-width="1.2"/><circle cx="6" cy="6.4" r="1.05" fill="none" stroke="${c}" stroke-width="1.1"/><path d="M3.6 11.8 6.7 8.4l2 2.1 1.9-2.2 2.4 2.7" fill="none" stroke="${c}" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>`;
368
+ break;
369
+ default: // folded-corner document
370
+ inner = `<path d="M4 2.25a1 1 0 0 1 1-1h4.3L12.5 4.7v9.05a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" fill="${c}" fill-opacity="0.2" stroke="${c}" stroke-width="1.1" stroke-linejoin="round"/><path d="M9.2 1.4v2.8a1 1 0 0 0 1 1h2.6" fill="none" stroke="${c}" stroke-width="1.1" stroke-linejoin="round"/>`;
371
+ }
372
+ return `<svg class="ftype" viewBox="0 0 16 16" aria-hidden="true">${inner}</svg>`;
373
+ }
374
+ function renderSourceNode(node, depth) {
375
+ if (node.file) {
376
+ const file = node.file;
377
+ const classes = ["file-link", "source-link", "tree-file", file.embedded ? "" : "not-embedded", file.vcs ? "vcs-" + file.vcs : ""].filter(Boolean).join(" ");
378
+ const tip = file.path + (file.embedded ? "" : " — not embedded");
379
+ return [
380
+ `<button type="button" class="${classes}" data-source-file="${escapeAttr(file.path)}" style="--depth:${depth}" title="${escapeAttr(tip)}">`,
381
+ fileTypeIcon(file.path),
382
+ `<span class="path">${escapeHtml(node.name)}</span>`,
383
+ "</button>",
384
+ ].join("");
385
+ }
386
+ let labelNode = node;
387
+ const names = [node.name];
388
+ for (;;) {
389
+ const entries = Array.from(labelNode.children.values());
390
+ if (entries.length !== 1 || entries[0].file)
391
+ break;
392
+ names.push(entries[0].name);
393
+ labelNode = entries[0];
394
+ }
395
+ return [
396
+ `<details class="tree-dir source-dir" data-dir="${escapeAttr(labelNode.path)}" style="--depth:${depth}">`,
397
+ `<summary><span class="folder-icon">v</span><span class="path">${escapeHtml(names.join("/"))}</span></summary>`,
398
+ renderSourceChildren(labelNode, depth + 1),
399
+ "</details>",
400
+ ].join("\n");
401
+ }
402
+ export function diffSubtitle(options) {
403
+ const source = options.staged ? "staged changes" : `working tree vs ${options.base ?? "HEAD"}`;
404
+ const untracked = options.includeUntracked ? "including untracked files" : "tracked files only";
405
+ return `${source}; ${untracked}; ${options.context} context lines`;
406
+ }
@@ -0,0 +1,20 @@
1
+ import type { DiffReviewResult, HttpSendRequest, HttpSendResult } from "./types.js";
2
+ export declare function performHttpRequest(request: HttpSendRequest): Promise<HttpSendResult>;
3
+ export declare function createDiffReview(input: {
4
+ base?: string;
5
+ staged: boolean;
6
+ includeUntracked: boolean;
7
+ context: number;
8
+ output: string;
9
+ title: string;
10
+ ignoreWhitespace?: boolean;
11
+ }): DiffReviewResult;
12
+ export declare function serveDiffWatch(input: {
13
+ base?: string;
14
+ staged: boolean;
15
+ includeUntracked: boolean;
16
+ context: number;
17
+ openInBrowser: boolean;
18
+ port?: string;
19
+ ignoreWhitespace?: boolean;
20
+ }): void;
package/dist/server.js ADDED
@@ -0,0 +1,175 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createServer } from "node:http";
5
+ import { pathToFileURL } from "node:url";
6
+ import { parsePositiveInteger } from "./util.js";
7
+ import { buildDiffReview } from "./build.js";
8
+ // Performs an HTTP request on behalf of the sandboxed renderer. Used by both the
9
+ // Electron IPC handler (app-main.ts) and the browser-mode proxy below.
10
+ export async function performHttpRequest(request) {
11
+ const startedAt = Date.now();
12
+ const method = (request.method || "GET").toUpperCase();
13
+ try {
14
+ const hasBody = typeof request.body === "string" && request.body.length > 0
15
+ && method !== "GET" && method !== "HEAD";
16
+ const response = await fetch(request.url, {
17
+ method,
18
+ headers: request.headers ?? {},
19
+ body: hasBody ? request.body : undefined,
20
+ redirect: "follow",
21
+ });
22
+ const body = await response.text();
23
+ const headers = {};
24
+ response.headers.forEach((value, key) => {
25
+ headers[key] = value;
26
+ });
27
+ return {
28
+ ok: true,
29
+ status: response.status,
30
+ statusText: response.statusText,
31
+ headers,
32
+ body,
33
+ durationMs: Date.now() - startedAt,
34
+ };
35
+ }
36
+ catch (error) {
37
+ return {
38
+ ok: false,
39
+ error: error instanceof Error ? error.message : String(error),
40
+ durationMs: Date.now() - startedAt,
41
+ };
42
+ }
43
+ }
44
+ async function handleHttpProxy(request, response) {
45
+ try {
46
+ const chunks = [];
47
+ for await (const chunk of request) {
48
+ chunks.push(chunk);
49
+ }
50
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
51
+ const result = await performHttpRequest(payload);
52
+ writeHttpJson(response, result);
53
+ }
54
+ catch (error) {
55
+ writeHttpJson(response, {
56
+ ok: false,
57
+ error: error instanceof Error ? error.message : String(error),
58
+ durationMs: 0,
59
+ });
60
+ }
61
+ }
62
+ export function createDiffReview(input) {
63
+ const outputPath = resolve(input.output);
64
+ const build = buildDiffReview({
65
+ base: input.base,
66
+ staged: input.staged,
67
+ includeUntracked: input.includeUntracked,
68
+ context: input.context,
69
+ title: input.title,
70
+ ignoreWhitespace: input.ignoreWhitespace,
71
+ });
72
+ mkdirSync(dirname(outputPath), { recursive: true });
73
+ writeFileSync(outputPath, build.html);
74
+ return {
75
+ path: outputPath,
76
+ url: pathToFileURL(outputPath).href,
77
+ files: build.files,
78
+ hunks: build.hunks,
79
+ };
80
+ }
81
+ export function serveDiffWatch(input) {
82
+ const host = "127.0.0.1";
83
+ const port = input.port ? parsePositiveInteger(input.port, "--port") : 0;
84
+ let lastBuild;
85
+ const build = () => {
86
+ lastBuild = buildDiffReview({
87
+ base: input.base,
88
+ staged: input.staged,
89
+ includeUntracked: input.includeUntracked,
90
+ context: input.context,
91
+ title: "monacori live diff",
92
+ watch: true,
93
+ ignoreWhitespace: input.ignoreWhitespace,
94
+ lazyLoad: true, // serve can stream per-file bodies/source over /file + /source
95
+ });
96
+ return lastBuild;
97
+ };
98
+ const server = createServer((request, response) => {
99
+ const requestUrl = new URL(request.url ?? "/", `http://${host}`);
100
+ try {
101
+ if (requestUrl.pathname === "/__ai_flow_state") {
102
+ const latest = build();
103
+ writeHttpJson(response, {
104
+ signature: latest.signature,
105
+ generatedAt: latest.generatedAt,
106
+ files: latest.files,
107
+ hunks: latest.hunks,
108
+ });
109
+ return;
110
+ }
111
+ if (requestUrl.pathname === "/__http_send" && request.method === "POST") {
112
+ void handleHttpProxy(request, response);
113
+ return;
114
+ }
115
+ if (requestUrl.pathname === "/" || requestUrl.pathname === "/review") {
116
+ const latest = build();
117
+ writeHttp(response, 200, "text/html; charset=utf-8", latest.html);
118
+ return;
119
+ }
120
+ // Phase 2 lazy-LOAD: serve a single file's diff body on demand (the renderer fetches this
121
+ // when a file scrolls into view / is navigated to). Reuses the cached build so we don't re-run
122
+ // git diff per file; falls back to a fresh build if no review has been requested yet.
123
+ if (requestUrl.pathname === "/file") {
124
+ const b = lastBuild ?? build();
125
+ const bodies = b.lazyBodies ?? [];
126
+ const idx = Number(requestUrl.searchParams.get("index"));
127
+ if (Number.isInteger(idx) && idx >= 0 && idx < bodies.length) {
128
+ writeHttp(response, 200, "text/html; charset=utf-8", bodies[idx]);
129
+ }
130
+ else {
131
+ writeHttp(response, 404, "text/plain; charset=utf-8", "Not found\n");
132
+ }
133
+ return;
134
+ }
135
+ // Phase 2b lazy-LOAD: serve the full source files JSON (with content) once, on demand, so the
136
+ // HTML embeds only source metadata. The renderer fetches this after first paint to populate the
137
+ // source view + build the go-to-definition index.
138
+ if (requestUrl.pathname === "/source-data") {
139
+ const b = lastBuild ?? build();
140
+ writeHttp(response, 200, "application/json; charset=utf-8", b.lazySourceData ?? "[]");
141
+ return;
142
+ }
143
+ writeHttp(response, 404, "text/plain; charset=utf-8", "Not found\n");
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ writeHttp(response, 500, "text/plain; charset=utf-8", `${message}\n`);
148
+ }
149
+ });
150
+ server.on("error", (error) => {
151
+ const message = error instanceof Error ? error.message : String(error);
152
+ console.error(`monacori: diff watch server failed: ${message}`);
153
+ process.exit(1);
154
+ });
155
+ server.listen(port, host, () => {
156
+ const address = server.address();
157
+ const actualPort = typeof address === "object" && address ? address.port : port;
158
+ const url = `http://${host}:${actualPort}/review`;
159
+ console.log(`Live diff review: ${url}`);
160
+ console.log("Watching working tree. Press Ctrl+C to stop.");
161
+ if (input.openInBrowser) {
162
+ spawnSync("open", [url], { stdio: "ignore" });
163
+ }
164
+ });
165
+ }
166
+ function writeHttp(response, status, contentType, body) {
167
+ response.writeHead(status, {
168
+ "content-type": contentType,
169
+ "cache-control": "no-store",
170
+ });
171
+ response.end(body);
172
+ }
173
+ function writeHttpJson(response, body) {
174
+ writeHttp(response, 200, "application/json; charset=utf-8", JSON.stringify(body));
175
+ }