@happy-nut/monacori 0.1.0 → 0.1.2

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,334 @@
1
+ import { createRequire } from "node:module";
2
+ import { escapeAttr, escapeHtml, jsonForScript } from "./util.js";
3
+ import { diff2HtmlCss, diffCss, diffScript } from "./assets.js";
4
+ const nodeRequire = createRequire(import.meta.url);
5
+ const packageVersion = (() => {
6
+ try {
7
+ const pkg = nodeRequire("../package.json");
8
+ return typeof pkg.version === "string" ? pkg.version : "";
9
+ }
10
+ catch {
11
+ return "";
12
+ }
13
+ })();
14
+ export function renderNotGitRepoHtml(root) {
15
+ return [
16
+ "<!doctype html>",
17
+ '<html lang="en">',
18
+ "<head>",
19
+ '<meta charset="utf-8">',
20
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
21
+ "<title>monacori</title>",
22
+ "<style>",
23
+ "* { box-sizing: border-box; }",
24
+ "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; }",
25
+ ".card { max-width: 560px; padding: 40px; text-align: center; }",
26
+ ".card .badge { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: #808080; }",
27
+ ".card h1 { font-size: 22px; margin: 10px 0 16px; color: #ffc66d; }",
28
+ ".card p { font-size: 14px; line-height: 1.7; margin: 10px 0; }",
29
+ ".card code { background: #3c3f41; padding: 3px 9px; border-radius: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #6a8759; }",
30
+ ".card .path { color: #808080; font-size: 12px; word-break: break-all; margin-top: 22px; }",
31
+ "</style>",
32
+ "</head>",
33
+ "<body>",
34
+ '<div class="card">',
35
+ '<div class="badge">monacori</div>',
36
+ "<h1>Not a Git repository</h1>",
37
+ "<p>monacori reviews changes tracked by Git, but this folder isn't a Git repository yet.</p>",
38
+ "<p>Open a terminal here, run <code>git init</code>, then reopen monacori.</p>",
39
+ `<p class="path">${escapeHtml(root)}</p>`,
40
+ "</div>",
41
+ "</body>",
42
+ "</html>",
43
+ ].join("\n");
44
+ }
45
+ // Above a size threshold the diff is rendered "lazily": each file's heavy body
46
+ // (the side-by-side tables — hundreds of thousands of rows on big repos) is moved
47
+ // out of the live DOM into an inert <script type="text/html"> island, leaving only
48
+ // a lightweight wrapper + header. The renderer materializes a file's body on demand
49
+ // (scroll-into-view / navigation), so the browser never parses + lays out a giant DOM
50
+ // up front; the UI opens instantly and shortcuts work immediately. Small repos and
51
+ // tests stay on the eager path (below threshold) and are byte-for-byte unchanged.
52
+ export function shouldLazyRender(fileCount, totalLines) {
53
+ return fileCount > 60 || totalLines > 4000;
54
+ }
55
+ export function splitDiffForLazy(diffHtml, files) {
56
+ const parts = diffHtml.split(/(?=<div [^>]*class="d2h-file-wrapper")/).filter((p) => p.includes('class="d2h-file-wrapper"'));
57
+ const shells = [];
58
+ const islands = [];
59
+ const bodies = []; // dense, one per file index — used by lazy-LOAD (served on demand)
60
+ let hunkIndex = 0;
61
+ parts.forEach((part, i) => {
62
+ const file = files[i];
63
+ const firstHunk = hunkIndex;
64
+ const hunkCount = file ? file.hunks.length : 0;
65
+ hunkIndex += hunkCount;
66
+ const marker = '<div class="d2h-files-diff">';
67
+ const open = part.indexOf(marker);
68
+ if (open < 0) {
69
+ shells.push(part); // no diff body (e.g. binary / pure rename) — leave it materialized
70
+ bodies.push("");
71
+ return;
72
+ }
73
+ const before = part.slice(0, open);
74
+ const after = part.slice(open + marker.length);
75
+ const body = after.replace(/<\/div>\s*<\/div>\s*$/, "");
76
+ const path = file ? file.displayPath : "";
77
+ 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>';
78
+ shells.push(shell);
79
+ bodies.push(body);
80
+ islands.push(`<script type="text/html" id="diff-body-${i}">${body}</script>`);
81
+ });
82
+ return { container: shells.join("\n"), islands: islands.join("\n"), bodies };
83
+ }
84
+ export function renderDiffHtml(input) {
85
+ const totalHunks = input.files.reduce((sum, file) => sum + file.hunks.length, 0);
86
+ const fileNav = renderDiffTree(input.files);
87
+ const sourceNav = renderSourceTree(input.sourceFiles);
88
+ const embeddedFiles = input.sourceFiles.filter((file) => file.embedded).length;
89
+ return [
90
+ "<!doctype html>",
91
+ '<html lang="en">',
92
+ "<head>",
93
+ '<meta charset="utf-8">',
94
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
95
+ '<link rel="icon" href="data:,">',
96
+ `<title>${escapeHtml(input.title)} - ${escapeHtml(input.projectName)}</title>`,
97
+ "<style>",
98
+ diff2HtmlCss(),
99
+ diffCss(),
100
+ "</style>",
101
+ "</head>",
102
+ "<body>",
103
+ '<aside class="sidebar" aria-label="Review navigation">',
104
+ '<div class="sidebar-scroll">',
105
+ `<div class="sidebar-brand" title="${escapeAttr(input.projectPath)}"><span class="brand-mark">monacori</span><span class="brand-project">${escapeHtml(input.projectName)}</span></div>`,
106
+ input.lazy
107
+ ? '<div class="tabs"><button type="button" class="tab active" data-tab="changes">Changes</button><button type="button" class="tab" data-tab="files">Files</button></div>'
108
+ : '<div class="tabs"><button type="button" class="tab" data-tab="changes">Changes</button><button type="button" class="tab active" data-tab="files">Files</button></div>',
109
+ `<div class="tab-panel${input.lazy ? "" : " hidden"}" id="changes-panel">${fileNav}</div>`,
110
+ // Big repos: defer the (potentially huge) source tree — ship it as an inert island, materialized on
111
+ // the first Files-tab open, so it never builds/lays-out at startup. Small repos render it inline.
112
+ input.lazy
113
+ ? `<div class="tab-panel hidden" id="files-panel"></div><script type="text/html" id="files-tree-html">${sourceNav}</script>`
114
+ : `<div class="tab-panel" id="files-panel">${sourceNav}</div>`,
115
+ "</div>",
116
+ `<div class="sidebar-footer"><span class="app-version">monacori${packageVersion ? " v" + escapeHtml(packageVersion) : ""}</span><span id="app-update-flag" class="app-update-flag hidden" title="Update available">update available</span><button type="button" id="app-info-btn" class="settings-btn" aria-haspopup="dialog" aria-label="About monacori" title="About monacori">⚙</button></div>`,
117
+ "</aside>",
118
+ '<div class="sidebar-resizer" aria-hidden="true"></div>',
119
+ '<main class="content">',
120
+ '<section id="diff-view" class="hidden">',
121
+ '<div class="toolbar">',
122
+ '<div class="breadcrumb" id="diff-breadcrumb"></div>',
123
+ `<div class="review-status"><span>${input.files.length} files</span><span>${totalHunks} hunks</span>${input.ignoreWhitespace ? '<span class="ws-ignored" title="Whitespace ignored — Cmd/Ctrl+Shift+W">ws ignored</span>' : ""}<span class="index-status" id="index-status" 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 ? "watching" : escapeHtml(input.generatedAt ?? new Date().toISOString())}</span></div>`,
124
+ '<button type="button" id="diff-viewed-toggle" class="diff-viewed-toggle" aria-pressed="false" title="Toggle viewed (<)" hidden>Viewed</button>',
125
+ "</div>",
126
+ `<div id="diff2html-container" class="diff2html-container">${input.diffHtml || '<div class="empty">No diff to review.</div>'}</div>`,
127
+ "</section>",
128
+ '<section id="source-viewer" class="source-viewer">',
129
+ '<div class="toolbar source-toolbar">',
130
+ '<div class="source-file-meta"><span id="source-type-icon" class="source-type-icon" aria-hidden="true"></span><span id="source-title">Source</span><span id="source-meta">Select a file from the Files tab.</span></div>',
131
+ '<select id="http-env-select" class="http-env-select hidden" title="HTTP Client environment" aria-label="HTTP environment"></select>',
132
+ '<button type="button" id="back-to-diff" class="plain-button">Diff</button>',
133
+ "</div>",
134
+ '<div id="source-body" class="source-body empty">Select a file from the Files tab.</div>',
135
+ "</section>",
136
+ "</main>",
137
+ `<div id="app-info" class="app-info hidden" role="dialog" aria-modal="false" aria-label="About monacori"><div class="app-info-head">monacori <span class="app-info-ver">${packageVersion ? "v" + escapeHtml(packageVersion) : ""}</span></div><div id="app-info-status" class="app-info-status">Checking for updates…</div><div class="app-info-cmd"><code>npm i -g @happy-nut/monacori</code><button type="button" id="app-info-copy" class="plain-button">Copy</button></div><div class="app-info-keys"><div class="app-info-keys-h">Keyboard shortcuts</div><div class="keys-grid"><kbd>F7</kbd><span>Next change</span><kbd>Shift+F7</kbd><span>Previous change</span><kbd>Cmd/Ctrl+[ / ]</kbd><span>Cursor back / forward</span><kbd>Shift Shift</kbd><span>Find file</span><kbd>Cmd/Ctrl+Shift+F</kbd><span>Find in files</span><kbd>Cmd/Ctrl+E</kbd><span>Recent files</span><kbd>Cmd/Ctrl+B</kbd><span>Definition / usages</span><kbd>Cmd/Ctrl+&darr;</kbd><span>Go to definition</span><kbd>Cmd/Ctrl+1 / 0</kbd><span>Files / Changes tab</span><kbd>Tab</kbd><span>Sidebar &harr; content</span><kbd>Opt/Alt+&larr;/&rarr;</kbd><span>Word jump (vim w)</span><kbd>Cmd/Ctrl+&larr;/&rarr;</kbd><span>Line start / end</span><kbd>Shift+arrows</kbd><span>Extend selection</span><kbd>&lt;</kbd><span>Toggle viewed</span><kbd>? &nbsp;&gt;</kbd><span>Add question / change</span><kbd>Cmd/Ctrl+Shift+/ .</kbd><span>All questions / changes</span><kbd>Cmd/Ctrl+Shift+W</kbd><span>Ignore whitespace</span><kbd>Cmd/Ctrl+Enter</kbd><span>Save comment</span></div></div></div>`,
138
+ '<div id="quick-open" class="quick-open hidden" role="dialog" aria-modal="true" aria-label="Quick open">',
139
+ '<div class="quick-open-panel">',
140
+ '<div class="quick-open-title"><span id="quick-open-mode">Search files</span></div>',
141
+ '<input id="quick-open-input" type="search" autocomplete="off" spellcheck="false" placeholder="Search files">',
142
+ '<div id="quick-open-results" class="quick-open-results"></div>',
143
+ '<div id="quick-open-preview" class="quick-open-preview"></div>',
144
+ "</div>",
145
+ "</div>",
146
+ '<div id="usages" class="quick-open hidden" role="dialog" aria-modal="true" aria-label="Usages">',
147
+ '<div class="quick-open-panel">',
148
+ '<div class="quick-open-title"><span id="usages-title">Usages</span></div>',
149
+ '<div id="usages-results" class="quick-open-results"></div>',
150
+ "</div>",
151
+ "</div>",
152
+ input.diffIslands || "",
153
+ `<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>`,
154
+ `<script type="application/json" id="source-files-data">${jsonForScript(input.lazyLoad ? input.sourceFiles.map((f) => ({ ...f, content: "", image: "" })) : input.sourceFiles)}</script>`,
155
+ `<script type="application/json" id="file-state-data">${jsonForScript(input.fileStates)}</script>`,
156
+ `<script type="application/json" id="http-env-data">${jsonForScript(input.httpEnvironments)}</script>`,
157
+ `<script>window.__MONACORI_VERSION__=${JSON.stringify(packageVersion)};</script>`,
158
+ "<script>",
159
+ diffScript(),
160
+ "</script>",
161
+ "</body>",
162
+ "</html>",
163
+ ].join("\n");
164
+ }
165
+ function renderDiffTree(files) {
166
+ if (files.length === 0) {
167
+ return '<div class="empty-nav">No changed files</div>';
168
+ }
169
+ let hunkIndex = 0;
170
+ const rows = files.map((file, fileIndex) => {
171
+ const firstHunk = hunkIndex;
172
+ hunkIndex += file.hunks.length;
173
+ let adds = 0;
174
+ let dels = 0;
175
+ for (const hunk of file.hunks) {
176
+ for (const line of hunk.lines) {
177
+ if (line.kind === "add")
178
+ adds += 1;
179
+ else if (line.kind === "delete")
180
+ dels += 1;
181
+ }
182
+ }
183
+ const slash = file.displayPath.lastIndexOf("/");
184
+ const name = slash >= 0 ? file.displayPath.slice(slash + 1) : file.displayPath;
185
+ const dir = slash > 0 ? file.displayPath.slice(0, slash) : "";
186
+ return [
187
+ `<a class="file-link change-row" href="#file-${fileIndex}" data-hunk="${firstHunk}" data-file="${escapeAttr(file.displayPath)}" title="${escapeAttr(file.displayPath + " — " + file.status)}">`,
188
+ fileTypeIcon(file.displayPath),
189
+ `<span class="status status-${escapeAttr(file.status)}">${escapeHtml(file.status)}</span>`,
190
+ `<span class="change-name"><span class="path" title="${escapeAttr(file.displayPath)}">${escapeHtml(name)}</span>${dir ? `<span class="change-dir">${escapeHtml(dir)}</span>` : ""}</span>`,
191
+ `<span class="diffstat">${adds ? `<span class="adds">+${adds}</span>` : ""}${dels ? `<span class="dels">−${dels}</span>` : ""}</span>`,
192
+ "</a>",
193
+ ].join("");
194
+ });
195
+ return `<nav class="tree changes-flat">${rows.join("")}</nav>`;
196
+ }
197
+ function renderSourceTree(files) {
198
+ if (files.length === 0) {
199
+ return '<div class="empty-nav">No source files indexed</div>';
200
+ }
201
+ const root = { name: "", path: "", children: new Map() };
202
+ files.forEach((file) => {
203
+ const parts = file.path.split("/").filter(Boolean);
204
+ let node = root;
205
+ let currentPath = "";
206
+ for (const part of parts.slice(0, -1)) {
207
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
208
+ let child = node.children.get(part);
209
+ if (!child) {
210
+ child = { name: part, path: currentPath, children: new Map() };
211
+ node.children.set(part, child);
212
+ }
213
+ node = child;
214
+ }
215
+ const leafName = parts[parts.length - 1] ?? file.path;
216
+ node.children.set(`${leafName}\0${file.path}`, {
217
+ name: leafName,
218
+ path: file.path,
219
+ children: new Map(),
220
+ file,
221
+ });
222
+ });
223
+ return `<nav class="tree source-tree">${renderSourceChildren(root, 0)}</nav>`;
224
+ }
225
+ function renderSourceChildren(node, depth) {
226
+ return Array.from(node.children.values())
227
+ .sort((a, b) => {
228
+ if (Boolean(a.file) !== Boolean(b.file)) {
229
+ return a.file ? 1 : -1;
230
+ }
231
+ return a.name.localeCompare(b.name);
232
+ })
233
+ .map((child) => renderSourceNode(child, depth))
234
+ .join("\n");
235
+ }
236
+ function fileTypeColor(ext) {
237
+ const map = {
238
+ ts: "#3178c6", tsx: "#3178c6", mts: "#3178c6", cts: "#3178c6", "d.ts": "#3178c6",
239
+ js: "#e8bf6a", jsx: "#e8bf6a", mjs: "#e8bf6a", cjs: "#e8bf6a",
240
+ json: "#cbcb41", jsonc: "#cbcb41",
241
+ yaml: "#cb9b41", yml: "#cb9b41", toml: "#cb9b41", ini: "#cb9b41", env: "#cb9b41", conf: "#cb9b41",
242
+ lock: "#9aa0a6", gitignore: "#9aa0a6", npmrc: "#9aa0a6", editorconfig: "#9aa0a6",
243
+ html: "#e44d26", htm: "#e44d26", vue: "#41b883", svelte: "#ff3e00", xml: "#e8bf6a", svg: "#e8bf6a",
244
+ css: "#42a5f5", scss: "#c6538c", sass: "#c6538c", less: "#2a6db5",
245
+ md: "#9aa0a6", mdx: "#9aa0a6", txt: "#9aa0a6", rst: "#9aa0a6",
246
+ go: "#00add8", rs: "#dea584", py: "#3572a5", rb: "#cc342d", java: "#b07219",
247
+ kt: "#a97bff", kts: "#a97bff", php: "#8892bf", swift: "#ff8a00", cs: "#9b59b6",
248
+ c: "#7aa6da", h: "#7aa6da", cpp: "#f34b7d", hpp: "#f34b7d",
249
+ sh: "#89e051", bash: "#89e051", zsh: "#89e051",
250
+ png: "#26a269", jpg: "#26a269", jpeg: "#26a269", gif: "#26a269", webp: "#26a269", ico: "#26a269", bmp: "#26a269",
251
+ };
252
+ return map[ext] || "#7f868d";
253
+ }
254
+ // Small file-type glyph (a tinted folded-corner document) for the Files tree, in place of a text badge.
255
+ function fileTypeCategory(ext) {
256
+ const sets = {
257
+ 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"],
258
+ data: ["json", "jsonc", "yaml", "yml", "toml", "ini", "env", "conf", "lock", "xml"],
259
+ markup: ["html", "htm", "vue", "svelte"],
260
+ style: ["css", "scss", "sass", "less"],
261
+ doc: ["md", "mdx", "txt", "rst"],
262
+ image: ["png", "jpg", "jpeg", "gif", "webp", "ico", "bmp", "svg"],
263
+ };
264
+ for (const cat of Object.keys(sets)) {
265
+ if (sets[cat].includes(ext))
266
+ return cat;
267
+ }
268
+ return "generic";
269
+ }
270
+ // A small, distinct glyph per file-type category, tinted with the language color, for the file lists.
271
+ function fileTypeIcon(path) {
272
+ const base = (path.split("/").pop() || path);
273
+ const dot = base.lastIndexOf(".");
274
+ const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : (base.startsWith(".") ? base.slice(1).toLowerCase() : "");
275
+ const c = fileTypeColor(ext);
276
+ const stroke = `fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"`;
277
+ let inner;
278
+ switch (fileTypeCategory(ext)) {
279
+ case "code": // < >
280
+ inner = `<path d="M6 4.6 3 8l3 3.4M10 4.6 13 8l-3 3.4" ${stroke}/>`;
281
+ break;
282
+ case "markup": // </>
283
+ 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}/>`;
284
+ break;
285
+ case "data": // { }
286
+ 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"/>`;
287
+ break;
288
+ case "style": // #
289
+ 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}/>`;
290
+ break;
291
+ case "doc": // page with text lines
292
+ 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"/>`;
293
+ break;
294
+ case "image": // framed picture
295
+ 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"/>`;
296
+ break;
297
+ default: // folded-corner document
298
+ 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"/>`;
299
+ }
300
+ return `<svg class="ftype" viewBox="0 0 16 16" aria-hidden="true">${inner}</svg>`;
301
+ }
302
+ function renderSourceNode(node, depth) {
303
+ if (node.file) {
304
+ const file = node.file;
305
+ const classes = ["file-link", "source-link", "tree-file", file.embedded ? "" : "not-embedded"].filter(Boolean).join(" ");
306
+ const tip = file.path + (file.embedded ? "" : " — not embedded");
307
+ return [
308
+ `<button type="button" class="${classes}" data-source-file="${escapeAttr(file.path)}" style="--depth:${depth}" title="${escapeAttr(tip)}">`,
309
+ fileTypeIcon(file.path),
310
+ `<span class="path">${escapeHtml(node.name)}</span>`,
311
+ "</button>",
312
+ ].join("");
313
+ }
314
+ let labelNode = node;
315
+ const names = [node.name];
316
+ for (;;) {
317
+ const entries = Array.from(labelNode.children.values());
318
+ if (entries.length !== 1 || entries[0].file)
319
+ break;
320
+ names.push(entries[0].name);
321
+ labelNode = entries[0];
322
+ }
323
+ return [
324
+ `<details class="tree-dir source-dir" data-dir="${escapeAttr(labelNode.path)}" style="--depth:${depth}">`,
325
+ `<summary><span class="folder-icon">v</span><span class="path">${escapeHtml(names.join("/"))}</span></summary>`,
326
+ renderSourceChildren(labelNode, depth + 1),
327
+ "</details>",
328
+ ].join("\n");
329
+ }
330
+ export function diffSubtitle(options) {
331
+ const source = options.staged ? "staged changes" : `working tree vs ${options.base ?? "HEAD"}`;
332
+ const untracked = options.includeUntracked ? "including untracked files" : "tracked files only";
333
+ return `${source}; ${untracked}; ${options.context} context lines`;
334
+ }
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ export type FlowConfig = {
2
+ version: 1;
3
+ projectName: string;
4
+ verification: {
5
+ commands: string[];
6
+ };
7
+ diff: {
8
+ context: number;
9
+ includeUntracked: boolean;
10
+ };
11
+ };
12
+ export type GitSnapshot = {
13
+ branch: string;
14
+ status: string;
15
+ diffStat: string;
16
+ recentCommits: string;
17
+ };
18
+ export type DiffLine = {
19
+ kind: "context" | "add" | "delete";
20
+ oldLine?: number;
21
+ newLine?: number;
22
+ text: string;
23
+ };
24
+ export type DiffHunk = {
25
+ header: string;
26
+ title: string;
27
+ oldStart: number;
28
+ newStart: number;
29
+ lines: DiffLine[];
30
+ };
31
+ export type DiffFile = {
32
+ oldPath: string;
33
+ newPath: string;
34
+ displayPath: string;
35
+ status: string;
36
+ binary: boolean;
37
+ hunks: DiffHunk[];
38
+ };
39
+ export type SourceFile = {
40
+ path: string;
41
+ name: string;
42
+ language: string;
43
+ content: string;
44
+ size: number;
45
+ changed: boolean;
46
+ embedded: boolean;
47
+ changedLines: number[];
48
+ signature: string;
49
+ skippedReason?: string;
50
+ image?: string;
51
+ };
52
+ export type HttpSendRequest = {
53
+ method: string;
54
+ url: string;
55
+ headers: Record<string, string>;
56
+ body?: string;
57
+ };
58
+ export type HttpSendResult = {
59
+ ok: boolean;
60
+ status?: number;
61
+ statusText?: string;
62
+ headers?: Record<string, string>;
63
+ body?: string;
64
+ error?: string;
65
+ durationMs: number;
66
+ };
67
+ export type SourceTreeNode = {
68
+ name: string;
69
+ path: string;
70
+ children: Map<string, SourceTreeNode>;
71
+ file?: SourceFile;
72
+ };
73
+ export type DiffReviewResult = {
74
+ path: string;
75
+ url: string;
76
+ files: number;
77
+ hunks: number;
78
+ };
79
+ export type DiffReviewBuild = {
80
+ html: string;
81
+ files: number;
82
+ hunks: number;
83
+ signature: string;
84
+ generatedAt: string;
85
+ lazyBodies?: string[];
86
+ lazySourceData?: string;
87
+ };
88
+ export type VerificationRun = {
89
+ commands: string[];
90
+ failed: boolean;
91
+ skipped: boolean;
92
+ logPath?: string;
93
+ };
94
+ export type ReviewFileState = {
95
+ path: string;
96
+ signature: string;
97
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/util.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ export declare function stripHtmlTags(value: string): string;
2
+ export declare function decodeEntities(value: string): string;
3
+ export declare function stripDiffPath(value: string): string;
4
+ export declare function languageForPath(path: string): string;
5
+ export declare function isLikelyBinary(path: string): boolean;
6
+ export declare function readOption(args: string[], name: string): string | undefined;
7
+ export declare function parsePositiveInteger(value: string, optionName: string): number;
8
+ export declare function readStdin(): string;
9
+ export declare function summarizeForState(content: string): string;
10
+ export declare function codeBlock(content: string): string;
11
+ export declare function timestampForFile(): string;
12
+ export declare function hashText(value: string): string;
13
+ export declare function sanitizeFilePart(value: string): string;
14
+ export declare function escapeHtml(value: string): string;
15
+ export declare function jsonForScript(value: unknown): string;
16
+ export declare function escapeAttr(value: string): string;
17
+ export declare function formatBytes(bytes: number): string;
18
+ export declare function listRecentFiles(dir: string, limit: number): string[];