@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.
@@ -0,0 +1,3343 @@
1
+
2
+ const REVIEW_LAZY = document.getElementById('review-meta')?.dataset.lazy === 'true';
3
+ // lazy-LOAD (Phase 2): file bodies are NOT embedded; they are fetched on demand (serve: GET /file,
4
+ // Electron: window.monacoriFile.get) so the initial HTML stays small. Implies REVIEW_LAZY (shells).
5
+ const REVIEW_LAZY_LOAD = document.getElementById('review-meta')?.dataset.lazyLoad === 'true';
6
+ if (!REVIEW_LAZY) prepareDiff2HtmlHunks();
7
+ const hunks = REVIEW_LAZY ? [] : Array.from(document.querySelectorAll('.hunk'));
8
+ const hunkPeers = REVIEW_LAZY ? [] : Array.from(document.querySelectorAll('.hunk-peer'));
9
+ // Lazy mode: each file body lives in an inert <script type="text/html"> island (see splitDiffForLazy).
10
+ // Build a hunk index from the lightweight shells (data-first-hunk/data-hunk-count/data-path) so F7 and
11
+ // change-nav work without materializing everything. hunkRowAt() materializes the target file on demand.
12
+ const hunkMeta = [];
13
+ if (REVIEW_LAZY) {
14
+ Array.prototype.forEach.call(document.querySelectorAll('#diff2html-container .d2h-file-wrapper'), function (w) {
15
+ var base = parseInt(w.dataset.firstHunk || '0', 10) || 0;
16
+ var cnt = parseInt(w.dataset.hunkCount || '0', 10) || 0;
17
+ var p = w.dataset.path || ((w.querySelector('.d2h-file-name') || {}).textContent || '').trim();
18
+ for (var k = 0; k < cnt; k++) hunkMeta[base + k] = { path: p };
19
+ });
20
+ }
21
+ var diffBootDone = false;
22
+ function hunkTotal() { return REVIEW_LAZY ? hunkMeta.length : hunks.length; }
23
+ function hunkPathAt(i) { return REVIEW_LAZY ? (hunkMeta[i] ? hunkMeta[i].path : '') : (hunks[i] ? hunks[i].dataset.file : ''); }
24
+ function hunkRowAt(i) {
25
+ if (!REVIEW_LAZY) return hunks[i] || null;
26
+ var meta = hunkMeta[i];
27
+ if (!meta) return null;
28
+ ensureFileReady(diffWrapperByPath(meta.path));
29
+ return document.getElementById('hunk-' + i);
30
+ }
31
+ // Assign global hunk ids/classes to a freshly materialized file body, keyed off its shell's
32
+ // data-first-hunk so indices stay globally consistent with the eager numbering.
33
+ function markWrapperHunks(wrapper) {
34
+ var base = parseInt(wrapper.dataset.firstHunk || '0', 10) || 0;
35
+ var fileName = ((wrapper.querySelector('.d2h-file-name') || {}).textContent || '').trim();
36
+ var headerToIndex = new Map();
37
+ var local = 0;
38
+ Array.prototype.forEach.call(wrapper.querySelectorAll('tr'), function (row) {
39
+ var header = (row.textContent || '').trim();
40
+ if (header.indexOf('@@') !== 0) return;
41
+ var index = headerToIndex.get(header);
42
+ if (index === undefined) { index = base + local; headerToIndex.set(header, index); row.classList.add('hunk'); row.id = 'hunk-' + index; local += 1; }
43
+ else { row.classList.add('hunk-peer'); }
44
+ row.dataset.hunkIndex = String(index);
45
+ row.dataset.file = fileName;
46
+ });
47
+ }
48
+ var bodyCache = {}; // file index -> diff body html (lazy-LOAD cache)
49
+ var bodyPromise = {}; // file index -> Promise that resolves once the body is materialized
50
+ function loadBodyHtml(index) {
51
+ if (bodyCache[index] != null) return Promise.resolve(bodyCache[index]);
52
+ var p;
53
+ if (typeof window !== 'undefined' && window.monacoriFile && typeof window.monacoriFile.get === 'function') {
54
+ p = Promise.resolve().then(function () { return window.monacoriFile.get(Number(index), 'diff'); });
55
+ } else if (typeof fetch !== 'undefined') {
56
+ p = fetch('file?index=' + index).then(function (r) { return r.ok ? r.text() : ''; });
57
+ } else {
58
+ p = Promise.resolve('');
59
+ }
60
+ return p.then(function (html) { bodyCache[index] = html || ''; return bodyCache[index]; }, function () { bodyCache[index] = ''; return ''; });
61
+ }
62
+ function materializeBody(wrapper, html) {
63
+ var body = wrapper.querySelector('.d2h-files-diff[data-lazy]');
64
+ if (!body) return;
65
+ body.innerHTML = html || '';
66
+ body.removeAttribute('data-lazy');
67
+ body.removeAttribute('data-loading');
68
+ markWrapperHunks(wrapper);
69
+ if (diffBootDone && typeof reviewComments !== 'undefined' && reviewComments.length) { try { refreshComments(); } catch (e) {} }
70
+ }
71
+ // Materialize a lazily-emitted file body. Phase 1 reads it from an inert embedded island (sync);
72
+ // Phase 2 lazy-LOAD fetches it on demand (async) — callers that then need rows must use whenFileReady().
73
+ function ensureFileReady(wrapper) {
74
+ if (!wrapper) return null;
75
+ var body = wrapper.querySelector('.d2h-files-diff[data-lazy]');
76
+ if (!body) return wrapper; // already materialized (or eager mode)
77
+ var idx = (wrapper.id || '').replace('file-', '');
78
+ if (REVIEW_LAZY_LOAD) {
79
+ if (!bodyPromise[idx]) {
80
+ body.setAttribute('data-loading', '1');
81
+ bodyPromise[idx] = loadBodyHtml(idx).then(function (html) { materializeBody(wrapper, html); return wrapper; });
82
+ }
83
+ return wrapper;
84
+ }
85
+ var island = document.getElementById('diff-body-' + idx);
86
+ if (island) materializeBody(wrapper, island.textContent || '');
87
+ return wrapper;
88
+ }
89
+ // Run cb once the wrapper's body is materialized — synchronously when it already is (eager / Phase 1
90
+ // island / cached), or after the fetch resolves (cold lazy-LOAD). Lets navigation stay correct without
91
+ // turning every caller async.
92
+ function whenFileReady(wrapper, cb) {
93
+ if (!wrapper) { cb(); return; }
94
+ ensureFileReady(wrapper);
95
+ var body = wrapper.querySelector('.d2h-files-diff');
96
+ if (!body || !body.hasAttribute('data-lazy')) { cb(); return; }
97
+ var idx = (wrapper.id || '').replace('file-', '');
98
+ if (bodyPromise[idx]) { bodyPromise[idx].then(function () { cb(); }); return; }
99
+ cb();
100
+ }
101
+ function setupLazyDiff() {
102
+ var container = document.getElementById('diff2html-container');
103
+ if (!container) return;
104
+ var wrappers = Array.prototype.slice.call(container.querySelectorAll('.d2h-file-wrapper'));
105
+ if (typeof IntersectionObserver !== 'undefined') {
106
+ var io = new IntersectionObserver(function (entries) {
107
+ entries.forEach(function (e) { if (e.isIntersecting) { ensureFileReady(e.target); io.unobserve(e.target); } });
108
+ }, { root: null, rootMargin: '600px 0px' });
109
+ wrappers.forEach(function (w) { io.observe(w); });
110
+ } else {
111
+ wrappers.forEach(function (w) { ensureFileReady(w); }); // no IntersectionObserver -> materialize all
112
+ }
113
+ if (wrappers[0]) ensureFileReady(wrappers[0]); // first file ready so the initial caret has a row to land on
114
+ }
115
+ if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
116
+ const links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
117
+ let sourceLinks = Array.from(document.querySelectorAll('.source-link')); // re-captured when a deferred tree materializes
118
+ const sourceFiles = JSON.parse(document.getElementById('source-files-data')?.textContent || '[]');
119
+ const fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
120
+ const httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
121
+ const httpEnvNames = Object.keys(httpEnvironments);
122
+ const httpEnvKey = 'monacori-http-env:' + location.pathname;
123
+ const httpRequestsByPath = new Map();
124
+ const httpVarsByPath = new Map();
125
+ const sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
126
+ // Phase 2b lazy-LOAD: source content is fetched once after first paint (serve /source-data or the
127
+ // Electron bridge) and merged into the metadata-only source records; until then sourceLoaded is false
128
+ // and the source view shows a brief loading state. Non-lazy-load modes embed source -> already loaded.
129
+ var sourceLoaded = !REVIEW_LAZY_LOAD;
130
+ var pendingSourceOpen = null;
131
+ var sourceLoading = false;
132
+ var pendingSymbol = null;
133
+ // The source blob (content + image base64) is large on big repos, so lazy-LOAD fetches it lazily — on
134
+ // the first source-view open or go-to-definition — not eagerly at startup. Idempotent.
135
+ function loadSourceData() {
136
+ if (sourceLoaded || sourceLoading) return;
137
+ sourceLoading = true;
138
+ var p;
139
+ if (typeof window !== 'undefined' && window.monacoriFile && typeof window.monacoriFile.getSourceData === 'function') {
140
+ p = Promise.resolve().then(function () { return window.monacoriFile.getSourceData(); });
141
+ } else if (typeof fetch !== 'undefined') {
142
+ p = fetch('source-data').then(function (r) { return r.ok ? r.text() : '[]'; });
143
+ } else {
144
+ p = Promise.resolve('[]');
145
+ }
146
+ p.then(function (text) {
147
+ var data = [];
148
+ try { data = JSON.parse(text || '[]'); } catch (e) { data = []; }
149
+ for (var i = 0; i < data.length; i++) {
150
+ var existing = sourceByPath.get(data[i].path);
151
+ if (existing) { existing.content = data[i].content; if (data[i].image) existing.image = data[i].image; }
152
+ }
153
+ sourceLoaded = true;
154
+ sourceLoading = false;
155
+ try { startSymbolIndex(); } catch (e) {}
156
+ if (pendingSourceOpen) { var po = pendingSourceOpen; pendingSourceOpen = null; openSourceFile(po.path, po.shouldSwitch); }
157
+ else if (isSourceViewerVisible() && document.getElementById('source-viewer').dataset.openPath) { openSourceFile(document.getElementById('source-viewer').dataset.openPath, false); }
158
+ if (pendingSymbol) { var s = pendingSymbol; pendingSymbol = null; goToDefOrUsages(s); }
159
+ }, function () { sourceLoaded = true; sourceLoading = false; });
160
+ }
161
+ const fileSignatureByPath = new Map(fileStates.map((file) => [file.path, file.signature]));
162
+ const reviewMeta = document.getElementById('review-meta');
163
+ const watchEnabled = reviewMeta?.dataset.watch === 'true';
164
+ const currentSignature = reviewMeta?.dataset.signature || '';
165
+ const uiStateKey = 'monacori-diff-ui:' + location.pathname;
166
+ const recentKey = 'monacori-diff-recent:' + location.pathname;
167
+ const viewedKey = 'monacori-diff-viewed:' + location.pathname;
168
+ const quickOpen = document.getElementById('quick-open');
169
+ const quickInput = document.getElementById('quick-open-input');
170
+ const quickResults = document.getElementById('quick-open-results');
171
+ const quickModeLabel = document.getElementById('quick-open-mode');
172
+ let current = -1;
173
+ let checkingForUpdates = false;
174
+ let lastShiftAt = 0;
175
+ let lastShiftSide = 0;
176
+ let quickMode = 'all';
177
+ let quickItems = [];
178
+ let quickActive = 0;
179
+ let usageItems = []; // find-usages results for the Cmd+B-on-declaration popup
180
+ let usageActive = 0;
181
+ let viewerCursor = null;
182
+ let selectedCommentRow = null; // a comment box "selected" while navigating with arrows (caret hidden); Backspace deletes it
183
+ let currentHttpEnvName = (function () {
184
+ let saved = '';
185
+ try { saved = localStorage.getItem(httpEnvKey) || ''; } catch (error) { saved = ''; }
186
+ if (saved && httpEnvNames.indexOf(saved) >= 0) return saved;
187
+ return httpEnvNames.length ? httpEnvNames[0] : '';
188
+ })();
189
+ let treeFocusIndex = -1;
190
+ let selectionAnchor = null;
191
+ let diffCursor = null; // { path, side: 'old'|'new', rowIndex, column } — keyboard caret in the side-by-side diff
192
+ // Cursor-position history for Cmd/Ctrl+[ (back) and Cmd/Ctrl+] (forward), IDE-style.
193
+ let navList = [];
194
+ let navPos = -1;
195
+ let navSuppress = false;
196
+ var NAV_JUMP_LINES = 8;
197
+ var NAV_MAX = 60;
198
+ let diffSelectionAnchor = null; // { side, rowIndex, column } — Shift+Arrow drag-select origin in the diff
199
+ let measuredCharWidth = 0;
200
+
201
+ // Review-comment state — initialized here (early) so saved comments are loaded before
202
+ // restoreUiState()/openDefaultSourceFile() run on startup and try to render them.
203
+ var COMMENTS_KEY = 'monacori-comments:' + location.pathname;
204
+ var reviewComments = [];
205
+ try { reviewComments = JSON.parse(localStorage.getItem(COMMENTS_KEY) || '[]'); } catch (commentsErr) { reviewComments = []; }
206
+ if (!Array.isArray(reviewComments)) reviewComments = [];
207
+ var commentSeq = reviewComments.reduce(function (max, c) { return Math.max(max, c.seq || 0); }, 0);
208
+ var composerState = null;
209
+
210
+ function prepareDiff2HtmlHunks() {
211
+ const wrappers = Array.from(document.querySelectorAll('.d2h-file-wrapper'));
212
+ let globalHunkIndex = 0;
213
+ wrappers.forEach((wrapper, fileIndex) => {
214
+ wrapper.id = 'file-' + fileIndex;
215
+ const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
216
+ const headerToIndex = new Map();
217
+ const rows = Array.from(wrapper.querySelectorAll('tr'));
218
+ rows.forEach((row) => {
219
+ const header = row.textContent.trim();
220
+ if (!header.startsWith('@@')) return;
221
+ let index = headerToIndex.get(header);
222
+ if (index === undefined) {
223
+ index = globalHunkIndex;
224
+ headerToIndex.set(header, index);
225
+ row.classList.add('hunk');
226
+ row.id = 'hunk-' + index;
227
+ globalHunkIndex += 1;
228
+ } else {
229
+ row.classList.add('hunk-peer');
230
+ }
231
+ row.dataset.hunkIndex = String(index);
232
+ row.dataset.file = fileName;
233
+ });
234
+ });
235
+ }
236
+
237
+ prepareViewedControls();
238
+
239
+ function prepareViewedControls() {
240
+ pruneViewedState();
241
+ document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
242
+ const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
243
+ const toggle = wrapper.querySelector('.d2h-file-collapse');
244
+ const input = toggle?.querySelector('input');
245
+ if (!fileName || !toggle || !input) return;
246
+ toggle.title = 'Toggle viewed (<)';
247
+ input.tabIndex = -1;
248
+ toggle.addEventListener('click', (event) => {
249
+ event.preventDefault();
250
+ setFileViewed(fileName, !isFileViewed(fileName));
251
+ });
252
+ });
253
+ applyViewedState();
254
+ }
255
+
256
+ function loadViewedState() {
257
+ try {
258
+ const value = JSON.parse(localStorage.getItem(viewedKey) || '{}');
259
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
260
+ } catch {
261
+ return {};
262
+ }
263
+ }
264
+
265
+ function saveViewedState(value) {
266
+ try {
267
+ localStorage.setItem(viewedKey, JSON.stringify(value));
268
+ } catch {}
269
+ }
270
+
271
+ function currentFileSignature(path) {
272
+ return fileSignatureByPath.get(path) || '';
273
+ }
274
+
275
+ function isFileViewed(path) {
276
+ const viewed = loadViewedState();
277
+ const signature = currentFileSignature(path);
278
+ return Boolean(signature && viewed[path] === signature);
279
+ }
280
+
281
+ function setFileViewed(path, viewed) {
282
+ const state = loadViewedState();
283
+ if (viewed) {
284
+ const signature = currentFileSignature(path);
285
+ if (signature) state[path] = signature;
286
+ } else {
287
+ delete state[path];
288
+ }
289
+ saveViewedState(state);
290
+ applyViewedState();
291
+ }
292
+
293
+ function pruneViewedState() {
294
+ const state = loadViewedState();
295
+ let changed = false;
296
+ Object.keys(state).forEach((path) => {
297
+ if (state[path] !== currentFileSignature(path)) {
298
+ delete state[path];
299
+ changed = true;
300
+ }
301
+ });
302
+ if (changed) saveViewedState(state);
303
+ }
304
+
305
+ function applyViewedState() {
306
+ document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
307
+ const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
308
+ const viewed = isFileViewed(fileName);
309
+ wrapper.classList.toggle('file-viewed', viewed);
310
+ const checkbox = wrapper.querySelector('.d2h-file-collapse-input');
311
+ if (checkbox) checkbox.checked = viewed;
312
+ });
313
+ // Viewed is a diff-review concept: only the Changes list shows it, not the Files/source tree.
314
+ links.forEach((link) => {
315
+ link.classList.toggle('viewed', isFileViewed(link.dataset.file || ''));
316
+ });
317
+ updateDiffViewedToggle();
318
+ }
319
+
320
+ // The diff file header is merged into the toolbar; this reflects the active file's viewed state there.
321
+ function updateDiffViewedToggle() {
322
+ var btn = document.getElementById('diff-viewed-toggle');
323
+ if (!btn) return;
324
+ var path = btn.dataset.file || '';
325
+ var known = Boolean(path && currentFileSignature(path));
326
+ btn.hidden = !known;
327
+ if (!known) return;
328
+ var viewed = isFileViewed(path);
329
+ btn.classList.toggle('is-viewed', viewed);
330
+ btn.setAttribute('aria-pressed', viewed ? 'true' : 'false');
331
+ }
332
+
333
+ let activeDiffRow = null;
334
+ function firstCodeRowOfHunk(hunkRow) {
335
+ let row = hunkRow.nextElementSibling;
336
+ let firstRow = null;
337
+ while (row && !row.classList.contains('hunk') && !row.classList.contains('hunk-peer')) {
338
+ if (row.querySelector && row.querySelector('.d2h-code-side-line')) {
339
+ if (!firstRow) firstRow = row;
340
+ if (row.querySelector('.d2h-ins, .d2h-del, ins, del')) return row;
341
+ }
342
+ row = row.nextElementSibling;
343
+ }
344
+ return firstRow || hunkRow;
345
+ }
346
+
347
+ // First row in a hunk to land the caret on. F7 should track the NEW (right) file, so prefer the
348
+ // first change on the new side anywhere in the hunk (additions / modifications) and only fall back
349
+ // to the old side for a pure-deletion hunk that has nothing on the new side. The .hunk marker sits
350
+ // on the OLD side and the two side tables are positionally aligned row-for-row, so the new-side row
351
+ // at the same index is the counterpart. Without this, a hunk that begins with deletions lands the
352
+ // caret on the old-side deletion instead of the added lines below it.
353
+ function isChangeCodeRow(row) {
354
+ return !!(row && isDiffCodeRow(row) && row.querySelector('.d2h-ins, .d2h-del, ins, del'));
355
+ }
356
+ function firstChangeRowForCaret(hunkRow) {
357
+ const wrapper = hunkRow.closest('.d2h-file-wrapper');
358
+ const sides = wrapper ? wrapper.querySelectorAll('.d2h-file-side-diff') : [];
359
+ const hunkSideEl = hunkRow.closest('.d2h-file-side-diff');
360
+ if (sides.length >= 2 && hunkSideEl) {
361
+ const hunkRows = Array.from(hunkSideEl.querySelectorAll('tr')); // old side (carries the .hunk marker)
362
+ const otherEl = hunkSideEl === sides[0] ? sides[1] : sides[0]; // new side
363
+ const otherRows = Array.from(otherEl.querySelectorAll('tr'));
364
+ let fallbackOld = null;
365
+ for (let i = hunkRows.indexOf(hunkRow) + 1; i < hunkRows.length; i++) {
366
+ const hr = hunkRows[i];
367
+ if (hr.classList.contains('hunk') || hr.classList.contains('hunk-peer')) break;
368
+ if (isChangeCodeRow(otherRows[i])) return otherRows[i]; // first new-side change wins (track the new file)
369
+ if (fallbackOld === null && isChangeCodeRow(hr)) fallbackOld = hr; // remember the first old-side change
370
+ }
371
+ if (fallbackOld) return fallbackOld; // pure-deletion hunk: nothing added, so land on the deletion
372
+ }
373
+ return firstCodeRowOfHunk(hunkRow);
374
+ }
375
+ function focusDiffRow(row) {
376
+ if (activeDiffRow) activeDiffRow.classList.remove('diff-active-row');
377
+ activeDiffRow = row || null;
378
+ if (!row) return;
379
+ row.classList.add('diff-active-row');
380
+ // move the diff caret to follow hunk navigation (F7 / Shift+F7 / [ / ])
381
+ const navInfo = diffRowInfoFromNode(row);
382
+ if (navInfo && navInfo.path) {
383
+ let navSide = navInfo.side;
384
+ if (navSide === 'old') { // prefer the new (modified) side when it has a real line at this row
385
+ const navWrap = diffWrapperByPath(navInfo.path);
386
+ if (isDiffCodeRow(navWrap ? diffRowAt(navWrap, 'new', navInfo.rowIndex) : null)) navSide = 'new';
387
+ }
388
+ setDiffCursor(navInfo.path, navSide, navInfo.rowIndex, 0, false);
389
+ }
390
+ }
391
+
392
+ function renderBreadcrumb(container, path) {
393
+ if (!container) return;
394
+ container.textContent = '';
395
+ const parts = (path || '').split('/').filter(Boolean);
396
+ parts.forEach((seg, i) => {
397
+ if (i > 0) {
398
+ const sep = document.createElement('span');
399
+ sep.className = 'crumb-sep';
400
+ sep.textContent = '›';
401
+ container.appendChild(sep);
402
+ }
403
+ const span = document.createElement('span');
404
+ span.className = i === parts.length - 1 ? 'crumb crumb-leaf' : 'crumb';
405
+ span.textContent = seg;
406
+ container.appendChild(span);
407
+ });
408
+ }
409
+
410
+ function setActive(index, shouldScroll = true) {
411
+ if (hunkTotal() === 0) return;
412
+ current = ((index % hunkTotal()) + hunkTotal()) % hunkTotal();
413
+ document.getElementById('source-viewer')?.classList.add('hidden');
414
+ document.getElementById('diff-view')?.classList.remove('hidden');
415
+ setTab('changes');
416
+ const file = hunkPathAt(current);
417
+ const idx = current;
418
+ links.forEach((link) => link.classList.toggle('active', link.dataset.file === file));
419
+ renderBreadcrumb(document.getElementById('diff-breadcrumb'), file);
420
+ var dvt = document.getElementById('diff-viewed-toggle');
421
+ if (dvt) dvt.dataset.file = file || '';
422
+ updateDiffViewedToggle();
423
+ if (file) rememberRecent(file, 'change');
424
+ history.replaceState(null, '', '#hunk-' + idx);
425
+ // Row-dependent work waits for the file body (sync for eager/Phase 1, async for cold lazy-LOAD).
426
+ whenFileReady(diffWrapperByPath(file), function () {
427
+ showOnlyFile(file);
428
+ const active = document.getElementById('hunk-' + idx);
429
+ if (!active) return;
430
+ if (REVIEW_LAZY) {
431
+ document.querySelectorAll('#diff2html-container .hunk.active, #diff2html-container .hunk-peer.active').forEach((h) => h.classList.remove('active'));
432
+ document.querySelectorAll('#diff2html-container [data-hunk-index="' + idx + '"]').forEach((h) => h.classList.add('active'));
433
+ } else {
434
+ hunks.forEach((hunk, i) => hunk.classList.toggle('active', i === idx));
435
+ hunkPeers.forEach((hunk) => hunk.classList.toggle('active', Number(hunk.dataset.hunkIndex) === idx));
436
+ }
437
+ const targetRow = firstChangeRowForCaret(active);
438
+ // F7/change navigation moves the caret but must NOT pollute the Cmd+[/] cursor history.
439
+ navSuppress = true;
440
+ try { focusDiffRow(targetRow); } finally { navSuppress = false; }
441
+ if (shouldScroll && targetRow) targetRow.scrollIntoView({ block: 'center' });
442
+ });
443
+ }
444
+
445
+ function showOnlyFile(fileName) {
446
+ if (REVIEW_LAZY) ensureFileReady(diffWrapperByPath(fileName));
447
+ document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
448
+ const name = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
449
+ wrapper.classList.toggle('df-inactive', name !== fileName);
450
+ });
451
+ ensureDiffCursor();
452
+ }
453
+
454
+ // The hunk the diff caret currently sits in. Arrow keys move the caret without touching the active
455
+ // index (the F7 anchor), so navigation must read the caret's real position -- otherwise pressing F7
456
+ // after arrowing to the bottom of a file re-treads hunks already passed instead of going to the next file.
457
+ function hunkIndexAtCaret() {
458
+ if (!diffCursor) return -1;
459
+ const wrapper = diffWrapperByPath(diffCursor.path);
460
+ if (!wrapper) return -1;
461
+ const caretRow = diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex);
462
+ const sideEl = caretRow ? caretRow.closest('.d2h-file-side-diff') : null;
463
+ if (!sideEl) return -1;
464
+ let found = -1;
465
+ // @@ markers on the caret's side carry data-hunk-index; the nearest one at or above the caret wins.
466
+ sideEl.querySelectorAll('[data-hunk-index]').forEach((marker) => {
467
+ if (marker === caretRow || (caretRow.compareDocumentPosition(marker) & Node.DOCUMENT_POSITION_PRECEDING)) {
468
+ found = Number(marker.dataset.hunkIndex);
469
+ }
470
+ });
471
+ return found;
472
+ }
473
+
474
+ // New-side row indices, one per change block — a run of change rows (ins/del) separated by context.
475
+ // A wide context window merges several edits into one @@ hunk; stepping by these stops at each edit.
476
+ function changeBlockAnchors(wrapper) {
477
+ var right = diffSideTables(wrapper).right;
478
+ if (!right) return [];
479
+ var rows = diffRowsOf(right);
480
+ var anchors = [];
481
+ var prev = false;
482
+ for (var i = 0; i < rows.length; i++) {
483
+ var chg = isChangeCodeRow(rows[i]);
484
+ if (chg && !prev) anchors.push(i);
485
+ prev = chg;
486
+ }
487
+ return anchors;
488
+ }
489
+
490
+ function next(delta) {
491
+ if (hunkTotal() === 0) return;
492
+ // Within the caret's (unviewed) file, step change-block by change-block so a context-merged hunk
493
+ // (several separate edits under one @@) stops at every edit instead of skipping to the next file.
494
+ if (diffCursor && isDiffViewVisible()) {
495
+ const w = diffWrapperByPath(diffCursor.path);
496
+ if (w && !isFileViewed(diffCursor.path)) {
497
+ const anchors = changeBlockAnchors(w);
498
+ const cur = diffCursor.rowIndex;
499
+ let target = null;
500
+ if (delta > 0) { for (let a = 0; a < anchors.length; a++) { if (anchors[a] > cur) { target = anchors[a]; break; } } }
501
+ else { for (let b = anchors.length - 1; b >= 0; b--) { if (anchors[b] < cur) { target = anchors[b]; break; } } }
502
+ if (target != null) {
503
+ const row = diffRowAt(w, 'new', target);
504
+ if (row) { navSuppress = true; try { focusDiffRow(row); } finally { navSuppress = false; } row.scrollIntoView({ block: 'center' }); return; }
505
+ }
506
+ }
507
+ }
508
+ // File boundary (no more change blocks this file) → hunk-level nav to the next/prev unviewed file.
509
+ const caretHunk = hunkIndexAtCaret();
510
+ const base = caretHunk >= 0 ? caretHunk : current;
511
+ let idx = base < 0 ? initialHunkForNavigation(delta) : base + delta;
512
+ for (let step = 0; step < hunkTotal(); step++) {
513
+ const norm = ((idx % hunkTotal()) + hunkTotal()) % hunkTotal();
514
+ if (!isFileViewed(hunkPathAt(norm) || '')) { setActive(norm); return; }
515
+ idx += delta;
516
+ }
517
+ // Every changed file is marked viewed — nothing left to review, so F7/[/] stay put.
518
+ }
519
+
520
+ function initialHunkForNavigation(delta) {
521
+ const openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
522
+ const sourceHunk = firstHunkForPath(openPath);
523
+ if (sourceHunk >= 0) return sourceHunk;
524
+ return delta < 0 ? hunkTotal() - 1 : 0;
525
+ }
526
+
527
+ function firstHunkForPath(path) {
528
+ if (!path) return -1;
529
+ const link = links.find((candidate) => candidate.dataset.file === path);
530
+ if (!link) return -1;
531
+ const index = Number(link.dataset.hunk);
532
+ return Number.isNaN(index) ? -1 : index;
533
+ }
534
+
535
+ function openQuickOpen(mode) {
536
+ if (!quickOpen || !quickInput || !quickModeLabel) return;
537
+ quickMode = mode;
538
+ quickModeLabel.textContent = mode === 'recent' ? 'Recent files' : mode === 'content' ? 'Find in Files' : 'Search files';
539
+ quickOpen.classList.remove('hidden');
540
+ quickInput.value = '';
541
+ renderQuickOpenResults();
542
+ setTimeout(() => quickInput.focus(), 0);
543
+ }
544
+
545
+ function closeQuickOpen() {
546
+ quickOpen?.classList.add('hidden');
547
+ }
548
+
549
+ function handleQuickOpenKey(event) {
550
+ if (event.key === 'Escape') {
551
+ event.preventDefault();
552
+ closeQuickOpen();
553
+ return true;
554
+ }
555
+ if (event.key === 'ArrowDown') {
556
+ event.preventDefault();
557
+ quickActive = Math.min(quickActive + 1, Math.max(quickItems.length - 1, 0));
558
+ updateQuickActive();
559
+ return true;
560
+ }
561
+ if (event.key === 'ArrowUp') {
562
+ event.preventDefault();
563
+ quickActive = Math.max(quickActive - 1, 0);
564
+ updateQuickActive();
565
+ return true;
566
+ }
567
+ if (event.key === 'Enter') {
568
+ event.preventDefault();
569
+ openQuickItem(quickItems[quickActive]);
570
+ return true;
571
+ }
572
+ return false;
573
+ }
574
+
575
+ function renderQuickOpenResults() {
576
+ if (!quickResults) return;
577
+ const query = quickInput?.value.trim().toLowerCase() || '';
578
+ const candidates = quickMode === 'recent' && query.length === 0 ? recentItems() : allQuickItems();
579
+ quickItems = candidates
580
+ .filter((item) => quickMode !== 'recent' || query.length > 0 || item.recent)
581
+ .filter((item) => {
582
+ if (query.length === 0) return true;
583
+ if (quickMode === 'content') {
584
+ const file = sourceByPath.get(item.path);
585
+ return Boolean(file && file.embedded && file.content.toLowerCase().includes(query));
586
+ }
587
+ return (item.path + '\n' + item.name + '\n' + item.detail).toLowerCase().includes(query);
588
+ })
589
+ .sort((a, b) => scoreQuickItem(a, query) - scoreQuickItem(b, query) || a.path.localeCompare(b.path))
590
+ .slice(0, 80);
591
+ quickActive = Math.min(quickActive, Math.max(quickItems.length - 1, 0));
592
+ if (quickItems.length === 0) {
593
+ quickResults.innerHTML = '<div class="quick-open-empty">No files found.</div>';
594
+ return;
595
+ }
596
+ quickResults.innerHTML = quickItems.map((item, index) => [
597
+ '<button type="button" class="quick-open-item' + (index === quickActive ? ' active' : '') + '" data-index="' + index + '">',
598
+ '<span class="quick-open-main">',
599
+ '<span class="quick-open-name">' + escapeHtml(item.name) + '</span>',
600
+ '<span class="quick-open-path">' + escapeHtml(item.path) + '</span>',
601
+ '</span>',
602
+ '<span class="quick-open-badge">' + escapeHtml(item.detail) + '</span>',
603
+ '</button>',
604
+ ].join('')).join('');
605
+ renderQuickPreview(quickItems[quickActive]);
606
+ }
607
+
608
+ function updateQuickActive() {
609
+ quickResults?.querySelectorAll('.quick-open-item').forEach((element, index) => {
610
+ const active = index === quickActive;
611
+ element.classList.toggle('active', active);
612
+ if (active) element.scrollIntoView({ block: 'nearest' });
613
+ });
614
+ renderQuickPreview(quickItems[quickActive]);
615
+ }
616
+
617
+ function renderQuickPreview(item) {
618
+ const preview = document.getElementById('quick-open-preview');
619
+ if (!preview) return;
620
+ if (!item) { preview.innerHTML = ''; return; }
621
+ const file = sourceByPath.get(item.path);
622
+ if (!file || !file.embedded) {
623
+ preview.innerHTML = '<div class="qp-empty">' + escapeHtml(item.path) + '</div>';
624
+ return;
625
+ }
626
+ const query = ((quickInput && quickInput.value) || '').trim().toLowerCase();
627
+ const lines = file.content.split(/\r?\n/);
628
+ let firstHit = -1;
629
+ const rows = lines.map((line, i) => {
630
+ const hit = query.length > 0 && line.toLowerCase().includes(query);
631
+ if (hit && firstHit < 0) firstHit = i;
632
+ return '<div class="qp-line' + (hit ? ' qp-hit' : '') + '"><span class="qp-num">' + (i + 1) + '</span><span class="qp-code">' + highlightLine(line, file.language || 'text') + '</span></div>';
633
+ }).join('');
634
+ preview.innerHTML = '<div class="qp-head">' + escapeHtml(item.path) + '</div><div class="qp-body">' + rows + '</div>';
635
+ if (firstHit >= 0) {
636
+ const target = preview.querySelectorAll('.qp-line')[firstHit];
637
+ if (target) target.scrollIntoView({ block: 'center' });
638
+ }
639
+ }
640
+
641
+ function openQuickItem(item) {
642
+ if (!item) return;
643
+ closeQuickOpen();
644
+ rememberRecent(item.path, item.kind);
645
+ if (sourceByPath.has(item.path)) {
646
+ openSourceFile(item.path);
647
+ return;
648
+ }
649
+ const link = links.find((candidate) => candidate.dataset.file === item.path);
650
+ if (!link) return;
651
+ const target = Number(link.dataset.hunk);
652
+ if (!Number.isNaN(target) && target >= 0 && target < hunkTotal()) {
653
+ setActive(target);
654
+ } else {
655
+ showDiffView(false);
656
+ const targetId = link.getAttribute('href')?.slice(1);
657
+ if (targetId) document.getElementById(targetId)?.scrollIntoView({ block: 'center' });
658
+ }
659
+ }
660
+
661
+ function allQuickItems() {
662
+ const items = sourceFiles.map((file) => ({
663
+ path: file.path,
664
+ name: baseName(file.path),
665
+ detail: [file.changed ? 'changed' : 'file', file.language || 'text'].join(' - '),
666
+ kind: 'source',
667
+ recent: false,
668
+ }));
669
+ links.forEach((link) => {
670
+ const path = link.dataset.file || '';
671
+ if (!path || sourceByPath.has(path)) return;
672
+ items.push({ path, name: baseName(path), detail: 'diff', kind: 'change', recent: false });
673
+ });
674
+ const recent = loadRecent();
675
+ const recentRank = new Map(recent.map((item, index) => [item.path, index]));
676
+ return items.map((item) => ({
677
+ ...item,
678
+ recent: recentRank.has(item.path),
679
+ recentRank: recentRank.get(item.path) ?? 9999,
680
+ }));
681
+ }
682
+
683
+ function recentItems() {
684
+ const all = allQuickItems();
685
+ const byPath = new Map(all.map((item) => [item.path, item]));
686
+ return loadRecent()
687
+ .map((item) => byPath.get(item.path) || {
688
+ path: item.path,
689
+ name: baseName(item.path),
690
+ detail: item.kind === 'change' ? 'diff' : 'file',
691
+ kind: item.kind,
692
+ recent: true,
693
+ recentRank: 0,
694
+ })
695
+ .map((item, index) => ({ ...item, recent: true, recentRank: index }));
696
+ }
697
+
698
+ function scoreQuickItem(item, query) {
699
+ let score = item.recentRank ?? 9999;
700
+ if (!query) return score;
701
+ const path = item.path.toLowerCase();
702
+ const name = item.name.toLowerCase();
703
+ if (name === query) score -= 3000;
704
+ else if (name.startsWith(query)) score -= 2000;
705
+ else if (path.includes('/' + query)) score -= 1000;
706
+ else if (path.includes(query)) score -= 500;
707
+ if (item.recent) score -= 100;
708
+ return score;
709
+ }
710
+
711
+ function loadRecent() {
712
+ try {
713
+ const value = JSON.parse(localStorage.getItem(recentKey) || '[]');
714
+ return Array.isArray(value) ? value.filter((item) => item && typeof item.path === 'string') : [];
715
+ } catch {
716
+ return [];
717
+ }
718
+ }
719
+
720
+ function rememberRecent(path, kind) {
721
+ if (!path) return;
722
+ const next = [{ path, kind }, ...loadRecent().filter((item) => item.path !== path)].slice(0, 30);
723
+ try {
724
+ localStorage.setItem(recentKey, JSON.stringify(next));
725
+ } catch {}
726
+ }
727
+
728
+ function baseName(path) {
729
+ return String(path).split('/').filter(Boolean).pop() || String(path);
730
+ }
731
+
732
+ // A tree row is navigable only when it is actually visible — i.e. not tucked inside a collapsed
733
+ // <details> folder. getClientRects alone is unreliable here: Chromium keeps collapsed <details>
734
+ // content laid out (content-visibility), so its descendants still report rects. Walk the ancestor
735
+ // <details> and treat anything inside a closed one (other than its own summary) as hidden.
736
+ function isTreeRowVisible(el) {
737
+ var node = el;
738
+ while (node) {
739
+ var parent = node.parentElement;
740
+ if (!parent || parent.classList.contains('tab-panel')) return true;
741
+ if (parent.tagName === 'DETAILS' && !parent.open && node.tagName !== 'SUMMARY') return false;
742
+ node = parent;
743
+ }
744
+ return true;
745
+ }
746
+ function treeRows() {
747
+ const panel = document.querySelector('.tab-panel:not(.hidden)');
748
+ if (!panel) return [];
749
+ return Array.from(panel.querySelectorAll('summary, .file-link')).filter((el) => el.getClientRects().length > 0 && isTreeRowVisible(el));
750
+ }
751
+
752
+ function focusTree(index) {
753
+ const rows = treeRows();
754
+ if (rows.length === 0) return;
755
+ treeFocusIndex = Math.max(0, Math.min(rows.length - 1, index));
756
+ rows.forEach((row, i) => row.classList.toggle('tree-focus', i === treeFocusIndex));
757
+ const el = rows[treeFocusIndex];
758
+ if (el) el.scrollIntoView({ block: 'nearest' });
759
+ }
760
+
761
+ function clearTreeFocus() {
762
+ treeFocusIndex = -1;
763
+ document.querySelectorAll('.tree-focus').forEach((el) => el.classList.remove('tree-focus'));
764
+ }
765
+
766
+ // Focus the tree row for the currently open file (source openPath, else the active diff file);
767
+ // falls back to the first row when nothing is open or no matching row exists.
768
+ function focusOpenFileInTree() {
769
+ const rows = treeRows();
770
+ if (rows.length === 0) return;
771
+ let openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
772
+ if (!openPath && typeof diffActiveWrapper === 'function') {
773
+ const w = diffActiveWrapper();
774
+ const n = w && w.querySelector('.d2h-file-name');
775
+ if (n && n.textContent) openPath = n.textContent.trim();
776
+ }
777
+ let idx = 0;
778
+ if (openPath) {
779
+ for (let i = 0; i < rows.length; i++) {
780
+ const ds = rows[i].dataset || {};
781
+ if (ds.sourceFile === openPath || ds.file === openPath) { idx = i; break; }
782
+ }
783
+ }
784
+ focusTree(idx);
785
+ }
786
+
787
+ function treePageSize() {
788
+ var scroller = document.querySelector('.sidebar-scroll');
789
+ var h = scroller ? scroller.clientHeight : 320;
790
+ return Math.max(1, Math.floor(h / 20) - 1); // ~20px per tree row, minus one for overlap
791
+ }
792
+ function treeOpenKey() { return 'monacori-tree-open:' + location.pathname; }
793
+ function loadTreeOpen() { try { return new Set(JSON.parse(sessionStorage.getItem(treeOpenKey()) || '[]')); } catch (e) { return new Set(); } }
794
+ function saveTreeOpen(set) { try { sessionStorage.setItem(treeOpenKey(), JSON.stringify(Array.from(set))); } catch (e) {} }
795
+ // Folders start collapsed. Restore the folders the user manually opened, plus reveal the open file's
796
+ // path. Toggle listeners attach AFTER the initial state so the auto-revealed path is not mistaken for
797
+ // a user-opened folder (keeping "collapsed by default" intact on the next load).
798
+ var treeRevealing = false; // true while opening folders programmatically, so those opens are not persisted
799
+ function persistTreeToggle(d) {
800
+ var set = loadTreeOpen();
801
+ var dir = d.dataset.dir || '';
802
+ if (d.open) set.add(dir); else set.delete(dir);
803
+ saveTreeOpen(set);
804
+ }
805
+ function initSourceTreeFolds() {
806
+ var dirs = Array.prototype.slice.call(document.querySelectorAll('.source-dir'));
807
+ if (!dirs.length) return;
808
+ var saved = loadTreeOpen();
809
+ var openPath = (document.getElementById('source-viewer') && document.getElementById('source-viewer').dataset.openPath) || '';
810
+ // Only USER toggles persist; the initial state below is applied under treeRevealing so the open
811
+ // file's revealed path stays transient (folders stay "collapsed by default" on the next load).
812
+ dirs.forEach(function (d) {
813
+ d.addEventListener('toggle', function () { if (!treeRevealing) persistTreeToggle(d); });
814
+ });
815
+ treeRevealing = true;
816
+ dirs.forEach(function (d) {
817
+ var dir = d.dataset.dir || '';
818
+ var reveal = openPath && (openPath === dir || openPath.indexOf(dir + '/') === 0);
819
+ d.open = saved.has(dir) || !!reveal;
820
+ });
821
+ setTimeout(function () { treeRevealing = false; }, 0);
822
+ }
823
+ // Expand a file's ancestor folders so it is visible in the tree (transient — not persisted), then
824
+ // scroll its row into view. Called whenever a source file opens (tree click, go-to-definition, etc.).
825
+ function revealTreeFor(path) {
826
+ if (!path) return;
827
+ treeRevealing = true;
828
+ document.querySelectorAll('.source-dir').forEach(function (d) {
829
+ var dir = d.dataset.dir || '';
830
+ if (dir && (path === dir || path.indexOf(dir + '/') === 0) && !d.open) d.open = true;
831
+ });
832
+ setTimeout(function () { treeRevealing = false; }, 0);
833
+ var active = document.querySelector('.source-link.active');
834
+ if (active && active.scrollIntoView) active.scrollIntoView({ block: 'nearest' });
835
+ }
836
+ function handleTreeKey(event) {
837
+ const rows = treeRows();
838
+ if (rows.length === 0) return false;
839
+ if (treeFocusIndex >= rows.length) treeFocusIndex = rows.length - 1;
840
+ const row = rows[treeFocusIndex];
841
+ const isFolder = row && row.tagName === 'SUMMARY';
842
+ if (event.key === 'ArrowDown') { event.preventDefault(); focusTree(treeFocusIndex + 1); return true; }
843
+ if (event.key === 'ArrowUp') { event.preventDefault(); focusTree(treeFocusIndex - 1); return true; }
844
+ if (event.key === 'PageDown') { event.preventDefault(); focusTree(treeFocusIndex + treePageSize()); return true; }
845
+ if (event.key === 'PageUp') { event.preventDefault(); focusTree(treeFocusIndex - treePageSize()); return true; }
846
+ if (event.key === 'Enter') {
847
+ event.preventDefault();
848
+ if (row && row.classList.contains('file-link')) { row.click(); clearTreeFocus(); }
849
+ else if (isFolder && row.parentElement) row.parentElement.open = !row.parentElement.open;
850
+ return true;
851
+ }
852
+ if (event.key === 'ArrowRight') {
853
+ event.preventDefault();
854
+ if (isFolder && row.parentElement && !row.parentElement.open) row.parentElement.open = true;
855
+ else focusTree(treeFocusIndex + 1);
856
+ return true;
857
+ }
858
+ if (event.key === 'ArrowLeft') {
859
+ event.preventDefault();
860
+ if (isFolder && row.parentElement && row.parentElement.open) row.parentElement.open = false;
861
+ else focusTree(treeFocusIndex - 1);
862
+ return true;
863
+ }
864
+ if (event.key === 'Escape') { event.preventDefault(); clearTreeFocus(); return true; }
865
+ return false;
866
+ }
867
+
868
+ document.addEventListener('keydown', (event) => {
869
+ if (!quickOpen?.classList.contains('hidden')) {
870
+ if (handleQuickOpenKey(event)) return;
871
+ }
872
+ var usagesBox = document.getElementById('usages');
873
+ if (usagesBox && !usagesBox.classList.contains('hidden')) {
874
+ if (handleUsagesKey(event)) return;
875
+ }
876
+
877
+ if ((event.metaKey || event.ctrlKey) && event.key === '1') {
878
+ event.preventDefault();
879
+ setTab('files');
880
+ focusOpenFileInTree();
881
+ return;
882
+ }
883
+ if ((event.metaKey || event.ctrlKey) && event.key === '0') {
884
+ event.preventDefault();
885
+ setTab('changes');
886
+ focusOpenFileInTree();
887
+ return;
888
+ }
889
+
890
+ // Tab / Shift+Tab move the "cursor" horizontally between the left sidebar and the right content pane.
891
+ if (event.key === 'Tab') {
892
+ const activeEl = document.activeElement;
893
+ const inField = activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT');
894
+ if (!inField) {
895
+ event.preventDefault();
896
+ if (event.shiftKey) {
897
+ // In the diff view, Shift+Tab toggles the caret between the old/new panes (this change owns
898
+ // Shift+Tab L/R; plain arrows stay in-pane and Cmd/Ctrl+Arrows also cross — see diff nav).
899
+ if (isDiffViewVisible() && diffCursor) {
900
+ const tabSide = diffCursor.side === 'new' ? 'old' : 'new';
901
+ const tabWrap = diffWrapperByPath(diffCursor.path);
902
+ const tabRow = tabWrap ? diffRowAt(tabWrap, tabSide, diffCursor.rowIndex) : null;
903
+ if (isDiffCodeRow(tabRow)) setDiffCursor(diffCursor.path, tabSide, diffCursor.rowIndex, 0, true);
904
+ return;
905
+ }
906
+ focusTree(treeFocusIndex >= 0 ? treeFocusIndex : 0); // ← left: focus sidebar tree
907
+ } else {
908
+ clearTreeFocus(); // → right: hand focus back to the content pane (source caret / diff nav)
909
+ const openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
910
+ if (isSourceViewerVisible() && openPath && (!viewerCursor || viewerCursor.path !== openPath)) {
911
+ setSourceCursor(openPath, viewerCursor ? viewerCursor.lineIndex : 0, 0, false, -1);
912
+ }
913
+ }
914
+ return;
915
+ }
916
+ }
917
+
918
+ // Merged comment views — see every saved comment of one kind at once + copy-all to paste into a prompt:
919
+ // Cmd/Ctrl+Shift+/ ("?") = all questions, Cmd/Ctrl+Shift+. (">") = all change-requests.
920
+ // Match the PHYSICAL key (event.code) so macOS/IME/layout never swallows the combo; fires in any focus.
921
+ if ((event.metaKey || event.ctrlKey) && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
922
+ event.preventDefault();
923
+ openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
924
+ return;
925
+ }
926
+ // "?" = question, ">" = change-request composer on the current line/selection (no modifier).
927
+ if (!event.altKey && !event.metaKey && !event.ctrlKey && (event.key === '?' || event.key === '>')) {
928
+ const ce = document.activeElement;
929
+ const inEditable = ce && (ce.tagName === 'INPUT' || ce.tagName === 'TEXTAREA' || ce.tagName === 'SELECT');
930
+ if (!inEditable) {
931
+ event.preventDefault();
932
+ openComposer(event.key === '?' ? 'q' : 'c');
933
+ return;
934
+ }
935
+ }
936
+
937
+ // "<" (Shift+,) toggles "viewed" for the current file (source openPath, else active diff file).
938
+ if (!event.altKey && !event.metaKey && !event.ctrlKey && event.key === '<') {
939
+ const ce2 = document.activeElement;
940
+ const inEditable2 = ce2 && (ce2.tagName === 'INPUT' || ce2.tagName === 'TEXTAREA' || ce2.tagName === 'SELECT');
941
+ if (!inEditable2) {
942
+ let vp = isSourceViewerVisible() ? (document.getElementById('source-viewer')?.dataset.openPath || '') : '';
943
+ if (!vp && typeof diffActiveWrapper === 'function') {
944
+ const vw = diffActiveWrapper();
945
+ const vn = vw && vw.querySelector('.d2h-file-name');
946
+ if (vn && vn.textContent) vp = vn.textContent.trim();
947
+ }
948
+ if (vp && currentFileSignature(vp)) {
949
+ event.preventDefault();
950
+ setFileViewed(vp, !isFileViewed(vp));
951
+ return;
952
+ }
953
+ }
954
+ }
955
+
956
+ // Opt/Alt + Left/Right: word-wise caret jump (source or diff view).
957
+ if (event.altKey && !event.metaKey && !event.ctrlKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
958
+ var wae = document.activeElement;
959
+ var wInField = wae && (wae.tagName === 'INPUT' || wae.tagName === 'TEXTAREA' || wae.tagName === 'SELECT');
960
+ if (!wInField && treeFocusIndex < 0) {
961
+ var wdir = event.key === 'ArrowRight' ? 1 : -1;
962
+ if (isSourceViewerVisible() && viewerCursor) { event.preventDefault(); moveSourceWord(wdir, event.shiftKey); return; }
963
+ if (isDiffViewVisible() && diffCursor) { event.preventDefault(); moveDiffWord(wdir, event.shiftKey); return; }
964
+ }
965
+ }
966
+
967
+ if (treeFocusIndex >= 0 && handleTreeKey(event)) return;
968
+ if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isSourceViewerVisible() && handleSourceCaretKey(event)) return;
969
+ if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isDiffViewVisible() && handleDiffCaretKey(event)) return;
970
+
971
+ if (event.key === 'Shift' && !event.repeat) {
972
+ const now = performance.now();
973
+ // event.location: 1 = left Shift, 2 = right Shift, 0 = unspecified.
974
+ // Require the SAME physical side twice (left+right never counts) within a
975
+ // tight 300ms window so quick-open doesn't fire on accidental or mixed
976
+ // Shift presses. The side !== 0 guard keeps an unknown location from ever
977
+ // matching itself and triggering.
978
+ const side = event.location;
979
+ if (side !== 0 && side === lastShiftSide && now - lastShiftAt < 300) {
980
+ event.preventDefault();
981
+ lastShiftAt = 0;
982
+ lastShiftSide = 0;
983
+ openQuickOpen('all');
984
+ return;
985
+ }
986
+ lastShiftAt = now;
987
+ lastShiftSide = side;
988
+ }
989
+
990
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'f') {
991
+ event.preventDefault();
992
+ openQuickOpen('content');
993
+ return;
994
+ }
995
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'e') {
996
+ event.preventDefault();
997
+ openQuickOpen('recent');
998
+ return;
999
+ }
1000
+
1001
+ if ((event.metaKey || event.altKey) && event.key === 'Enter' && isSourceViewerVisible()) {
1002
+ const enterPath = document.getElementById('source-viewer')?.dataset.openPath || '';
1003
+ if (isHttpFile(enterPath)) {
1004
+ event.preventDefault();
1005
+ runHttpAtCaret();
1006
+ return;
1007
+ }
1008
+ }
1009
+
1010
+ if ((event.metaKey || event.ctrlKey) && event.key === 'ArrowDown') {
1011
+ event.preventDefault();
1012
+ if (isSourceViewerVisible()) goToSymbolUnderCursor();
1013
+ else openDiffFileAtCaret();
1014
+ return;
1015
+ }
1016
+
1017
+ if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
1018
+ var aeB = document.activeElement;
1019
+ if (aeB && (aeB.tagName === 'INPUT' || aeB.tagName === 'TEXTAREA' || aeB.tagName === 'SELECT')) return;
1020
+ event.preventDefault();
1021
+ if (isSourceViewerVisible()) goToSymbolUnderCursor();
1022
+ else if (isDiffViewVisible()) goToSymbolFromDiff();
1023
+ return;
1024
+ }
1025
+
1026
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') && isSourceViewerVisible() && viewerCursor) {
1027
+ event.preventDefault();
1028
+ const lineEdgeFile = sourceByPath.get(viewerCursor.path);
1029
+ if (lineEdgeFile && lineEdgeFile.embedded) {
1030
+ const lineEdgeLines = lineEdgeFile.content.split(/\r?\n/);
1031
+ const lineEdgeCol = event.key === 'ArrowLeft' ? 0 : (lineEdgeLines[viewerCursor.lineIndex] || '').length;
1032
+ if (event.shiftKey) { if (!selectionAnchor) selectionAnchor = { lineIndex: viewerCursor.lineIndex, column: viewerCursor.column }; }
1033
+ else selectionAnchor = null;
1034
+ setSourceCursor(viewerCursor.path, viewerCursor.lineIndex, lineEdgeCol, true, -1);
1035
+ applySourceSelection();
1036
+ }
1037
+ return;
1038
+ }
1039
+
1040
+ // Diff view: Cmd/Ctrl + Left/Right goes to the line start / end; pressing it again AT the
1041
+ // edge crosses to the adjacent pane (Left -> old, Right -> new). Plain arrows never cross.
1042
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') && isDiffViewVisible() && diffCursor) {
1043
+ event.preventDefault();
1044
+ const edgeWrap = diffWrapperByPath(diffCursor.path);
1045
+ const edgeRow = edgeWrap ? diffRowAt(edgeWrap, diffCursor.side, diffCursor.rowIndex) : null;
1046
+ const edgeLen = edgeRow ? diffLineText(edgeRow).length : 0;
1047
+ if (event.key === 'ArrowLeft') {
1048
+ if (diffCursor.column > 0) {
1049
+ setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, 0, true); // -> line start
1050
+ } else if (diffCursor.side === 'new') { // already at start -> cross to old (left)
1051
+ const oldRow = edgeWrap ? diffRowAt(edgeWrap, 'old', diffCursor.rowIndex) : null;
1052
+ if (isDiffCodeRow(oldRow)) setDiffCursor(diffCursor.path, 'old', diffCursor.rowIndex, diffLineText(oldRow).length, true);
1053
+ }
1054
+ } else { // ArrowRight
1055
+ if (diffCursor.column < edgeLen) {
1056
+ setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, edgeLen, true); // -> line end
1057
+ } else if (diffCursor.side === 'old') { // already at end -> cross to new (right)
1058
+ const newRow = edgeWrap ? diffRowAt(edgeWrap, 'new', diffCursor.rowIndex) : null;
1059
+ if (isDiffCodeRow(newRow)) setDiffCursor(diffCursor.path, 'new', diffCursor.rowIndex, 0, true);
1060
+ }
1061
+ }
1062
+ return;
1063
+ }
1064
+
1065
+ // Cmd/Ctrl+[ / ] walk the cursor-position history (back / forward), like an editor's Go Back/Forward.
1066
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && (event.key === '[' || event.key === ']')) {
1067
+ var navEl = document.activeElement;
1068
+ var navInField = navEl && (navEl.tagName === 'INPUT' || navEl.tagName === 'TEXTAREA' || navEl.tagName === 'SELECT');
1069
+ if (!navInField) {
1070
+ event.preventDefault();
1071
+ if (event.key === '[') navBack(); else navForward();
1072
+ return;
1073
+ }
1074
+ }
1075
+
1076
+ if (event.key === 'F7') {
1077
+ event.preventDefault();
1078
+ if (!document.getElementById('source-viewer')?.classList.contains('hidden')) {
1079
+ const sourceHunk = firstHunkForPath(document.getElementById('source-viewer')?.dataset.openPath || '');
1080
+ if (sourceHunk >= 0) {
1081
+ setActive(sourceHunk);
1082
+ return;
1083
+ }
1084
+ }
1085
+ next(event.shiftKey ? -1 : 1);
1086
+ }
1087
+ });
1088
+
1089
+ quickInput?.addEventListener('input', () => renderQuickOpenResults());
1090
+ quickResults?.addEventListener('mousemove', (event) => {
1091
+ const item = event.target.closest?.('.quick-open-item');
1092
+ if (!item) return;
1093
+ quickActive = Number(item.dataset.index || 0);
1094
+ updateQuickActive();
1095
+ });
1096
+ quickResults?.addEventListener('click', (event) => {
1097
+ const item = event.target.closest?.('.quick-open-item');
1098
+ if (!item) return;
1099
+ const index = Number(item.dataset.index || 0);
1100
+ openQuickItem(quickItems[index]);
1101
+ });
1102
+ quickOpen?.addEventListener('click', (event) => {
1103
+ if (event.target === quickOpen) closeQuickOpen();
1104
+ });
1105
+ document.getElementById('usages-results')?.addEventListener('mousemove', function (event) {
1106
+ var it = event.target.closest && event.target.closest('.usage-item');
1107
+ if (!it) return;
1108
+ usageActive = Number(it.dataset.index || 0);
1109
+ updateUsageActive();
1110
+ });
1111
+ document.getElementById('usages-results')?.addEventListener('click', function (event) {
1112
+ var it = event.target.closest && event.target.closest('.usage-item');
1113
+ if (!it) return;
1114
+ openUsageItem(usageItems[Number(it.dataset.index || 0)]);
1115
+ });
1116
+ document.getElementById('usages')?.addEventListener('click', function (event) {
1117
+ if (event.target && event.target.id === 'usages') closeUsages();
1118
+ });
1119
+
1120
+ links.forEach((link) => {
1121
+ link.addEventListener('click', (event) => {
1122
+ showDiffView(false);
1123
+ const target = Number(link.dataset.hunk);
1124
+ if (!Number.isNaN(target) && target >= 0 && target < hunkTotal()) {
1125
+ event.preventDefault();
1126
+ setActive(target);
1127
+ }
1128
+ });
1129
+ });
1130
+
1131
+ // Delegated so it works whether the tree is inline (small repos) or materialized later (big repos).
1132
+ document.getElementById('files-panel')?.addEventListener('click', (event) => {
1133
+ const link = event.target && event.target.closest ? event.target.closest('.source-link') : null;
1134
+ if (link && link.dataset.sourceFile) openSourceFile(link.dataset.sourceFile);
1135
+ });
1136
+
1137
+ document.querySelectorAll('.tab').forEach((button) => {
1138
+ button.addEventListener('click', () => setTab(button.dataset.tab || 'changes'));
1139
+ });
1140
+
1141
+ document.getElementById('back-to-diff')?.addEventListener('click', () => showDiffView(true));
1142
+ document.getElementById('diff-viewed-toggle')?.addEventListener('click', function () {
1143
+ var btn = document.getElementById('diff-viewed-toggle');
1144
+ var path = btn ? (btn.dataset.file || '') : '';
1145
+ if (path) setFileViewed(path, !isFileViewed(path));
1146
+ });
1147
+ document.getElementById('source-body')?.addEventListener('click', handleSourceClick);
1148
+ document.getElementById('source-body')?.addEventListener('click', function (event) {
1149
+ var img = event.target && event.target.closest && event.target.closest('.image-preview');
1150
+ if (img) openLightbox(img.getAttribute('src'), img.getAttribute('alt'));
1151
+ });
1152
+ document.addEventListener('keydown', function (event) {
1153
+ if (event.key === 'Escape' && lightboxOpen()) { event.preventDefault(); event.stopPropagation(); closeLightbox(); }
1154
+ }, true);
1155
+ document.addEventListener('copy', handleSourceCopy);
1156
+
1157
+ populateHttpEnvSelect();
1158
+ if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0); // non-lazy indexes now; lazy-LOAD defers the (large) source blob + index to the first source-view open / go-to-def
1159
+ const restored = restoreUiState();
1160
+ if (!restored) {
1161
+ const initial = location.hash.match(/^#hunk-(\\d+)$/);
1162
+ if (initial) setActive(Number(initial[1]), false);
1163
+ else if (REVIEW_LAZY_LOAD) showDiffView(false); // big repos: open to the diff (Changes); the source tree stays deferred until the Files tab is opened
1164
+ else openDefaultSourceFile();
1165
+ }
1166
+ initSourceTreeFolds();
1167
+ if (watchEnabled) setInterval(checkForLiveUpdate, 1500);
1168
+ window.addEventListener('beforeunload', saveUiState);
1169
+
1170
+ (function setupSidebarResize() {
1171
+ const resizer = document.querySelector('.sidebar-resizer');
1172
+ if (!resizer) return;
1173
+ const sidebarKey = 'monacori-sidebar-width:' + location.pathname;
1174
+ const saved = localStorage.getItem(sidebarKey);
1175
+ if (saved) document.documentElement.style.setProperty('--sidebar-width', saved);
1176
+ let resizing = false;
1177
+ resizer.addEventListener('mousedown', (event) => {
1178
+ resizing = true;
1179
+ resizer.classList.add('resizing');
1180
+ document.body.style.userSelect = 'none';
1181
+ event.preventDefault();
1182
+ });
1183
+ document.addEventListener('mousemove', (event) => {
1184
+ if (!resizing) return;
1185
+ const width = Math.min(640, Math.max(180, event.clientX));
1186
+ document.documentElement.style.setProperty('--sidebar-width', width + 'px');
1187
+ });
1188
+ document.addEventListener('mouseup', () => {
1189
+ if (!resizing) return;
1190
+ resizing = false;
1191
+ resizer.classList.remove('resizing');
1192
+ document.body.style.userSelect = '';
1193
+ try { localStorage.setItem(sidebarKey, getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width').trim()); } catch (e) {}
1194
+ });
1195
+ })();
1196
+
1197
+ (function setupDiffCaret() {
1198
+ const container = document.getElementById('diff2html-container');
1199
+ if (!container) return;
1200
+ // No contenteditable: the diff caret is the JS diffCursor. A native contenteditable caret
1201
+ // would render a second blinking cursor alongside it. Text selection (for comment capture)
1202
+ // still works on non-editable content.
1203
+ container.setAttribute('aria-readonly', 'true');
1204
+ container.querySelectorAll('.d2h-code-side-linenumber, .d2h-code-linenumber, .d2h-code-line-prefix').forEach((el) => el.setAttribute('contenteditable', 'false'));
1205
+ const inComment = (event) => Boolean(event.target && event.target.closest && event.target.closest('.mc-comment-row'));
1206
+ const block = (event) => { if (inComment(event)) return; event.preventDefault(); };
1207
+ container.addEventListener('focusin', (event) => { if (!inComment(event)) clearTreeFocus(); });
1208
+ container.addEventListener('mousedown', (event) => { if (!inComment(event)) clearTreeFocus(); });
1209
+ container.addEventListener('beforeinput', block);
1210
+ container.addEventListener('paste', block);
1211
+ container.addEventListener('drop', block);
1212
+ container.addEventListener('dragstart', block);
1213
+ container.addEventListener('keydown', (event) => {
1214
+ if (inComment(event)) return;
1215
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
1216
+ if (event.key.length === 1 || event.key === 'Enter' || event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Tab') {
1217
+ event.preventDefault();
1218
+ }
1219
+ });
1220
+ container.addEventListener('click', (event) => {
1221
+ if (inComment(event)) return;
1222
+ const info = diffRowInfoFromNode(event.target);
1223
+ if (info && info.path) setDiffCursor(info.path, info.side, info.rowIndex, 0, false);
1224
+ });
1225
+ ensureDiffCursor();
1226
+ })();
1227
+
1228
+ // ===== Side-by-side diff caret (keyboard navigation across the old/new panes) =====
1229
+ function isDiffViewVisible() {
1230
+ var d = document.getElementById('diff-view');
1231
+ return Boolean(d && !d.classList.contains('hidden'));
1232
+ }
1233
+ function diffActiveWrapper() {
1234
+ return document.querySelector('#diff2html-container .d2h-file-wrapper:not(.df-inactive)')
1235
+ || document.querySelector('#diff2html-container .d2h-file-wrapper');
1236
+ }
1237
+ function diffWrapperByPath(path) {
1238
+ var ws = document.querySelectorAll('#diff2html-container .d2h-file-wrapper');
1239
+ for (var i = 0; i < ws.length; i++) {
1240
+ var n = ws[i].querySelector('.d2h-file-name');
1241
+ if (n && (n.textContent || '').trim() === path) return ws[i];
1242
+ }
1243
+ return null;
1244
+ }
1245
+ function diffSideTables(wrapper) {
1246
+ var sides = wrapper ? wrapper.querySelectorAll('.d2h-file-side-diff') : [];
1247
+ return { left: sides[0] || null, right: sides[sides.length - 1] || null };
1248
+ }
1249
+ function diffSideTable(wrapper, side) {
1250
+ var t = diffSideTables(wrapper);
1251
+ return side === 'old' ? t.left : t.right;
1252
+ }
1253
+ function diffRowsOf(sideTable) {
1254
+ if (!sideTable) return [];
1255
+ return Array.prototype.slice.call(sideTable.querySelectorAll('tr')).filter(function (r) {
1256
+ return !r.classList.contains('mc-comment-row') && !r.classList.contains('mc-spacer-row');
1257
+ });
1258
+ }
1259
+ function diffRowAt(wrapper, side, rowIndex) {
1260
+ var rows = diffRowsOf(diffSideTable(wrapper, side));
1261
+ return rows[rowIndex] || null;
1262
+ }
1263
+ function diffCellCtn(row) {
1264
+ return row ? row.querySelector('.d2h-code-line-ctn') : null;
1265
+ }
1266
+ function diffLineText(row) {
1267
+ var ctn = diffCellCtn(row);
1268
+ return ctn ? (ctn.textContent || '') : '';
1269
+ }
1270
+ function diffLineNumber(row) {
1271
+ var n = row ? row.querySelector('.d2h-code-side-linenumber') : null;
1272
+ var v = n ? parseInt((n.textContent || '').trim(), 10) : NaN;
1273
+ return isFinite(v) ? v : null;
1274
+ }
1275
+ function diffRowInfoFromNode(node) {
1276
+ var el = node ? (node.nodeType === 1 ? node : node.parentElement) : null;
1277
+ if (!el || !el.closest) return null;
1278
+ var wrapper = el.closest('.d2h-file-wrapper');
1279
+ var sideEl = el.closest('.d2h-file-side-diff');
1280
+ var row = el.closest('tr');
1281
+ if (!wrapper || !sideEl || !row) return null;
1282
+ var nameEl = wrapper.querySelector('.d2h-file-name');
1283
+ var path = (nameEl && nameEl.textContent ? nameEl.textContent : '').trim();
1284
+ var t = diffSideTables(wrapper);
1285
+ var side = sideEl === t.left ? 'old' : 'new';
1286
+ if (!isDiffCodeRow(row)) return null;
1287
+ var rowIndex = diffRowsOf(sideEl).indexOf(row);
1288
+ if (!path || rowIndex < 0) return null;
1289
+ return { path: path, side: side, rowIndex: rowIndex };
1290
+ }
1291
+ function diffCaretDomPosition(ctn, column) {
1292
+ if (!ctn) return null;
1293
+ var remaining = column;
1294
+ var walker = document.createTreeWalker(ctn, NodeFilter.SHOW_TEXT);
1295
+ var node;
1296
+ while ((node = walker.nextNode())) {
1297
+ var len = node.textContent.length;
1298
+ if (remaining <= len) return { node: node, offset: remaining };
1299
+ remaining -= len;
1300
+ }
1301
+ return { node: ctn, offset: ctn.childNodes.length };
1302
+ }
1303
+ var diffCaretSpan = null;
1304
+ function clearDiffCaret() {
1305
+ var container = document.getElementById('diff2html-container');
1306
+ if (container) {
1307
+ container.querySelectorAll('.mc-diff-cursor-row').forEach(function (r) { r.classList.remove('mc-diff-cursor-row'); });
1308
+ // remove ALL caret spans (not just the tracked one) so a stray indicator never lingers
1309
+ container.querySelectorAll('.code-cursor').forEach(function (s) { var p = s.parentNode; if (p) { p.removeChild(s); if (p.normalize) p.normalize(); } });
1310
+ }
1311
+ diffCaretSpan = null;
1312
+ }
1313
+ function renderDiffCaret() {
1314
+ clearDiffCaret();
1315
+ if (!diffCursor) return;
1316
+ var wrapper = diffWrapperByPath(diffCursor.path);
1317
+ if (!wrapper) return;
1318
+ var row = diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex);
1319
+ if (!row) return;
1320
+ row.classList.add('mc-diff-cursor-row');
1321
+ var ctn = diffCellCtn(row);
1322
+ if (!ctn) return;
1323
+ // Empty line (ctn is just a <br>): the row highlight marks the caret. Inserting a caret span
1324
+ // next to the <br> would push it onto a second visual line and break the row's height.
1325
+ if ((ctn.textContent || '').length === 0) return;
1326
+ var pos = diffCaretDomPosition(ctn, diffCursor.column);
1327
+ if (!pos) return;
1328
+ var span = document.createElement('span');
1329
+ span.className = 'code-cursor';
1330
+ span.setAttribute('aria-hidden', 'true');
1331
+ try {
1332
+ var off = pos.node.nodeType === 3 ? Math.min(pos.offset, (pos.node.textContent || '').length) : pos.offset;
1333
+ var range = document.createRange();
1334
+ range.setStart(pos.node, off);
1335
+ range.collapse(true);
1336
+ range.insertNode(span);
1337
+ diffCaretSpan = span;
1338
+ } catch (e) { diffCaretSpan = null; }
1339
+ }
1340
+ function setDiffCursor(path, side, rowIndex, column, reveal) {
1341
+ var wrapper = diffWrapperByPath(path);
1342
+ if (!wrapper) return;
1343
+ var rows = diffRowsOf(diffSideTable(wrapper, side));
1344
+ if (!rows.length) return;
1345
+ var ri = Math.max(0, Math.min(rowIndex, rows.length - 1));
1346
+ var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
1347
+ diffCursor = { path: path, side: side, rowIndex: ri, column: col };
1348
+ diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
1349
+ renderDiffCaret();
1350
+ applyDiffSelection();
1351
+ if (reveal) {
1352
+ var r = diffRowAt(wrapper, side, ri);
1353
+ if (r && r.scrollIntoView) requestAnimationFrame(function () { try { r.scrollIntoView({ block: 'nearest' }); } catch (e) {} });
1354
+ }
1355
+ recordNav(navEntryOf('diff'));
1356
+ }
1357
+ function navEntryOf(kind) {
1358
+ if (kind === 'diff') {
1359
+ if (!diffCursor) return null;
1360
+ return { kind: 'diff', path: diffCursor.path, side: diffCursor.side, rowIndex: diffCursor.rowIndex, column: diffCursor.column, line: diffCursor.rowIndex };
1361
+ }
1362
+ if (!viewerCursor) return null;
1363
+ return { kind: 'source', path: viewerCursor.path, lineIndex: viewerCursor.lineIndex, column: viewerCursor.column, line: viewerCursor.lineIndex };
1364
+ }
1365
+ function navSamePos(a, b) {
1366
+ return !!(a && b && a.kind === b.kind && a.path === b.path && a.line === b.line && (a.kind !== 'diff' || a.side === b.side));
1367
+ }
1368
+ // Record a caret placement into the back/forward history. Contiguous small moves refresh the
1369
+ // current entry (so arrowing around does not flood it); a jump (different file or a far line)
1370
+ // pushes a new entry and drops any forward history.
1371
+ function recordNav(entry) {
1372
+ if (navSuppress || !entry) return;
1373
+ var cur = navPos >= 0 ? navList[navPos] : null;
1374
+ if (navSamePos(cur, entry)) { navList[navPos] = entry; return; }
1375
+ var small = cur && cur.kind === entry.kind && cur.path === entry.path && Math.abs(cur.line - entry.line) < NAV_JUMP_LINES;
1376
+ if (small) { navList[navPos] = entry; return; }
1377
+ navList = navList.slice(0, navPos + 1);
1378
+ navList.push(entry);
1379
+ navPos = navList.length - 1;
1380
+ if (navList.length > NAV_MAX) { navList.shift(); navPos -= 1; }
1381
+ }
1382
+ function revealDiffFile(path) {
1383
+ document.getElementById('source-viewer')?.classList.add('hidden');
1384
+ document.getElementById('diff-view')?.classList.remove('hidden');
1385
+ setTab('changes');
1386
+ showOnlyFile(path);
1387
+ links.forEach(function (link) { link.classList.toggle('active', link.dataset.file === path); });
1388
+ renderBreadcrumb(document.getElementById('diff-breadcrumb'), path);
1389
+ }
1390
+ function restoreNav(entry) {
1391
+ if (!entry) return;
1392
+ navSuppress = true;
1393
+ try {
1394
+ if (entry.kind === 'diff') {
1395
+ revealDiffFile(entry.path);
1396
+ setDiffCursor(entry.path, entry.side, entry.rowIndex, entry.column, true);
1397
+ } else {
1398
+ setSourceCursor(entry.path, entry.lineIndex, entry.column, true, -1);
1399
+ }
1400
+ } finally {
1401
+ navSuppress = false;
1402
+ }
1403
+ }
1404
+ function navBack() {
1405
+ if (navPos < 0) return;
1406
+ // Change-nav (F7) does not record positions. If the caret has drifted past the last recorded
1407
+ // spot, the first Cmd+[ returns to it; the next steps further back through the cursor history.
1408
+ var live = navEntryOf(isSourceViewerVisible() ? 'source' : 'diff');
1409
+ if (live && !navSamePos(live, navList[navPos])) { restoreNav(navList[navPos]); return; }
1410
+ if (navPos > 0) { navPos -= 1; restoreNav(navList[navPos]); }
1411
+ }
1412
+ function navForward() {
1413
+ if (navPos < navList.length - 1) { navPos += 1; restoreNav(navList[navPos]); }
1414
+ }
1415
+ function applyDiffSelection() {
1416
+ var sel = window.getSelection();
1417
+ if (!sel) return;
1418
+ // Selection only makes sense within one pane and one file; otherwise clear it.
1419
+ if (!diffSelectionAnchor || !diffCursor || diffSelectionAnchor.side !== diffCursor.side) { try { sel.removeAllRanges(); } catch (e) {} return; }
1420
+ var wrapper = diffWrapperByPath(diffCursor.path);
1421
+ if (!wrapper) { try { sel.removeAllRanges(); } catch (e) {} return; }
1422
+ var aCtn = diffCellCtn(diffRowAt(wrapper, diffSelectionAnchor.side, diffSelectionAnchor.rowIndex));
1423
+ var cCtn = diffCellCtn(diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex));
1424
+ var a = aCtn ? diffCaretDomPosition(aCtn, diffSelectionAnchor.column) : null;
1425
+ var c = cCtn ? diffCaretDomPosition(cCtn, diffCursor.column) : null;
1426
+ if (a && c) { try { sel.setBaseAndExtent(a.node, a.offset, c.node, c.offset); } catch (e) {} }
1427
+ }
1428
+ function isDiffCodeRow(row) {
1429
+ if (!row) return false;
1430
+ if (row.querySelector('.d2h-emptyplaceholder, .d2h-code-side-emptyplaceholder')) return false; // added/removed counterpart — no real line
1431
+ if (!row.querySelector('.d2h-code-line-ctn')) return false;
1432
+ var num = row.querySelector('.d2h-code-side-linenumber');
1433
+ return !!num && (num.textContent || '').trim().length > 0; // real code line has a line number (excludes hunk-info rows)
1434
+ }
1435
+ function firstDiffCodeRow(wrapper, side) {
1436
+ var rows = diffRowsOf(diffSideTable(wrapper, side));
1437
+ for (var i = 0; i < rows.length; i++) { if (isDiffCodeRow(rows[i])) return i; }
1438
+ return -1;
1439
+ }
1440
+ function ensureDiffCursor() {
1441
+ if (!isDiffViewVisible()) return;
1442
+ var wrapper = diffActiveWrapper();
1443
+ if (!wrapper) return;
1444
+ whenFileReady(wrapper, function () {
1445
+ var nameEl = wrapper.querySelector('.d2h-file-name');
1446
+ var path = (nameEl && nameEl.textContent ? nameEl.textContent : '').trim();
1447
+ if (!path) return;
1448
+ if (diffCursor && diffCursor.path === path) { renderDiffCaret(); return; }
1449
+ var ri = firstDiffCodeRow(wrapper, 'new');
1450
+ if (ri < 0) return;
1451
+ setDiffCursor(path, 'new', ri, 0, false);
1452
+ });
1453
+ }
1454
+ function moveDiffCursor(dLine, dColumn, extend) {
1455
+ if (!diffCursor) return;
1456
+ var wrapper = diffWrapperByPath(diffCursor.path);
1457
+ if (!wrapper) return;
1458
+ var side = diffCursor.side;
1459
+ var rows = diffRowsOf(diffSideTable(wrapper, side));
1460
+ var ri = diffCursor.rowIndex;
1461
+ var col = diffCursor.column;
1462
+ var text = diffLineText(rows[ri]);
1463
+ // Shift extends a text selection from where the caret sat before the first shifted move.
1464
+ var anchor = extend ? (diffSelectionAnchor || { side: diffCursor.side, rowIndex: diffCursor.rowIndex, column: diffCursor.column }) : null;
1465
+ // Plain arrows stay within the current pane (no auto pane-crossing — that is Cmd+Left/Right).
1466
+ if (dColumn < 0) {
1467
+ if (col > 0) { col -= 1; }
1468
+ else { // at line start: end of previous code line in the SAME pane
1469
+ var p = ri - 1; while (p >= 0 && !isDiffCodeRow(rows[p])) p -= 1;
1470
+ if (p >= 0) { ri = p; col = diffLineText(rows[p]).length; }
1471
+ }
1472
+ } else if (dColumn > 0) {
1473
+ if (col < text.length) { col += 1; }
1474
+ else { // at line end: start of next code line in the SAME pane
1475
+ var nx = ri + 1; while (nx < rows.length && !isDiffCodeRow(rows[nx])) nx += 1;
1476
+ if (nx < rows.length) { ri = nx; col = 0; }
1477
+ }
1478
+ }
1479
+ if (dLine !== 0) {
1480
+ var rows2 = diffRowsOf(diffSideTable(wrapper, side));
1481
+ var step = dLine > 0 ? 1 : -1;
1482
+ var cand = ri + step;
1483
+ while (cand >= 0 && cand < rows2.length && !isDiffCodeRow(rows2[cand])) cand += step;
1484
+ if (cand >= 0 && cand < rows2.length) { ri = cand; col = Math.min(col, diffLineText(rows2[ri]).length); }
1485
+ }
1486
+ setDiffCursor(diffCursor.path, side, ri, col, true); // clears diffSelectionAnchor + native selection
1487
+ if (anchor) { diffSelectionAnchor = anchor; applyDiffSelection(); } // re-establish the Shift selection
1488
+ }
1489
+ function moveDiffWord(dir, extend) {
1490
+ if (!diffCursor) return;
1491
+ var wrapper = diffWrapperByPath(diffCursor.path);
1492
+ if (!wrapper) return;
1493
+ var row = diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex);
1494
+ var text = diffLineText(row);
1495
+ var ncol = nextWordBoundary(text, diffCursor.column, dir);
1496
+ if (ncol === diffCursor.column) return; // already at the line edge — plain arrows change lines
1497
+ var anchor = extend ? (diffSelectionAnchor || { side: diffCursor.side, rowIndex: diffCursor.rowIndex, column: diffCursor.column }) : null;
1498
+ setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, ncol, true);
1499
+ if (anchor) { diffSelectionAnchor = anchor; applyDiffSelection(); }
1500
+ }
1501
+ function handleDiffCaretKey(event) {
1502
+ if (!isDiffViewVisible() || !diffCursor) return false;
1503
+ var ae = document.activeElement;
1504
+ if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return false;
1505
+ var extend = event.shiftKey;
1506
+ if (event.key === 'ArrowDown') { event.preventDefault(); moveDiffCursor(1, 0, extend); return true; }
1507
+ if (event.key === 'ArrowUp') { event.preventDefault(); moveDiffCursor(-1, 0, extend); return true; }
1508
+ if (event.key === 'ArrowLeft') { event.preventDefault(); moveDiffCursor(0, -1, extend); return true; }
1509
+ if (event.key === 'ArrowRight') { event.preventDefault(); moveDiffCursor(0, 1, extend); return true; }
1510
+ return false;
1511
+ }
1512
+
1513
+ // ===== Review comments: questions ("?") and change-requests (">") =====
1514
+ // (COMMENTS_KEY / reviewComments / commentSeq / composerState are declared near the top of the script)
1515
+ function saveComments() {
1516
+ try { localStorage.setItem(COMMENTS_KEY, JSON.stringify(reviewComments)); } catch (e) {}
1517
+ }
1518
+ function commentsAt(path, line) {
1519
+ return reviewComments.filter(function (c) { return c.path === path && c.line === line; });
1520
+ }
1521
+ function commentKindLabel(kind) {
1522
+ return kind === 'q' ? '❓ Question' : '✎ Change request';
1523
+ }
1524
+ function relevantLines(path) {
1525
+ var set = {};
1526
+ reviewComments.forEach(function (c) { if (c.path === path) set[c.line] = true; });
1527
+ if (composerState && composerState.path === path) set[composerState.line] = true;
1528
+ return Object.keys(set).map(Number).sort(function (a, b) { return a - b; });
1529
+ }
1530
+ function addComment(kind, path, line, code, text) {
1531
+ var trimmed = String(text || '').trim();
1532
+ if (!trimmed) return;
1533
+ commentSeq += 1;
1534
+ reviewComments.push({ seq: commentSeq, kind: kind, path: path, line: line, code: String(code || ''), text: trimmed });
1535
+ saveComments();
1536
+ }
1537
+ function deleteComment(seq) {
1538
+ reviewComments = reviewComments.filter(function (c) { return c.seq !== seq; });
1539
+ saveComments();
1540
+ refreshComments();
1541
+ }
1542
+
1543
+ function sourceRowLineOf(node) {
1544
+ var el = node ? (node.nodeType === 1 ? node : node.parentElement) : null;
1545
+ var row = el && el.closest ? el.closest('.source-row') : null;
1546
+ if (!row) return null;
1547
+ var v = parseInt(row.dataset.lineIndex, 10);
1548
+ return isFinite(v) ? v : null;
1549
+ }
1550
+ function currentCommentTarget() {
1551
+ var sel = window.getSelection();
1552
+ var selText = (sel && sel.toString) ? sel.toString() : '';
1553
+ var hasSel = !!sel && !sel.isCollapsed && selText.trim().length > 0;
1554
+ // Source view: anchor BELOW the selection (its last line) so the box sits under the drag.
1555
+ // Derive the span from the actual DOM range so MOUSE drags work (they don't move the JS caret).
1556
+ if (isSourceViewerVisible() && viewerCursor) {
1557
+ if (hasSel) {
1558
+ var srng = sel.rangeCount ? sel.getRangeAt(0) : null;
1559
+ var sa = srng ? sourceRowLineOf(srng.startContainer) : null;
1560
+ var sb = srng ? sourceRowLineOf(srng.endContainer) : null;
1561
+ if (sa == null || sb == null) { sa = selectionAnchor ? selectionAnchor.lineIndex : viewerCursor.lineIndex; sb = viewerCursor.lineIndex; }
1562
+ var f = Math.min(sa, sb), t = Math.max(sa, sb);
1563
+ return { path: viewerCursor.path, line: t + 1, code: selText, from: f + 1, to: t + 1, side: null };
1564
+ }
1565
+ return { path: viewerCursor.path, line: viewerCursor.lineIndex + 1, code: '', from: null, to: null, side: null };
1566
+ }
1567
+ // Diff view: prefer the explicit diff caret when there is no text selection.
1568
+ if (!hasSel && diffCursor && isDiffViewVisible()) {
1569
+ var dwrap = diffWrapperByPath(diffCursor.path);
1570
+ var drow = dwrap ? diffRowAt(dwrap, diffCursor.side, diffCursor.rowIndex) : null;
1571
+ var dline = drow ? diffLineNumber(drow) : null;
1572
+ if (dline != null) return { path: diffCursor.path, line: dline, code: '', from: null, to: null, side: null };
1573
+ }
1574
+ // Diff view with a selection (or click): anchor at the LAST line so the composer drops BELOW the
1575
+ // drag; capture the selected code + line span (used to keep the drag highlighted via .mc-sel-line).
1576
+ var rng = (sel && sel.rangeCount) ? sel.getRangeAt(0) : null;
1577
+ var fromNode = rng ? rng.startContainer : (sel ? sel.anchorNode : null);
1578
+ var toNode = rng ? rng.endContainer : (sel ? sel.anchorNode : null);
1579
+ var fromEl = fromNode ? (fromNode.nodeType === 1 ? fromNode : fromNode.parentElement) : null;
1580
+ var toEl = toNode ? (toNode.nodeType === 1 ? toNode : toNode.parentElement) : null;
1581
+ var wrapper = (toEl && toEl.closest && toEl.closest('.d2h-file-wrapper')) || document.querySelector('#diff2html-container .d2h-file-wrapper:not(.df-inactive)');
1582
+ if (!wrapper) return null;
1583
+ var nameEl = wrapper.querySelector('.d2h-file-name');
1584
+ var path = (nameEl && nameEl.textContent ? nameEl.textContent : '').trim();
1585
+ if (!path) return null;
1586
+ var toRow = toEl && toEl.closest ? toEl.closest('tr') : null;
1587
+ if (!toRow || !toRow.querySelector('.d2h-code-side-linenumber')) {
1588
+ var sides0 = wrapper.querySelectorAll('.d2h-file-side-diff');
1589
+ var right0 = sides0[sides0.length - 1];
1590
+ var firstNum = right0 ? right0.querySelector('.d2h-code-side-linenumber') : null;
1591
+ toRow = firstNum ? firstNum.closest('tr') : null;
1592
+ }
1593
+ if (!toRow) return null;
1594
+ var toLine = diffLineNumber(toRow);
1595
+ if (toLine == null) return null;
1596
+ var fromRow = (hasSel && fromEl && fromEl.closest) ? fromEl.closest('tr') : null;
1597
+ var fromLine = fromRow ? diffLineNumber(fromRow) : null;
1598
+ if (fromLine == null) fromLine = toLine;
1599
+ var sideEl = toEl && toEl.closest ? toEl.closest('.d2h-file-side-diff') : null;
1600
+ var st = diffSideTables(wrapper);
1601
+ var side = (sideEl && sideEl === st.left) ? 'old' : 'new';
1602
+ return { path: path, line: toLine, code: hasSel ? selText : '', from: hasSel ? Math.min(fromLine, toLine) : null, to: hasSel ? Math.max(fromLine, toLine) : null, side: side };
1603
+ }
1604
+
1605
+ function threadHtml(path, line) {
1606
+ var html = '';
1607
+ commentsAt(path, line).forEach(function (c) {
1608
+ html += '<div class="mc-card mc-' + c.kind + '">'
1609
+ + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(c.kind) + '</span>'
1610
+ + '<button type="button" class="mc-del" data-seq="' + c.seq + '" title="Delete">×</button></div>'
1611
+ + '<div class="mc-card-body">' + escapeHtml(c.text) + '</div></div>';
1612
+ });
1613
+ if (composerState && composerState.path === path && composerState.line === line) {
1614
+ var ph = composerState.kind === 'q' ? 'Ask a question about this line' : 'Request a change for this line';
1615
+ html += '<div class="mc-card mc-' + composerState.kind + ' mc-composer">'
1616
+ + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(composerState.kind) + '</span></div>'
1617
+ + '<textarea class="mc-input" rows="3" placeholder="' + ph + '"></textarea>'
1618
+ + '<div class="mc-actions"><button type="button" class="mc-btn mc-save">Comment</button>'
1619
+ + '<button type="button" class="mc-btn mc-ghost mc-cancel">Cancel</button>'
1620
+ + '<span class="mc-hint">Cmd/Ctrl+Enter to save, Esc to cancel</span></div></div>';
1621
+ }
1622
+ return html;
1623
+ }
1624
+
1625
+ function injectThreadRow(anchorRow, path, line) {
1626
+ if (!anchorRow || !anchorRow.parentNode) return;
1627
+ var tr = document.createElement('tr');
1628
+ tr.className = 'mc-comment-row';
1629
+ var td = document.createElement('td');
1630
+ // source/markdown/csv rows can have >2 cells (csv); span them all. diff (d2h) rows stay 2.
1631
+ td.colSpan = (anchorRow.classList && anchorRow.classList.contains('source-row')) ? (anchorRow.children.length || 2) : 2;
1632
+ td.className = 'mc-thread-cell';
1633
+ td.innerHTML = threadHtml(path, line);
1634
+ tr.appendChild(td);
1635
+ anchorRow.parentNode.insertBefore(tr, anchorRow.nextSibling);
1636
+ }
1637
+
1638
+ function renderDiffComments() {
1639
+ var container = document.getElementById('diff2html-container');
1640
+ if (!container) return;
1641
+ container.querySelectorAll('.mc-comment-row').forEach(function (r) { r.remove(); });
1642
+ container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
1643
+ var nameEl = w.querySelector('.d2h-file-name');
1644
+ var path = (nameEl && nameEl.textContent ? nameEl.textContent : '').trim();
1645
+ if (!path) return;
1646
+ var lines = relevantLines(path);
1647
+ if (!lines.length) return;
1648
+ var sides = w.querySelectorAll('.d2h-file-side-diff');
1649
+ var right = sides[sides.length - 1];
1650
+ if (!right) return;
1651
+ var rows = right.querySelectorAll('tr');
1652
+ lines.forEach(function (line) {
1653
+ for (var i = 0; i < rows.length; i++) {
1654
+ var num = rows[i].querySelector('.d2h-code-side-linenumber');
1655
+ if (num && (num.textContent || '').trim() === String(line)) { injectThreadRow(rows[i], path, line); break; }
1656
+ }
1657
+ });
1658
+ });
1659
+ }
1660
+
1661
+ function renderSourceComments() {
1662
+ var body = document.getElementById('source-body');
1663
+ if (!body) return;
1664
+ body.querySelectorAll('.mc-comment-row').forEach(function (r) { r.remove(); });
1665
+ var viewer = document.getElementById('source-viewer');
1666
+ var path = viewer ? (viewer.dataset.openPath || '') : '';
1667
+ if (!path) return;
1668
+ relevantLines(path).forEach(function (line) {
1669
+ var anchor = body.querySelector('.source-row[data-line-index="' + (line - 1) + '"]');
1670
+ if (anchor) injectThreadRow(anchor, path, line);
1671
+ });
1672
+ }
1673
+
1674
+ // Per-file comment counts as small (no-emoji) badges in BOTH sidebars — the Changes list
1675
+ // (.change-row, before the diffstat) and the Files tree (.source-link, after the file name).
1676
+ function renderCommentBadges() {
1677
+ document.querySelectorAll('.mc-file-badge').forEach(function (b) { b.remove(); });
1678
+ var counts = {};
1679
+ reviewComments.forEach(function (x) {
1680
+ var k = counts[x.path] || (counts[x.path] = { q: 0, c: 0 });
1681
+ if (x.kind === 'q') k.q += 1; else k.c += 1;
1682
+ });
1683
+ function makeBadge(k) {
1684
+ var badge = document.createElement('span');
1685
+ badge.className = 'mc-file-badge';
1686
+ var html = '';
1687
+ if (k.q) html += '<span class="mc-fb mc-fb-q" title="' + k.q + ' question(s)">' + k.q + '</span>';
1688
+ if (k.c) html += '<span class="mc-fb mc-fb-c" title="' + k.c + ' change request(s)">' + k.c + '</span>';
1689
+ badge.innerHTML = html;
1690
+ return badge;
1691
+ }
1692
+ function inject(selector, keyAttr, refSelector) {
1693
+ document.querySelectorAll(selector).forEach(function (row) {
1694
+ var k = counts[row.dataset[keyAttr] || ''];
1695
+ if (!k) return;
1696
+ var ref = row.querySelector(refSelector);
1697
+ if (ref) row.insertBefore(makeBadge(k), ref); else row.appendChild(makeBadge(k));
1698
+ });
1699
+ }
1700
+ inject('.change-row', 'file', '.diffstat');
1701
+ inject('.source-link', 'sourceFile', '.count');
1702
+ }
1703
+
1704
+ // While composing on a drag selection, keep those lines highlighted (.mc-sel-line) so the user
1705
+ // sees what they are commenting on even though the native selection was cleared.
1706
+ function applyCommentSelectionHighlight() {
1707
+ document.querySelectorAll('.mc-sel-line').forEach(function (r) { r.classList.remove('mc-sel-line'); });
1708
+ if (!composerState || composerState.from == null || composerState.to == null) return;
1709
+ var from = composerState.from, to = composerState.to;
1710
+ if (isDiffViewVisible()) {
1711
+ var wrap = diffWrapperByPath(composerState.path);
1712
+ if (!wrap) return;
1713
+ diffRowsOf(diffSideTable(wrap, composerState.side || 'new')).forEach(function (row) {
1714
+ var ln = diffLineNumber(row);
1715
+ if (ln != null && ln >= from && ln <= to) row.classList.add('mc-sel-line');
1716
+ });
1717
+ } else if (isSourceViewerVisible()) {
1718
+ for (var ln = from; ln <= to; ln++) {
1719
+ var sr = document.querySelector('.source-row[data-line-index="' + (ln - 1) + '"]');
1720
+ if (sr) sr.classList.add('mc-sel-line');
1721
+ }
1722
+ }
1723
+ }
1724
+ function refreshComments() {
1725
+ renderDiffComments();
1726
+ if (isSourceViewerVisible()) renderSourceComments();
1727
+ renderCommentBadges();
1728
+ applyCommentSelectionHighlight();
1729
+ if (composerState) {
1730
+ var focusComposerInput = function () {
1731
+ var ta = document.querySelector('.mc-composer .mc-input');
1732
+ if (ta && document.activeElement !== ta) {
1733
+ try { ta.focus({ preventScroll: true }); } catch (e) { try { ta.focus(); } catch (e2) {} }
1734
+ try { ta.selectionStart = ta.selectionEnd = ta.value.length; } catch (e3) {}
1735
+ }
1736
+ };
1737
+ // Focus now, next frame, and next task: after a drag the browser may async-restore focus to
1738
+ // the body (esp. in Electron), so retry across all three so the textarea reliably wins.
1739
+ focusComposerInput();
1740
+ requestAnimationFrame(focusComposerInput);
1741
+ setTimeout(focusComposerInput, 0);
1742
+ }
1743
+ }
1744
+
1745
+ function openComposer(kind) {
1746
+ var target = currentCommentTarget();
1747
+ if (!target) return;
1748
+ composerState = { kind: kind, path: target.path, line: target.line, code: target.code, from: target.from, to: target.to, side: target.side };
1749
+ // Keep the dragged code visibly highlighted via the .mc-sel-line class (applyCommentSelectionHighlight),
1750
+ // and clear the native selection so its highlight doesn't bleed into the composer/cards below it.
1751
+ try { var psel = window.getSelection(); if (psel) psel.removeAllRanges(); } catch (e) {}
1752
+ refreshComments();
1753
+ }
1754
+ function closeComposer() {
1755
+ if (!composerState) return;
1756
+ composerState = null;
1757
+ refreshComments();
1758
+ }
1759
+ function saveComposer(ta) {
1760
+ if (!composerState) return;
1761
+ var box = ta || document.querySelector('.mc-composer .mc-input');
1762
+ if (!box) return;
1763
+ addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
1764
+ composerState = null;
1765
+ refreshComments();
1766
+ }
1767
+
1768
+ function buildMergedText(kind) {
1769
+ var items = reviewComments.filter(function (c) { return c.kind === kind; });
1770
+ var nl = String.fromCharCode(10);
1771
+ var lines = [];
1772
+ // Per-kind agent contract: questions are the understand-phase (answer, don't edit); change
1773
+ // requests are the act-phase (edit the code). The merged view is an editable textarea, so the
1774
+ // reviewer can trim or localize this before copying.
1775
+ lines.push(kind === 'q'
1776
+ ? 'The following are questions about code you just wrote. Answer each one — explain the intent, rationale, or context. Do not change any code; this clarifies understanding before any revisions.'
1777
+ : 'The following are change requests for code you just wrote. For each, edit the code at the quoted location to satisfy the request. Keep changes minimal and focused; do not make unrelated edits.');
1778
+ lines.push('');
1779
+ lines.push((kind === 'q' ? '# Questions' : '# Change requests') + ' (' + items.length + ')');
1780
+ lines.push('');
1781
+ items.forEach(function (c) {
1782
+ lines.push('### ' + c.path + ':' + c.line);
1783
+ if (c.code && c.code.trim()) lines.push('> ' + c.code.trim());
1784
+ lines.push(c.text);
1785
+ lines.push('');
1786
+ });
1787
+ return lines.join(nl);
1788
+ }
1789
+
1790
+ function openMergedView(kind) {
1791
+ var existing = document.getElementById('mc-modal');
1792
+ if (existing) existing.remove();
1793
+ var modal = document.createElement('div');
1794
+ modal.id = 'mc-modal';
1795
+ modal.className = 'mc-modal';
1796
+ var panel = document.createElement('div');
1797
+ panel.className = 'mc-modal-panel';
1798
+ var head = document.createElement('div');
1799
+ head.className = 'mc-modal-head';
1800
+ var title = document.createElement('span');
1801
+ title.textContent = kind === 'q' ? 'Question comments' : 'Change-request comments';
1802
+ var copyBtn = document.createElement('button');
1803
+ copyBtn.type = 'button';
1804
+ copyBtn.className = 'mc-btn';
1805
+ copyBtn.textContent = 'Copy all';
1806
+ var closeBtn = document.createElement('button');
1807
+ closeBtn.type = 'button';
1808
+ closeBtn.className = 'mc-btn mc-ghost';
1809
+ closeBtn.textContent = 'Close';
1810
+ var area = document.createElement('textarea');
1811
+ area.className = 'mc-modal-text';
1812
+ area.readOnly = true;
1813
+ area.value = buildMergedText(kind);
1814
+ copyBtn.addEventListener('click', function () {
1815
+ area.focus(); area.select();
1816
+ var ok = false;
1817
+ try { ok = document.execCommand('copy'); } catch (e) {}
1818
+ if (navigator.clipboard && navigator.clipboard.writeText) { try { navigator.clipboard.writeText(area.value); ok = true; } catch (e) {} }
1819
+ copyBtn.textContent = ok ? 'Copied' : 'Copy failed';
1820
+ setTimeout(function () { copyBtn.textContent = 'Copy all'; }, 1500);
1821
+ });
1822
+ closeBtn.addEventListener('click', function () { modal.remove(); });
1823
+ head.appendChild(title);
1824
+ head.appendChild(copyBtn);
1825
+ head.appendChild(closeBtn);
1826
+ panel.appendChild(head);
1827
+ panel.appendChild(area);
1828
+ modal.appendChild(panel);
1829
+ modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
1830
+ modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
1831
+ document.body.appendChild(modal);
1832
+ requestAnimationFrame(function () { area.focus(); area.select(); });
1833
+ }
1834
+
1835
+ document.addEventListener('click', function (event) {
1836
+ var t = event.target;
1837
+ if (!t || !t.closest) return;
1838
+ var del = t.closest('.mc-del');
1839
+ if (del) { event.preventDefault(); deleteComment(parseInt(del.dataset.seq, 10)); return; }
1840
+ if (t.closest('.mc-save')) { event.preventDefault(); saveComposer(); return; }
1841
+ if (t.closest('.mc-cancel')) { event.preventDefault(); closeComposer(); return; }
1842
+ });
1843
+ document.addEventListener('keydown', function (event) {
1844
+ var t = event.target;
1845
+ if (!t || !t.classList || !t.classList.contains('mc-input')) return;
1846
+ if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeComposer(); return; }
1847
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); saveComposer(t); return; }
1848
+ }, true);
1849
+
1850
+ refreshComments();
1851
+
1852
+ // In Electron, the Review menu's Cmd/Ctrl+Shift+/ and +. accelerators arrive here via IPC
1853
+ // (macOS reserves Cmd+? for its Help search, so the menu claims it and routes to these views).
1854
+ if (window.monacoriMenu && typeof window.monacoriMenu.onMergedView === 'function') {
1855
+ window.monacoriMenu.onMergedView(function (kind) { openMergedView(kind); });
1856
+ }
1857
+
1858
+ (function checkForUpdate() {
1859
+ var current = window.__MONACORI_VERSION__ || '';
1860
+ if (!current) return;
1861
+ var isNewer = function (a, b) {
1862
+ var pa = String(a).split('.'), pb = String(b).split('.');
1863
+ for (var i = 0; i < 3; i++) {
1864
+ var x = parseInt(pa[i], 10) || 0, y = parseInt(pb[i], 10) || 0;
1865
+ if (x > y) return true;
1866
+ if (x < y) return false;
1867
+ }
1868
+ return false;
1869
+ };
1870
+ var apply = function (latest) {
1871
+ if (!latest) return;
1872
+ var status = document.getElementById('app-info-status');
1873
+ if (isNewer(latest, current)) {
1874
+ var flag = document.getElementById('app-update-flag');
1875
+ if (flag) flag.classList.remove('hidden');
1876
+ if (status) { status.textContent = 'Update available: v' + latest; status.classList.add('has-update'); }
1877
+ } else if (status) {
1878
+ status.textContent = 'Up to date (v' + current + ')';
1879
+ }
1880
+ };
1881
+ // Cache the npm result for the session so watch-mode reloads reuse it instead of refetching.
1882
+ var cached = '';
1883
+ try { cached = sessionStorage.getItem('monacori-update-latest') || ''; } catch (e) {}
1884
+ if (cached) { apply(cached); return; }
1885
+ if (typeof fetch !== 'function') return;
1886
+ fetch('https://registry.npmjs.org/@happy-nut/monacori/latest', { cache: 'no-store' })
1887
+ .then(function (res) { return res && res.ok ? res.json() : null; })
1888
+ .then(function (data) {
1889
+ if (!data || !data.version) return;
1890
+ try { sessionStorage.setItem('monacori-update-latest', data.version); } catch (e) {}
1891
+ apply(data.version);
1892
+ })
1893
+ .catch(function () {});
1894
+ })();
1895
+
1896
+ (function setupAppInfo() {
1897
+ var btn = document.getElementById('app-info-btn');
1898
+ var panel = document.getElementById('app-info');
1899
+ var flag = document.getElementById('app-update-flag');
1900
+ var copyBtn = document.getElementById('app-info-copy');
1901
+ if (!btn || !panel) return;
1902
+ var setOpen = function (open) { panel.classList.toggle('hidden', !open); };
1903
+ btn.addEventListener('click', function (e) { e.stopPropagation(); setOpen(panel.classList.contains('hidden')); });
1904
+ if (flag) flag.addEventListener('click', function (e) { e.stopPropagation(); setOpen(true); });
1905
+ if (copyBtn) copyBtn.addEventListener('click', function () {
1906
+ var cmd = 'npm i -g @happy-nut/monacori';
1907
+ var done = function () { copyBtn.textContent = 'Copied'; setTimeout(function () { copyBtn.textContent = 'Copy'; }, 1200); };
1908
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(cmd).then(done).catch(function () {}); }
1909
+ });
1910
+ document.addEventListener('click', function (e) {
1911
+ if (panel.classList.contains('hidden')) return;
1912
+ if (panel.contains(e.target) || btn.contains(e.target)) return;
1913
+ setOpen(false);
1914
+ });
1915
+ document.addEventListener('keydown', function (e) {
1916
+ if (e.key === 'Escape' && !panel.classList.contains('hidden')) setOpen(false);
1917
+ });
1918
+ })();
1919
+
1920
+ function setTab(name) {
1921
+ if (name === 'files') ensureTreeRendered();
1922
+ document.querySelectorAll('.tab').forEach((button) => {
1923
+ button.classList.toggle('active', button.dataset.tab === name);
1924
+ });
1925
+ document.getElementById('changes-panel')?.classList.toggle('hidden', name !== 'changes');
1926
+ document.getElementById('files-panel')?.classList.toggle('hidden', name !== 'files');
1927
+ }
1928
+ // Big repos ship the source tree as an inert island (see render.ts); build it the first time the Files
1929
+ // tab is opened so the (potentially huge) tree never blocks startup. No-op for inline (small) trees.
1930
+ function ensureTreeRendered() {
1931
+ var panel = document.getElementById('files-panel');
1932
+ var island = document.getElementById('files-tree-html');
1933
+ if (!panel || !island) return;
1934
+ var html = island.textContent || '';
1935
+ island.parentNode && island.parentNode.removeChild(island);
1936
+ panel.innerHTML = '<div class="empty-nav">Building file tree…</div>';
1937
+ setTimeout(function () { // let "Building…" paint before the heavy innerHTML
1938
+ panel.innerHTML = html;
1939
+ sourceLinks = Array.from(document.querySelectorAll('.source-link'));
1940
+ if (typeof refreshComments === 'function') { try { refreshComments(); } catch (e) {} } // re-render per-file badges
1941
+ }, 0);
1942
+ }
1943
+
1944
+ function showDiffView(shouldScroll) {
1945
+ document.getElementById('source-viewer')?.classList.add('hidden');
1946
+ document.getElementById('diff-view')?.classList.remove('hidden');
1947
+ setTab('changes');
1948
+ if (current < 0 && hunkTotal()) {
1949
+ setActive(0, shouldScroll);
1950
+ return;
1951
+ }
1952
+ if (current >= 0) {
1953
+ const cidx = current;
1954
+ whenFileReady(diffWrapperByPath(hunkPathAt(cidx)), function () {
1955
+ const curRow = document.getElementById('hunk-' + cidx);
1956
+ if (curRow) {
1957
+ showOnlyFile(hunkPathAt(cidx));
1958
+ if (shouldScroll) curRow.scrollIntoView({ block: 'start' });
1959
+ }
1960
+ });
1961
+ }
1962
+ }
1963
+
1964
+ function showSourceView() {
1965
+ document.getElementById('diff-view')?.classList.add('hidden');
1966
+ document.getElementById('source-viewer')?.classList.remove('hidden');
1967
+ setTab('files');
1968
+ }
1969
+
1970
+ function saveUiState() {
1971
+ const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'changes';
1972
+ const sourcePath = document.getElementById('source-viewer')?.dataset.openPath || '';
1973
+ sessionStorage.setItem(uiStateKey, JSON.stringify({
1974
+ tab: activeTab,
1975
+ view: document.getElementById('source-viewer')?.classList.contains('hidden') ? 'diff' : 'source',
1976
+ sourcePath,
1977
+ hash: location.hash,
1978
+ }));
1979
+ }
1980
+
1981
+ function restoreUiState() {
1982
+ const raw = sessionStorage.getItem(uiStateKey);
1983
+ if (!raw) return false;
1984
+ try {
1985
+ const state = JSON.parse(raw);
1986
+ if (state.view === 'diff') {
1987
+ const match = String(state.hash || location.hash || '').match(/^#hunk-(\\d+)$/);
1988
+ setActive(match ? Number(match[1]) : current >= 0 ? current : 0, false);
1989
+ return true;
1990
+ }
1991
+ if (state.sourcePath && sourceByPath.has(state.sourcePath)) {
1992
+ openSourceFile(state.sourcePath);
1993
+ return true;
1994
+ }
1995
+ } catch {
1996
+ sessionStorage.removeItem(uiStateKey);
1997
+ }
1998
+ return false;
1999
+ }
2000
+
2001
+ async function checkForLiveUpdate() {
2002
+ if (checkingForUpdates) return;
2003
+ checkingForUpdates = true;
2004
+ const liveStatus = document.getElementById('live-status');
2005
+ try {
2006
+ const response = await fetch('/__ai_flow_state', { cache: 'no-store' });
2007
+ if (!response.ok) return;
2008
+ const state = await response.json();
2009
+ if (liveStatus && state.generatedAt) {
2010
+ liveStatus.textContent = 'Live: updated ' + new Date(state.generatedAt).toLocaleTimeString();
2011
+ }
2012
+ if (state.signature && state.signature !== currentSignature) {
2013
+ saveUiState();
2014
+ location.reload();
2015
+ }
2016
+ } catch {
2017
+ if (liveStatus) liveStatus.textContent = 'Live: waiting for diff server';
2018
+ } finally {
2019
+ checkingForUpdates = false;
2020
+ }
2021
+ }
2022
+
2023
+ function filterNavigation(rawQuery) {
2024
+ const query = rawQuery.trim().toLowerCase();
2025
+ links.forEach((link) => {
2026
+ const path = link.dataset.file || '';
2027
+ const source = sourceByPath.get(path);
2028
+ const haystack = (path + '\n' + (source?.content || '')).toLowerCase();
2029
+ link.hidden = query.length > 0 && !haystack.includes(query);
2030
+ });
2031
+ sourceLinks.forEach((link) => {
2032
+ const path = link.dataset.sourceFile || '';
2033
+ const source = sourceByPath.get(path);
2034
+ const haystack = (path + '\n' + (source?.content || '')).toLowerCase();
2035
+ link.hidden = query.length > 0 && !haystack.includes(query);
2036
+ });
2037
+ updateTreeVisibility(document.getElementById('changes-panel'), query);
2038
+ updateTreeVisibility(document.getElementById('files-panel'), query);
2039
+ }
2040
+
2041
+ function updateTreeVisibility(root, query) {
2042
+ if (!root) return;
2043
+ Array.from(root.querySelectorAll('details')).reverse().forEach((details) => {
2044
+ const hasVisibleLeaf = Array.from(details.children).some((child) => {
2045
+ if (child.tagName === 'SUMMARY') return false;
2046
+ return !child.hidden;
2047
+ });
2048
+ details.hidden = query.length > 0 && !hasVisibleLeaf;
2049
+ if (query.length > 0 && hasVisibleLeaf) details.open = true;
2050
+ });
2051
+ }
2052
+
2053
+ function openDefaultSourceFile() {
2054
+ const file = sourceFiles.find((candidate) => candidate.changed && candidate.embedded)
2055
+ || sourceFiles.find((candidate) => candidate.embedded)
2056
+ || sourceFiles.find((candidate) => candidate.changed)
2057
+ || sourceFiles[0];
2058
+ if (file) {
2059
+ openSourceFile(file.path);
2060
+ return;
2061
+ }
2062
+ if (hunkTotal() > 0) setActive(0, false);
2063
+ }
2064
+
2065
+ function handleSourceCopy(event) {
2066
+ const selection = window.getSelection();
2067
+ const sourceBody = document.getElementById('source-body');
2068
+ const viewer = document.getElementById('source-viewer');
2069
+ if (!selection || selection.isCollapsed || !sourceBody || !viewer || viewer.classList.contains('hidden')) return;
2070
+ if (!selection.anchorNode || !selection.focusNode) return;
2071
+ if (!sourceBody.contains(selection.anchorNode) || !sourceBody.contains(selection.focusNode)) return;
2072
+
2073
+ const path = viewer.dataset.openPath || '';
2074
+ const file = sourceByPath.get(path);
2075
+ if (!file || !file.embedded) return;
2076
+ const rows = selectedSourceRows(selection);
2077
+ if (rows.length === 0) return;
2078
+
2079
+ const lineNumbers = rows
2080
+ .map((row) => Number(row.dataset.lineIndex || 0) + 1)
2081
+ .filter((line) => Number.isFinite(line))
2082
+ .sort((a, b) => a - b);
2083
+ const startLine = lineNumbers[0];
2084
+ const endLine = lineNumbers[lineNumbers.length - 1];
2085
+ if (!startLine || !endLine) return;
2086
+
2087
+ const selectedText = cleanSelectedSourceText(selection.toString(), rows);
2088
+ const code = selectedText || sourceLinesForRows(file, rows);
2089
+ if (!code.trim()) return;
2090
+
2091
+ const reference = path + ':' + (startLine === endLine ? String(startLine) : startLine + '-' + endLine);
2092
+ const language = file.language && file.language !== 'text' ? file.language : '';
2093
+ const fence = String.fromCharCode(96).repeat(3);
2094
+ const payload = reference + '\n\n' + fence + language + '\n' + code.replace(/\s+$/g, '') + '\n' + fence;
2095
+ event.clipboardData?.setData('text/plain', payload);
2096
+ event.preventDefault();
2097
+ }
2098
+
2099
+ function selectedSourceRows(selection) {
2100
+ if (!selection.rangeCount) return [];
2101
+ const ranges = Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index));
2102
+ return Array.from(document.querySelectorAll('#source-body .source-row'))
2103
+ .filter((row) => ranges.some((range) => {
2104
+ try {
2105
+ return range.intersectsNode(row);
2106
+ } catch {
2107
+ return false;
2108
+ }
2109
+ }))
2110
+ .sort((a, b) => Number(a.dataset.lineIndex || 0) - Number(b.dataset.lineIndex || 0));
2111
+ }
2112
+
2113
+ function cleanSelectedSourceText(text, rows) {
2114
+ const value = String(text || '').replace(/\r/g, '').replace(/\u200b/g, '');
2115
+ if (!value.trim()) return '';
2116
+ const lineNumbers = rows.map((row) => Number(row.dataset.lineIndex || 0) + 1);
2117
+ const lines = value.split('\n');
2118
+ if (lines.length >= lineNumbers.length) {
2119
+ return lines
2120
+ .map((line, index) => {
2121
+ const lineNumber = lineNumbers[index];
2122
+ return lineNumber ? line.replace(new RegExp('^\\s*' + lineNumber + '\\s+'), '') : line;
2123
+ })
2124
+ .join('\n')
2125
+ .trimEnd();
2126
+ }
2127
+ return value.trimEnd();
2128
+ }
2129
+
2130
+ function sourceLinesForRows(file, rows) {
2131
+ const lines = file.content.split(/\r?\n/);
2132
+ return rows
2133
+ .map((row) => lines[Number(row.dataset.lineIndex || 0)] || '')
2134
+ .join('\n')
2135
+ .trimEnd();
2136
+ }
2137
+
2138
+ function handleSourceClick(event) {
2139
+ const target = event.target;
2140
+ const runBtn = target?.closest?.('.http-run');
2141
+ if (runBtn) {
2142
+ event.preventDefault();
2143
+ runHttpRequest(Number(runBtn.dataset.req));
2144
+ return;
2145
+ }
2146
+ const respToggle = target?.closest?.('.http-resp-toggle');
2147
+ if (respToggle) {
2148
+ event.preventDefault();
2149
+ const panel = respToggle.closest('.http-response')?.querySelector('.http-resp-headers');
2150
+ if (panel) panel.classList.toggle('hidden');
2151
+ return;
2152
+ }
2153
+ const row = target?.closest?.('.source-row');
2154
+ if (!row) return;
2155
+ clearTreeFocus();
2156
+ const viewer = document.getElementById('source-viewer');
2157
+ const path = viewer?.dataset.openPath || '';
2158
+ const file = sourceByPath.get(path);
2159
+ if (!file || !file.embedded) return;
2160
+ const lineIndex = Number(row.dataset.lineIndex || 0);
2161
+ const lines = file.content.split(/\r?\n/);
2162
+ const line = lines[lineIndex] || '';
2163
+ const codeCell = row.querySelector('.source-code');
2164
+ const column = estimateColumnFromClick(codeCell, event, line);
2165
+ setSourceCursor(path, lineIndex, column, false, -1);
2166
+ }
2167
+
2168
+ function estimateColumnFromClick(codeCell, event, line) {
2169
+ if (!codeCell) return 0;
2170
+ const rect = codeCell.getBoundingClientRect();
2171
+ const style = getComputedStyle(codeCell);
2172
+ const paddingLeft = Number.parseFloat(style.paddingLeft || '0') || 0;
2173
+ const x = event.clientX - rect.left - paddingLeft;
2174
+ const width = measuredCharWidth || measureCharWidth(codeCell);
2175
+ const column = Math.round(x / Math.max(width, 1));
2176
+ return Math.max(0, Math.min(line.length, column));
2177
+ }
2178
+
2179
+ function measureCharWidth(element) {
2180
+ const probe = document.createElement('span');
2181
+ probe.textContent = 'mmmmmmmmmm';
2182
+ probe.style.position = 'absolute';
2183
+ probe.style.visibility = 'hidden';
2184
+ probe.style.whiteSpace = 'pre';
2185
+ probe.style.font = getComputedStyle(element).font;
2186
+ document.body.appendChild(probe);
2187
+ const width = probe.getBoundingClientRect().width / 10;
2188
+ probe.remove();
2189
+ measuredCharWidth = width || 7;
2190
+ return measuredCharWidth;
2191
+ }
2192
+
2193
+ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLine = -1) {
2194
+ selectedCommentRow = null; // any explicit caret placement (click/move) ends a comment-box selection
2195
+ const file = sourceByPath.get(path);
2196
+ if (!file || !file.embedded) return;
2197
+ const lines = file.content.split(/\r?\n/);
2198
+ const boundedLine = Math.max(0, Math.min(lineIndex, Math.max(lines.length - 1, 0)));
2199
+ const boundedColumn = Math.max(0, Math.min(column, (lines[boundedLine] || '').length));
2200
+
2201
+ const prev = viewerCursor;
2202
+ const viewer = document.getElementById('source-viewer');
2203
+ // Fast path: the file is already on screen and only the caret moved. Re-rendering the whole
2204
+ // file on every keystroke blocks the main thread on large files, so patch just the previous
2205
+ // and new caret lines in place instead.
2206
+ const sameFileOpen = Boolean(viewer && viewer.dataset.openPath === path && !viewer.classList.contains('hidden')
2207
+ && prev && prev.path === path && !isHttpFile(path));
2208
+
2209
+ viewerCursor = { path, lineIndex: boundedLine, column: boundedColumn, targetLine };
2210
+
2211
+ if (sameFileOpen) {
2212
+ updateSourceCaret(prev, lines, file.language || 'text');
2213
+ } else {
2214
+ const shouldSwitch = !viewer || viewer.dataset.openPath !== path || viewer.classList.contains('hidden');
2215
+ openSourceFile(path, shouldSwitch);
2216
+ }
2217
+ if (shouldReveal) {
2218
+ requestAnimationFrame(() => {
2219
+ document.querySelector('.source-row.cursor-line')?.scrollIntoView({ block: 'center' });
2220
+ });
2221
+ }
2222
+ recordNav(navEntryOf('source'));
2223
+ }
2224
+
2225
+ // Move the caret by patching only the affected line cells, never the whole <table>. This keeps
2226
+ // large files responsive (no full re-highlight per keystroke) and, because the new caret line is
2227
+ // rebuilt with a fresh .code-cursor span, restarts the blink animation so the caret is solid the
2228
+ // instant it moves and only resumes blinking when idle.
2229
+ function updateSourceCaret(prev, lines, language) {
2230
+ const body = document.getElementById('source-body');
2231
+ if (!body) return;
2232
+ const rowFor = (idx) => body.querySelector('.source-row[data-line-index="' + idx + '"]');
2233
+ // Restore the line the caret left: drop the caret span, re-highlight the full line.
2234
+ if (prev && prev.lineIndex !== viewerCursor.lineIndex) {
2235
+ const prevRow = rowFor(prev.lineIndex);
2236
+ if (prevRow) {
2237
+ prevRow.classList.remove('cursor-line');
2238
+ const prevCell = prevRow.querySelector('.source-code');
2239
+ if (prevCell) prevCell.innerHTML = highlightLine(lines[prev.lineIndex] || '', language);
2240
+ }
2241
+ }
2242
+ // Reconcile the go-to-definition highlight (set only on symbol jumps, cleared on plain moves).
2243
+ body.querySelectorAll('.source-row.symbol-target').forEach((r) => r.classList.remove('symbol-target'));
2244
+ if (viewerCursor.targetLine >= 0) rowFor(viewerCursor.targetLine)?.classList.add('symbol-target');
2245
+ // Rebuild the new caret line with the caret span.
2246
+ const row = rowFor(viewerCursor.lineIndex);
2247
+ if (!row) { openSourceFile(viewerCursor.path, false); return; } // line not in the DOM — fall back to a full render
2248
+ row.classList.add('cursor-line');
2249
+ const cell = row.querySelector('.source-code');
2250
+ if (cell) cell.innerHTML = renderLineWithCursor(lines[viewerCursor.lineIndex] || '', language, viewerCursor.column);
2251
+ }
2252
+
2253
+ function openSourceAt(path, lineIndex, column) {
2254
+ setSourceCursor(path, lineIndex, column, true, lineIndex);
2255
+ }
2256
+
2257
+ function isSourceViewerVisible() {
2258
+ const viewer = document.getElementById('source-viewer');
2259
+ return Boolean(viewer && !viewer.classList.contains('hidden'));
2260
+ }
2261
+
2262
+ function openDiffFileAtCaret() {
2263
+ if (diffCursor && isDiffViewVisible()) {
2264
+ const dwrap = diffWrapperByPath(diffCursor.path);
2265
+ const drow = dwrap ? diffRowAt(dwrap, diffCursor.side, diffCursor.rowIndex) : null;
2266
+ const dline = drow ? diffLineNumber(drow) : null;
2267
+ if (sourceByPath.has(diffCursor.path)) { setSourceCursor(diffCursor.path, dline != null ? dline - 1 : 0, 0, true, -1); return; }
2268
+ openSourceFile(diffCursor.path); return;
2269
+ }
2270
+ const sel = window.getSelection();
2271
+ const node = sel && sel.anchorNode;
2272
+ const el = node ? (node.nodeType === 1 ? node : node.parentElement) : null;
2273
+ const wrapper = (el && el.closest && el.closest('.d2h-file-wrapper')) || document.querySelector('.d2h-file-wrapper:not(.df-inactive)');
2274
+ if (!wrapper) return;
2275
+ const fileName = (wrapper.querySelector('.d2h-file-name')?.textContent || '').trim();
2276
+ if (!fileName) return;
2277
+ if (!sourceByPath.has(fileName)) { openSourceFile(fileName); return; }
2278
+ let lineIndex = 0;
2279
+ const lineEl = el && el.closest && el.closest('.d2h-code-side-line');
2280
+ if (lineEl) {
2281
+ const row = lineEl.closest('tr');
2282
+ const numEl = row && row.querySelector('.d2h-code-side-linenumber');
2283
+ const num = numEl ? parseInt((numEl.textContent || '').trim(), 10) : NaN;
2284
+ if (Number.isFinite(num)) lineIndex = Math.max(0, num - 1);
2285
+ }
2286
+ setSourceCursor(fileName, lineIndex, 0, true, -1);
2287
+ }
2288
+
2289
+ // ----- Comment-box navigation: a box attached to a line is a selectable stop while moving the caret -----
2290
+ function commentRowSiblingOf(lineIndex, dir) {
2291
+ var cur = document.querySelector('#source-body .source-row[data-line-index="' + lineIndex + '"]');
2292
+ if (!cur) return null;
2293
+ var sib = dir < 0 ? cur.previousElementSibling : cur.nextElementSibling;
2294
+ return (sib && sib.classList && sib.classList.contains('mc-comment-row')) ? sib : null;
2295
+ }
2296
+ function selectCommentRow(row) {
2297
+ if (selectedCommentRow && selectedCommentRow !== row) selectedCommentRow.classList.remove('mc-row-selected');
2298
+ selectedCommentRow = row || null;
2299
+ if (!selectedCommentRow) return;
2300
+ selectedCommentRow.classList.add('mc-row-selected');
2301
+ // hide the text caret while the box is "selected" (no re-render happens during plain selection)
2302
+ document.querySelectorAll('#source-body .source-row.cursor-line').forEach(function (r) { r.classList.remove('cursor-line'); });
2303
+ document.querySelectorAll('#source-body .code-cursor').forEach(function (s) { var p = s.parentNode; if (p) { p.removeChild(s); if (p.normalize) p.normalize(); } });
2304
+ }
2305
+ function deleteCommentsInRow(row) {
2306
+ if (!row) return;
2307
+ var seqs = Array.prototype.slice.call(row.querySelectorAll('.mc-del')).map(function (b) { return parseInt(b.dataset.seq, 10); });
2308
+ selectedCommentRow = null;
2309
+ if (seqs.length) {
2310
+ reviewComments = reviewComments.filter(function (c) { return seqs.indexOf(c.seq) < 0; });
2311
+ saveComments();
2312
+ }
2313
+ refreshComments(); // remaining comment rows re-injected; the caret stays hidden until the next arrow press
2314
+ }
2315
+ function handleSourceCaretKey(event) {
2316
+ if (!viewerCursor) return false;
2317
+ var ae = document.activeElement;
2318
+ if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return false;
2319
+ const extend = event.shiftKey;
2320
+ // A comment box is selected (caret hidden): Backspace/Delete removes it; an arrow steps off it.
2321
+ if (selectedCommentRow) {
2322
+ if (event.key === 'Backspace' || event.key === 'Delete') { event.preventDefault(); deleteCommentsInRow(selectedCommentRow); return true; }
2323
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Escape') {
2324
+ var dir = event.key === 'ArrowUp' ? -1 : (event.key === 'ArrowDown' ? 1 : 0);
2325
+ var sib = dir < 0 ? selectedCommentRow.previousElementSibling : (dir > 0 ? selectedCommentRow.nextElementSibling : null);
2326
+ selectedCommentRow.classList.remove('mc-row-selected');
2327
+ selectedCommentRow = null;
2328
+ event.preventDefault();
2329
+ if (sib && sib.classList && sib.classList.contains('source-row')) {
2330
+ var li = parseInt(sib.dataset.lineIndex, 10);
2331
+ if (isFinite(li)) { setSourceCursor(viewerCursor.path, li, 0, true, -1); return true; }
2332
+ }
2333
+ setSourceCursor(viewerCursor.path, viewerCursor.lineIndex, viewerCursor.column, false, -1); // restore caret where it was
2334
+ return true;
2335
+ }
2336
+ return false;
2337
+ }
2338
+ // Plain Up/Down: a comment box between the caret line and the next line becomes a selectable stop.
2339
+ if (!extend && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
2340
+ var box = commentRowSiblingOf(viewerCursor.lineIndex, event.key === 'ArrowUp' ? -1 : 1);
2341
+ if (box) { event.preventDefault(); selectCommentRow(box); return true; }
2342
+ }
2343
+ if (event.key === 'ArrowDown') { event.preventDefault(); moveSourceCursor(1, 0, extend); return true; }
2344
+ if (event.key === 'ArrowUp') { event.preventDefault(); moveSourceCursor(-1, 0, extend); return true; }
2345
+ if (event.key === 'ArrowLeft') { event.preventDefault(); moveSourceCursor(0, -1, extend); return true; }
2346
+ if (event.key === 'ArrowRight') { event.preventDefault(); moveSourceCursor(0, 1, extend); return true; }
2347
+ return false;
2348
+ }
2349
+
2350
+ function moveSourceCursor(dLine, dColumn, extend) {
2351
+ if (!viewerCursor) return;
2352
+ const file = sourceByPath.get(viewerCursor.path);
2353
+ if (!file || !file.embedded) return;
2354
+ const lines = file.content.split(/\r?\n/);
2355
+ let line = viewerCursor.lineIndex;
2356
+ let col = viewerCursor.column;
2357
+ if (dColumn < 0) {
2358
+ if (col > 0) col -= 1;
2359
+ else if (line > 0) { line -= 1; col = (lines[line] || '').length; }
2360
+ } else if (dColumn > 0) {
2361
+ if (col < (lines[line] || '').length) col += 1;
2362
+ else if (line < lines.length - 1) { line += 1; col = 0; }
2363
+ }
2364
+ if (dLine !== 0) {
2365
+ line = Math.max(0, Math.min(lines.length - 1, line + dLine));
2366
+ col = Math.min(col, (lines[line] || '').length);
2367
+ }
2368
+ if (extend) {
2369
+ if (!selectionAnchor) selectionAnchor = { lineIndex: viewerCursor.lineIndex, column: viewerCursor.column };
2370
+ } else {
2371
+ selectionAnchor = null;
2372
+ }
2373
+ setSourceCursor(viewerCursor.path, line, col, true, -1);
2374
+ applySourceSelection();
2375
+ }
2376
+ // Word boundary in text from col in direction dir (+1 next, -1 prev): skip non-word, then word.
2377
+ function nextWordBoundary(text, col, dir) {
2378
+ // Classify like vim's word motions: 0 = whitespace, 1 = word char, 2 = punctuation.
2379
+ // A run of word chars and a run of punctuation are each their own "word", so the
2380
+ // caret lands on the START of the next word/punctuation run (vim 'w'), or the start
2381
+ // of the previous one (vim 'b') -- never stranded in the middle of whitespace.
2382
+ var classOf = function (ch) {
2383
+ if (ch === '' || /\s/.test(ch)) return 0;
2384
+ if (/[A-Za-z0-9_$]/.test(ch)) return 1;
2385
+ return 2;
2386
+ };
2387
+ var i = col;
2388
+ if (dir > 0) {
2389
+ var cf = classOf(text.charAt(i));
2390
+ if (cf !== 0) { while (i < text.length && classOf(text.charAt(i)) === cf) i++; }
2391
+ while (i < text.length && classOf(text.charAt(i)) === 0) i++;
2392
+ } else {
2393
+ i--;
2394
+ while (i > 0 && classOf(text.charAt(i)) === 0) i--;
2395
+ var cb = classOf(text.charAt(i));
2396
+ while (i > 0 && classOf(text.charAt(i - 1)) === cb) i--;
2397
+ if (i < 0) i = 0;
2398
+ }
2399
+ return i;
2400
+ }
2401
+ function moveSourceWord(dir, extend) {
2402
+ if (!viewerCursor) return;
2403
+ var file = sourceByPath.get(viewerCursor.path);
2404
+ if (!file || !file.embedded) return;
2405
+ var lines = file.content.split(/\r?\n/);
2406
+ var line = viewerCursor.lineIndex, col = viewerCursor.column;
2407
+ var text = lines[line] || '';
2408
+ if (dir > 0) {
2409
+ var fwd = nextWordBoundary(text, col, 1);
2410
+ if (fwd < text.length || line >= lines.length - 1) { col = fwd; }
2411
+ else { line += 1; var nt = lines[line] || ''; var m = nt.search(/\S/); col = m < 0 ? 0 : m; }
2412
+ } else {
2413
+ var back = nextWordBoundary(text, col, -1);
2414
+ if (back < col && /\S/.test(text.charAt(back))) { col = back; }
2415
+ else if (line > 0) { line -= 1; var pt = lines[line] || ''; col = pt.length > 0 ? nextWordBoundary(pt, pt.length, -1) : 0; }
2416
+ else { col = back; }
2417
+ }
2418
+ if (extend) { if (!selectionAnchor) selectionAnchor = { lineIndex: viewerCursor.lineIndex, column: viewerCursor.column }; }
2419
+ else selectionAnchor = null;
2420
+ setSourceCursor(viewerCursor.path, line, col, true, -1);
2421
+ applySourceSelection();
2422
+ }
2423
+
2424
+ function applySourceSelection() {
2425
+ const sel = window.getSelection();
2426
+ if (!sel) return;
2427
+ if (!selectionAnchor || !viewerCursor) { sel.removeAllRanges(); return; }
2428
+ const a = caretDomPosition(selectionAnchor.lineIndex, selectionAnchor.column);
2429
+ const c = caretDomPosition(viewerCursor.lineIndex, viewerCursor.column);
2430
+ if (a && c) {
2431
+ try { sel.setBaseAndExtent(a.node, a.offset, c.node, c.offset); } catch (e) {}
2432
+ }
2433
+ }
2434
+
2435
+ function caretDomPosition(lineIndex, column) {
2436
+ const cell = document.querySelector('.source-row[data-line-index="' + lineIndex + '"] .source-code');
2437
+ if (!cell) return null;
2438
+ let remaining = column;
2439
+ const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT);
2440
+ let node;
2441
+ while ((node = walker.nextNode())) {
2442
+ const len = node.textContent.length;
2443
+ if (remaining <= len) return { node, offset: remaining };
2444
+ remaining -= len;
2445
+ }
2446
+ return { node: cell, offset: cell.childNodes.length };
2447
+ }
2448
+
2449
+ function wordAtCursor() {
2450
+ if (!viewerCursor) return null;
2451
+ const file = sourceByPath.get(viewerCursor.path);
2452
+ if (!file || !file.embedded) return null;
2453
+ const line = file.content.split(/\r?\n/)[viewerCursor.lineIndex] || '';
2454
+ const column = Math.max(0, Math.min(viewerCursor.column, line.length));
2455
+ const identifier = /[A-Za-z_$][A-Za-z0-9_$]*/g;
2456
+ let match = null;
2457
+ while ((match = identifier.exec(line))) {
2458
+ const start = match.index;
2459
+ const end = start + match[0].length;
2460
+ if (column >= start && column <= end) {
2461
+ return { name: match[0], path: viewerCursor.path, lineIndex: viewerCursor.lineIndex, column: start };
2462
+ }
2463
+ }
2464
+ return null;
2465
+ }
2466
+
2467
+ function goToSymbolUnderCursor() {
2468
+ const symbol = wordAtCursor();
2469
+ if (symbol) goToDefOrUsages(symbol.name);
2470
+ }
2471
+ // Cmd+B: on a declaration, show its usages (navigate if there's only one); elsewhere, go to the definition.
2472
+ function goToDefOrUsages(name) {
2473
+ if (!name) return;
2474
+ if (REVIEW_LAZY_LOAD && !sourceLoaded) { pendingSymbol = name; loadSourceData(); return; } // load source+index on first use
2475
+ var def = findSymbolDefinition(name);
2476
+ var loc = caretSourceLoc();
2477
+ if (def && loc && def.path === loc.path && def.lineIndex === loc.lineIndex) {
2478
+ openUsages(name, def);
2479
+ return;
2480
+ }
2481
+ if (def) openSourceAt(def.path, def.lineIndex, def.column);
2482
+ }
2483
+ // Where the caret sits, mapped to a source (path, lineIndex). In the diff, only the new side maps cleanly.
2484
+ function caretSourceLoc() {
2485
+ if (isSourceViewerVisible() && viewerCursor) return { path: viewerCursor.path, lineIndex: viewerCursor.lineIndex };
2486
+ if (isDiffViewVisible() && diffCursor && diffCursor.side === 'new') {
2487
+ var wrap = diffWrapperByPath(diffCursor.path);
2488
+ var row = wrap ? diffRowAt(wrap, diffCursor.side, diffCursor.rowIndex) : null;
2489
+ var ln = row ? diffLineNumber(row) : null;
2490
+ if (ln != null) return { path: diffCursor.path, lineIndex: ln - 1 };
2491
+ }
2492
+ return null;
2493
+ }
2494
+ // All word-boundary occurrences of name across embedded files, excluding the declaration line itself.
2495
+ function findUsages(name, defPath, defLine) {
2496
+ var re;
2497
+ try { re = new RegExp('(^|[^A-Za-z0-9_$])' + escapeRegExp(name) + '(?![A-Za-z0-9_$])'); } catch (e) { return []; }
2498
+ var out = [];
2499
+ for (var fi = 0; fi < sourceFiles.length; fi++) {
2500
+ var f = sourceFiles[fi];
2501
+ if (!f.embedded) continue;
2502
+ var lines = String(f.content).split(/\r?\n/);
2503
+ for (var li = 0; li < lines.length; li++) {
2504
+ if (f.path === defPath && li === defLine) continue;
2505
+ var m = re.exec(lines[li]);
2506
+ if (m) {
2507
+ out.push({ path: f.path, lineIndex: li, column: m.index + (m[1] ? m[1].length : 0), text: lines[li] });
2508
+ if (out.length >= 500) return out;
2509
+ }
2510
+ }
2511
+ }
2512
+ return out;
2513
+ }
2514
+ function openUsages(name, def) {
2515
+ var items = findUsages(name, def.path, def.lineIndex);
2516
+ if (items.length === 1) { openSourceAt(items[0].path, items[0].lineIndex, items[0].column); return; }
2517
+ usageItems = items;
2518
+ usageActive = 0;
2519
+ showUsages(name, items.length);
2520
+ }
2521
+ function showUsages(name, count) {
2522
+ var box = document.getElementById('usages');
2523
+ var title = document.getElementById('usages-title');
2524
+ if (!box) return;
2525
+ if (title) title.textContent = count + ' usage' + (count === 1 ? '' : 's') + ' of ' + name;
2526
+ renderUsages();
2527
+ box.classList.remove('hidden');
2528
+ }
2529
+ function renderUsages() {
2530
+ var results = document.getElementById('usages-results');
2531
+ if (!results) return;
2532
+ if (!usageItems.length) { results.innerHTML = '<div class="quick-open-empty">No usages found.</div>'; return; }
2533
+ results.innerHTML = usageItems.map(function (item, index) {
2534
+ var fname = item.path.split('/').pop();
2535
+ return '<button type="button" class="quick-open-item usage-item' + (index === usageActive ? ' active' : '') + '" data-index="' + index + '">'
2536
+ + '<span class="usage-loc">' + escapeHtml(fname) + ':' + (item.lineIndex + 1) + '</span>'
2537
+ + '<span class="usage-code">' + escapeHtml(item.text.replace(/^\s+/, '').slice(0, 160)) + '</span>'
2538
+ + '</button>';
2539
+ }).join('');
2540
+ updateUsageActive();
2541
+ }
2542
+ function updateUsageActive() {
2543
+ var results = document.getElementById('usages-results');
2544
+ if (!results) return;
2545
+ var items = results.querySelectorAll('.usage-item');
2546
+ for (var i = 0; i < items.length; i++) {
2547
+ var on = i === usageActive;
2548
+ items[i].classList.toggle('active', on);
2549
+ if (on && items[i].scrollIntoView) items[i].scrollIntoView({ block: 'nearest' });
2550
+ }
2551
+ }
2552
+ function handleUsagesKey(event) {
2553
+ if (event.key === 'Escape') { event.preventDefault(); closeUsages(); return true; }
2554
+ if (event.key === 'ArrowDown') { event.preventDefault(); usageActive = Math.min(usageActive + 1, usageItems.length - 1); updateUsageActive(); return true; }
2555
+ if (event.key === 'ArrowUp') { event.preventDefault(); usageActive = Math.max(usageActive - 1, 0); updateUsageActive(); return true; }
2556
+ if (event.key === 'Enter') { event.preventDefault(); openUsageItem(usageItems[usageActive]); return true; }
2557
+ return false;
2558
+ }
2559
+ function openUsageItem(item) {
2560
+ if (!item) return;
2561
+ closeUsages();
2562
+ openSourceAt(item.path, item.lineIndex, item.column);
2563
+ }
2564
+ function closeUsages() {
2565
+ document.getElementById('usages')?.classList.add('hidden');
2566
+ }
2567
+
2568
+ var symbolIndex = null; // Map<name, [{path,lineIndex,column}]>; built off-thread by a Web Worker, null until ready
2569
+ function symbolIndexWorker() {
2570
+ self.onmessage = function (e) {
2571
+ var files = e.data || [];
2572
+ var patterns = [
2573
+ /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
2574
+ /^\s*(?:(?:public|private|protected|internal|abstract|final|open|sealed|data|inner|annotation|static|export|default|expect|actual|value)\s+)*(?:class|interface|object|enum|trait|struct)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
2575
+ /^\s*(?:export\s+)?(?:interface|type|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
2576
+ /^\s*(?:export\s+)?(?:const|let|var|val)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
2577
+ /^\s*(?:(?:public|private|protected|internal|abstract|final|open|override|suspend|inline|operator|static|async)\s+)*(?:fun|def|fn|func)\s+([A-Za-z_$][A-Za-z0-9_$]*)/
2578
+ ];
2579
+ var index = new Map();
2580
+ var total = files.length;
2581
+ var step = Math.max(1, Math.floor(total / 20)); // ~20 progress ticks regardless of repo size
2582
+ for (var fi = 0; fi < total; fi++) {
2583
+ var p = files[fi].path;
2584
+ var lines = String(files[fi].content || '').split(/\r?\n/);
2585
+ for (var li = 0; li < lines.length; li++) {
2586
+ var line = lines[li];
2587
+ for (var pi = 0; pi < patterns.length; pi++) {
2588
+ var m = patterns[pi].exec(line);
2589
+ if (m && m[1]) {
2590
+ var arr = index.get(m[1]);
2591
+ if (!arr) { arr = []; index.set(m[1], arr); }
2592
+ arr.push({ path: p, lineIndex: li, column: Math.max(0, line.indexOf(m[1])) });
2593
+ break;
2594
+ }
2595
+ }
2596
+ }
2597
+ if ((fi + 1) % step === 0 && fi + 1 < total) self.postMessage({ done: fi + 1, total: total });
2598
+ }
2599
+ self.postMessage({ index: index, total: total });
2600
+ };
2601
+ }
2602
+ function startSymbolIndex() {
2603
+ try {
2604
+ if (typeof Worker === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) return;
2605
+ var src = '(' + symbolIndexWorker.toString() + ')()';
2606
+ var url = URL.createObjectURL(new Blob([src], { type: 'application/javascript' }));
2607
+ var worker = new Worker(url);
2608
+ worker.onmessage = function (e) {
2609
+ var msg = e.data;
2610
+ if (msg && msg.index) { // final index
2611
+ symbolIndex = msg.index;
2612
+ setIndexProgress(msg.total, msg.total);
2613
+ try { worker.terminate(); } catch (x) {}
2614
+ try { URL.revokeObjectURL(url); } catch (x) {}
2615
+ } else if (msg && typeof msg.done === 'number') { // progress tick
2616
+ setIndexProgress(msg.done, msg.total);
2617
+ }
2618
+ };
2619
+ worker.onerror = function () { setIndexProgress(1, 1); try { worker.terminate(); } catch (x) {} };
2620
+ var payload = [];
2621
+ for (var i = 0; i < sourceFiles.length; i++) {
2622
+ if (sourceFiles[i].embedded) payload.push({ path: sourceFiles[i].path, content: sourceFiles[i].content });
2623
+ }
2624
+ setIndexProgress(0, payload.length);
2625
+ worker.postMessage(payload);
2626
+ } catch (err) { /* Worker unavailable -> scan fallback remains in effect */ }
2627
+ }
2628
+ // Drive the go-to-definition indexing progress bar in the toolbar status. Hidden when done / not running.
2629
+ function setIndexProgress(done, total) {
2630
+ var el = document.getElementById('index-status');
2631
+ var bar = document.getElementById('index-progress');
2632
+ if (!el) return;
2633
+ if (!total || done >= total) {
2634
+ el.textContent = (total || 0) + ' indexed';
2635
+ if (bar) bar.classList.add('hidden');
2636
+ return;
2637
+ }
2638
+ el.textContent = 'indexing ' + done + '/' + total + '…';
2639
+ if (bar) {
2640
+ bar.classList.remove('hidden');
2641
+ var fill = bar.firstElementChild;
2642
+ if (fill) fill.style.width = Math.round(done / total * 100) + '%';
2643
+ }
2644
+ }
2645
+ function wordAtDiffCaret() {
2646
+ if (!diffCursor) return null;
2647
+ var wrapper = diffWrapperByPath(diffCursor.path);
2648
+ if (!wrapper) return null;
2649
+ var text = diffLineText(diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex));
2650
+ var column = Math.max(0, Math.min(diffCursor.column, text.length));
2651
+ var identifier = /[A-Za-z_$][A-Za-z0-9_$]*/g;
2652
+ var match = null;
2653
+ while ((match = identifier.exec(text))) {
2654
+ if (column >= match.index && column <= match.index + match[0].length) return match[0];
2655
+ }
2656
+ return null;
2657
+ }
2658
+ function goToSymbolFromDiff() {
2659
+ goToDefOrUsages(wordAtDiffCaret());
2660
+ }
2661
+ function findSymbolDefinition(name) {
2662
+ if (symbolIndex) {
2663
+ var hits = symbolIndex.get(name);
2664
+ if (hits && hits.length) {
2665
+ var cur = (viewerCursor && viewerCursor.path) || (diffCursor && diffCursor.path) || '';
2666
+ for (var i = 0; i < hits.length; i++) { if (hits[i].path === cur) return hits[i]; }
2667
+ return hits[0];
2668
+ }
2669
+ }
2670
+ const matchers = definitionMatchers(name);
2671
+ const currentPath = viewerCursor?.path || '';
2672
+ const orderedFiles = [
2673
+ ...sourceFiles.filter((file) => file.path === currentPath),
2674
+ ...sourceFiles.filter((file) => file.path !== currentPath),
2675
+ ].filter((file) => file.embedded);
2676
+
2677
+ for (const file of orderedFiles) {
2678
+ const lines = file.content.split(/\r?\n/);
2679
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
2680
+ const line = lines[lineIndex];
2681
+ if (matchers.some((matcher) => matcher.test(line))) {
2682
+ return { path: file.path, lineIndex, column: Math.max(0, line.indexOf(name)) };
2683
+ }
2684
+ }
2685
+ }
2686
+ return null;
2687
+ }
2688
+
2689
+ function definitionMatchers(name) {
2690
+ const escaped = escapeRegExp(name);
2691
+ const mod = '(?:(?:public|private|protected|internal|abstract|final|open|sealed|data|inner|enum|annotation|static|export|default|expect|actual|value)\\s+)*';
2692
+ const funMod = '(?:(?:public|private|protected|internal|abstract|final|open|override|suspend|inline|operator|static|async)\\s+)*';
2693
+ return [
2694
+ new RegExp('^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s+' + escaped + '\\b'),
2695
+ new RegExp('^\\s*' + mod + '(?:class|interface|object|enum|trait|struct)\\s+' + escaped + '\\b'),
2696
+ new RegExp('^\\s*(?:export\\s+)?(?:interface|type|enum)\\s+' + escaped + '\\b'),
2697
+ new RegExp('^\\s*(?:export\\s+)?(?:const|let|var|val)\\s+' + escaped + '\\b'),
2698
+ new RegExp('^\\s*' + funMod + '(?:fun|def|fn|func)\\s+' + escaped + '\\b'),
2699
+ new RegExp('^\\s*' + funMod + escaped + '\\s*\\([^)]*\\)\\s*(?::\\s*[^=]+)?\\s*(?:\\{|=>)'),
2700
+ new RegExp('^\\s*' + escaped + '\\s*[:=]\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*=>)'),
2701
+ ];
2702
+ }
2703
+
2704
+ function escapeRegExp(value) {
2705
+ return String(value).replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
2706
+ }
2707
+
2708
+ function setSourceTypeIcon(path) {
2709
+ var holder = document.getElementById('source-type-icon');
2710
+ if (!holder) return;
2711
+ var link = sourceLinks.find(function (l) { return l.dataset.sourceFile === path; });
2712
+ var icon = link ? link.querySelector('.ftype') : null;
2713
+ holder.innerHTML = icon ? icon.outerHTML : '';
2714
+ }
2715
+ function openSourceFile(path, shouldSwitch = true) {
2716
+ const file = sourceByPath.get(path);
2717
+ if (!file) return;
2718
+ // lazy-LOAD: source content not fetched yet -> show a loading state; loadSourceData re-opens it.
2719
+ if (REVIEW_LAZY_LOAD && !sourceLoaded && file.embedded) {
2720
+ pendingSourceOpen = { path: path, shouldSwitch: shouldSwitch };
2721
+ loadSourceData();
2722
+ document.getElementById('source-viewer').dataset.openPath = path;
2723
+ sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
2724
+ renderBreadcrumb(document.getElementById('source-title'), path);
2725
+ setSourceTypeIcon(path);
2726
+ revealTreeFor(path);
2727
+ var lb = document.getElementById('source-body');
2728
+ lb.className = 'source-body empty';
2729
+ lb.textContent = 'Loading source…';
2730
+ if (shouldSwitch) showSourceView();
2731
+ return;
2732
+ }
2733
+ rememberRecent(path, 'source');
2734
+ document.getElementById('source-viewer').dataset.openPath = path;
2735
+ sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
2736
+ renderBreadcrumb(document.getElementById('source-title'), path);
2737
+ setSourceTypeIcon(path);
2738
+ revealTreeFor(path);
2739
+ const meta = [
2740
+ file.language || 'text',
2741
+ formatBytes(file.size || 0),
2742
+ file.changed ? 'changed' : 'unchanged',
2743
+ file.embedded ? 'searchable' : file.skippedReason || 'not embedded',
2744
+ ].join(' | ');
2745
+ document.getElementById('source-meta').textContent = meta;
2746
+ const body = document.getElementById('source-body');
2747
+ // Image files carry a data: URI preview instead of text — render inline (click to zoom).
2748
+ if (file.image) {
2749
+ body.className = 'source-body image-body';
2750
+ body.innerHTML = renderImageView(file);
2751
+ document.getElementById('http-env-select')?.classList.add('hidden');
2752
+ updateRenderToggle(path);
2753
+ if (shouldSwitch) showSourceView();
2754
+ return;
2755
+ }
2756
+ if (!file.embedded) {
2757
+ body.className = 'source-body empty';
2758
+ body.textContent = file.skippedReason ? 'Source preview unavailable: ' + file.skippedReason + '.' : 'Source preview unavailable.';
2759
+ document.getElementById('http-env-select')?.classList.add('hidden');
2760
+ updateRenderToggle(path);
2761
+ if (shouldSwitch) showSourceView();
2762
+ return;
2763
+ }
2764
+ if (!viewerCursor || viewerCursor.path !== path) {
2765
+ viewerCursor = { path, lineIndex: 0, column: 0, targetLine: -1 };
2766
+ }
2767
+ body.className = 'source-body';
2768
+ const httpEnvSelect = document.getElementById('http-env-select');
2769
+ // Markdown/CSV render to HTML but stay a line-numbered .source-table: each block (md) or record (csv)
2770
+ // is a .source-row keyed by its start line, so the gutter shows line numbers and line/block comments
2771
+ // work exactly as in the plain source view (renderSourceComments anchors on .source-row[data-line-index]).
2772
+ if (isMarkdownPath(path)) {
2773
+ body.classList.add('rendered-body');
2774
+ body.innerHTML = renderMarkdownRows(file.content);
2775
+ if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
2776
+ renderSourceComments();
2777
+ if (shouldSwitch) showSourceView();
2778
+ return;
2779
+ }
2780
+ if (isCsvPath(path)) {
2781
+ body.classList.add('rendered-body');
2782
+ body.innerHTML = renderCsvRows(file.content, path);
2783
+ if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
2784
+ renderSourceComments();
2785
+ if (shouldSwitch) showSourceView();
2786
+ return;
2787
+ }
2788
+ if (isHttpFile(path)) {
2789
+ body.innerHTML = renderHttpTable(file);
2790
+ if (httpEnvSelect) httpEnvSelect.classList.toggle('hidden', httpEnvNames.length === 0);
2791
+ } else {
2792
+ body.innerHTML = renderSourceTable(file, '');
2793
+ if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
2794
+ }
2795
+ renderSourceComments();
2796
+ if (shouldSwitch) showSourceView();
2797
+ }
2798
+
2799
+ function isMarkdownPath(p) { return /\.(md|mdx|markdown)$/i.test(p || ''); }
2800
+ function isCsvPath(p) { return /\.(csv|tsv)$/i.test(p || ''); }
2801
+
2802
+ function renderImageView(file) {
2803
+ return '<div class="image-view">'
2804
+ + '<img class="image-preview" src="' + file.image + '" alt="' + escapeHtml(file.name) + '" data-zoomable="1">'
2805
+ + '<div class="image-cap">' + escapeHtml(file.name) + ' &middot; ' + formatBytes(file.size || 0) + ' &middot; click to zoom</div>'
2806
+ + '</div>';
2807
+ }
2808
+
2809
+ function openLightbox(src, alt) {
2810
+ if (!src) return;
2811
+ var lb = document.getElementById('mc-lightbox');
2812
+ if (!lb) {
2813
+ lb = document.createElement('div');
2814
+ lb.id = 'mc-lightbox';
2815
+ lb.className = 'mc-lightbox hidden';
2816
+ lb.innerHTML = '<img class="mc-lightbox-img" alt="">';
2817
+ document.body.appendChild(lb);
2818
+ lb.addEventListener('click', closeLightbox);
2819
+ }
2820
+ var img = lb.querySelector('img');
2821
+ img.src = src;
2822
+ img.alt = alt || '';
2823
+ lb.classList.remove('hidden');
2824
+ }
2825
+ function closeLightbox() {
2826
+ var lb = document.getElementById('mc-lightbox');
2827
+ if (lb) lb.classList.add('hidden');
2828
+ }
2829
+ function lightboxOpen() {
2830
+ var lb = document.getElementById('mc-lightbox');
2831
+ return !!(lb && !lb.classList.contains('hidden'));
2832
+ }
2833
+
2834
+ // Minimal, dependency-free Markdown -> HTML for the preview pane. Input is escaped before any
2835
+ // markup is applied; links/images are restricted to http(s)/data/mailto/anchor targets.
2836
+ function renderInlineMd(text) {
2837
+ var s = escapeHtml(text);
2838
+ s = s.replace(/`([^`]+)`/g, function (m, code) { return '<code>' + code + '</code>'; });
2839
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/g, function (m, alt, url) {
2840
+ return /^(https?:|data:)/i.test(url) ? '<img class="md-img" src="' + url + '" alt="' + alt + '">' : m;
2841
+ });
2842
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/g, function (m, label, url) {
2843
+ return /^(https?:|mailto:|#)/i.test(url) ? '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a>' : label;
2844
+ });
2845
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/__([^_]+)__/g, '<strong>$1</strong>');
2846
+ s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>').replace(/(^|[^_\w])_([^_\s][^_]*)_/g, '$1<em>$2</em>');
2847
+ s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
2848
+ return s;
2849
+ }
2850
+
2851
+ function mdFenceLang(lang) {
2852
+ var l = (lang || '').toLowerCase();
2853
+ if (l === 'js' || l === 'jsx' || l === 'ts' || l === 'tsx') return 'typescript';
2854
+ if (l === 'sh' || l === 'bash' || l === 'zsh') return 'shell';
2855
+ if (l === 'yml') return 'yaml';
2856
+ return l || 'text';
2857
+ }
2858
+
2859
+ function splitTableRow(line) {
2860
+ return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return c.trim(); });
2861
+ }
2862
+
2863
+ // Parse markdown into block objects { line, html } where line is the 0-based start line in the source.
2864
+ // Each block becomes one .source-row so the rendered view keeps a real line gutter + line comments.
2865
+ function renderMarkdownBlocks(content) {
2866
+ var lines = String(content).split(/\r?\n/);
2867
+ var blocks = [];
2868
+ var i = 0;
2869
+ var m;
2870
+ while (i < lines.length) {
2871
+ var start = i;
2872
+ var line = lines[i];
2873
+ var fence = line.match(/^(\s*)(```+|~~~+)\s*([\w+#-]*)\s*$/);
2874
+ if (fence) {
2875
+ var marker = fence[2].charAt(0);
2876
+ var closeRe = new RegExp('^\\s*' + (marker === '`' ? '`' : '~') + '{3,}\\s*$');
2877
+ var lang = mdFenceLang(fence[3]);
2878
+ var buf = [];
2879
+ i++;
2880
+ while (i < lines.length && !closeRe.test(lines[i])) { buf.push(lines[i]); i++; }
2881
+ i++;
2882
+ blocks.push({ line: start, html: '<pre class="md-code"><code>' + buf.map(function (l) { return highlightLine(l, lang); }).join('\n') + '</code></pre>' });
2883
+ continue;
2884
+ }
2885
+ if (/^\s*$/.test(line)) { i++; continue; }
2886
+ var h = line.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
2887
+ if (h) { var lv = h[1].length; blocks.push({ line: start, html: '<h' + lv + ' class="md-h md-h' + lv + '">' + renderInlineMd(h[2].replace(/\s+#+\s*$/, '')) + '</h' + lv + '>' }); i++; continue; }
2888
+ if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) { blocks.push({ line: start, html: '<hr class="md-hr">' }); i++; continue; }
2889
+ if (/^\s*>\s?/.test(line)) {
2890
+ var qbuf = [];
2891
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) { qbuf.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
2892
+ blocks.push({ line: start, html: '<blockquote class="md-quote">' + qbuf.map(function (l) { return l.trim() ? '<p>' + renderInlineMd(l) + '</p>' : ''; }).join('') + '</blockquote>' });
2893
+ continue;
2894
+ }
2895
+ if (/\|/.test(line) && i + 1 < lines.length && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1])) {
2896
+ var header = splitTableRow(line);
2897
+ i += 2;
2898
+ var rowsHtml = '';
2899
+ while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) {
2900
+ var cells = splitTableRow(lines[i]);
2901
+ rowsHtml += '<tr>' + header.map(function (_h, ci) { return '<td>' + renderInlineMd(cells[ci] || '') + '</td>'; }).join('') + '</tr>';
2902
+ i++;
2903
+ }
2904
+ blocks.push({ line: start, html: '<table class="md-table"><thead><tr>' + header.map(function (c) { return '<th>' + renderInlineMd(c) + '</th>'; }).join('') + '</tr></thead><tbody>' + rowsHtml + '</tbody></table>' });
2905
+ continue;
2906
+ }
2907
+ if ((m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/))) {
2908
+ var type = /\d/.test(m[2]) ? 'ol' : 'ul';
2909
+ var items = '';
2910
+ while (i < lines.length && (m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/))) { items += '<li>' + renderInlineMd(m[3]) + '</li>'; i++; }
2911
+ blocks.push({ line: start, html: '<' + type + ' class="md-list">' + items + '</' + type + '>' });
2912
+ continue;
2913
+ }
2914
+ var pbuf = [line];
2915
+ i++;
2916
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(\s{0,3}#{1,6}\s|\s*>|\s*([-*+]|\d+[.)])\s|\s*(```|~~~))/.test(lines[i])) { pbuf.push(lines[i]); i++; }
2917
+ blocks.push({ line: start, html: '<p class="md-p">' + renderInlineMd(pbuf.join('\n')).replace(/\n/g, '<br>') + '</p>' });
2918
+ }
2919
+ return blocks;
2920
+ }
2921
+
2922
+ function renderMarkdownRows(content) {
2923
+ var blocks = renderMarkdownBlocks(content);
2924
+ if (!blocks.length) return '<table class="source-table md-doc"><tbody></tbody></table>';
2925
+ var rows = blocks.map(function (b) {
2926
+ return '<tr class="source-row md-row" data-line-index="' + b.line + '"><td class="num">' + (b.line + 1) + '</td><td class="source-code md-cell">' + b.html + '</td></tr>';
2927
+ }).join('');
2928
+ return '<table class="source-table md-doc"><tbody>' + rows + '</tbody></table>';
2929
+ }
2930
+
2931
+ // RFC-4180-ish delimited parser: handles quoted fields with embedded delimiters, newlines, and "" escapes.
2932
+ function parseDelimited(content, delim) {
2933
+ var rows = [];
2934
+ var row = [];
2935
+ var field = '';
2936
+ var inQuotes = false;
2937
+ var s = String(content);
2938
+ for (var i = 0; i < s.length; i++) {
2939
+ var ch = s[i];
2940
+ if (inQuotes) {
2941
+ if (ch === '"') {
2942
+ if (s[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
2943
+ } else field += ch;
2944
+ } else if (ch === '"') {
2945
+ inQuotes = true;
2946
+ } else if (ch === delim) {
2947
+ row.push(field); field = '';
2948
+ } else if (ch === '\n') {
2949
+ row.push(field); rows.push(row); row = []; field = '';
2950
+ } else if (ch !== '\r') {
2951
+ field += ch;
2952
+ }
2953
+ }
2954
+ if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
2955
+ return rows;
2956
+ }
2957
+
2958
+ // CSV/TSV renders to an aligned table that is still a .source-table: each record is a .source-row keyed
2959
+ // by its record index (data-line-index) so line numbers show in the gutter and comments anchor per row.
2960
+ function renderCsvRows(content, path) {
2961
+ var delim = /\.tsv$/i.test(path || '') ? '\t' : ',';
2962
+ var records = parseDelimited(content, delim).filter(function (r) { return !(r.length === 1 && r[0] === ''); });
2963
+ if (!records.length) return '<table class="source-table csv-doc"><tbody></tbody></table>';
2964
+ var cols = records.reduce(function (max, r) { return Math.max(max, r.length); }, 0);
2965
+ var rows = records.map(function (rec, idx) {
2966
+ var head = idx === 0;
2967
+ var cells = '';
2968
+ for (var c = 0; c < cols; c++) {
2969
+ var v = escapeHtml(rec[c] == null ? '' : rec[c]);
2970
+ cells += head ? '<th class="csv-cell">' + v + '</th>' : '<td class="csv-cell">' + v + '</td>';
2971
+ }
2972
+ return '<tr class="source-row csv-row' + (head ? ' csv-head' : '') + '" data-line-index="' + idx + '"><td class="num">' + (idx + 1) + '</td>' + cells + '</tr>';
2973
+ }).join('');
2974
+ return '<table class="source-table csv-doc"><tbody>' + rows + '</tbody></table>';
2975
+ }
2976
+
2977
+ function isHttpFile(path) {
2978
+ return /\.(http|rest)$/i.test(path || '');
2979
+ }
2980
+
2981
+ function currentHttpEnv() {
2982
+ return httpEnvironments[currentHttpEnvName] || {};
2983
+ }
2984
+
2985
+ function applyHttpVars(text, env) {
2986
+ return String(text == null ? '' : text).replace(/\{\{\s*([\w.$-]+)\s*\}\}/g, function (whole, name) {
2987
+ if (env && Object.prototype.hasOwnProperty.call(env, name)) return env[name];
2988
+ return whole;
2989
+ });
2990
+ }
2991
+
2992
+ // Parses an IntelliJ-style .http file into a list of requests. Each request
2993
+ // tracks the line of its request line (for the gutter Run button) and the line
2994
+ // span it covers (for placing the inline response and for Cmd/Alt+Enter).
2995
+ function parseHttpRequests(content) {
2996
+ const methods = { GET: 1, POST: 1, PUT: 1, PATCH: 1, DELETE: 1, HEAD: 1, OPTIONS: 1, TRACE: 1, CONNECT: 1 };
2997
+ const lines = String(content).split(/\r?\n/);
2998
+ const requests = [];
2999
+ const vars = {};
3000
+ let curr = null;
3001
+ let phase = 'pre';
3002
+ function flush() {
3003
+ if (curr && curr.url) {
3004
+ curr.body = curr.bodyLines.join('\n').replace(/\s+$/, '');
3005
+ requests.push(curr);
3006
+ }
3007
+ }
3008
+ function start(boundaryLine, name, index) {
3009
+ return { name: name, method: '', url: '', headers: [], bodyLines: [], startLine: -1, endLine: index, boundaryLine: boundaryLine };
3010
+ }
3011
+ for (let i = 0; i < lines.length; i++) {
3012
+ const rawLine = lines[i];
3013
+ const trimmed = rawLine.trim();
3014
+ if (trimmed.indexOf('###') === 0) {
3015
+ flush();
3016
+ curr = start(i, trimmed.replace(/^#+/, '').trim(), i);
3017
+ phase = 'pre';
3018
+ continue;
3019
+ }
3020
+ if (!curr) {
3021
+ curr = start(-1, '', i);
3022
+ phase = 'pre';
3023
+ }
3024
+ curr.endLine = i;
3025
+ if (phase === 'pre') {
3026
+ if (trimmed === '') continue;
3027
+ if (trimmed.indexOf('#') === 0 || trimmed.indexOf('//') === 0) continue;
3028
+ const varMatch = /^@([\w.$-]+)\s*=\s*(.*)$/.exec(trimmed);
3029
+ if (varMatch) { vars[varMatch[1]] = varMatch[2].trim(); continue; }
3030
+ const sp = trimmed.indexOf(' ');
3031
+ const firstToken = sp >= 0 ? trimmed.slice(0, sp) : trimmed;
3032
+ if (sp >= 0 && methods[firstToken.toUpperCase()]) {
3033
+ curr.method = firstToken.toUpperCase();
3034
+ curr.url = trimmed.slice(sp + 1).replace(/\s+HTTP\/[\d.]+\s*$/i, '').trim();
3035
+ } else {
3036
+ curr.method = 'GET';
3037
+ curr.url = trimmed.replace(/\s+HTTP\/[\d.]+\s*$/i, '').trim();
3038
+ }
3039
+ curr.startLine = i;
3040
+ phase = 'headers';
3041
+ continue;
3042
+ }
3043
+ if (phase === 'headers') {
3044
+ if (trimmed === '') { phase = 'body'; continue; }
3045
+ if (trimmed.indexOf('#') === 0 || trimmed.indexOf('//') === 0) continue;
3046
+ const colon = rawLine.indexOf(':');
3047
+ if (colon > 0) curr.headers.push({ name: rawLine.slice(0, colon).trim(), value: rawLine.slice(colon + 1).trim() });
3048
+ continue;
3049
+ }
3050
+ curr.bodyLines.push(rawLine);
3051
+ }
3052
+ flush();
3053
+ return { requests: requests, vars: vars };
3054
+ }
3055
+
3056
+ function renderHttpTable(file) {
3057
+ const parsed = parseHttpRequests(file.content);
3058
+ const requests = parsed.requests;
3059
+ httpRequestsByPath.set(file.path, requests);
3060
+ httpVarsByPath.set(file.path, parsed.vars);
3061
+ const env = Object.assign({}, parsed.vars, currentHttpEnv());
3062
+ const lines = String(file.content).split(/\r?\n/);
3063
+ const cursor = viewerCursor && viewerCursor.path === file.path ? viewerCursor : null;
3064
+ const runAtLine = {};
3065
+ const respAfterLine = {};
3066
+ requests.forEach(function (req, idx) {
3067
+ if (req.startLine >= 0) runAtLine[req.startLine] = idx;
3068
+ respAfterLine[req.endLine] = idx;
3069
+ });
3070
+ let rows = '';
3071
+ lines.forEach(function (line, index) {
3072
+ const hasRun = Object.prototype.hasOwnProperty.call(runAtLine, index);
3073
+ const reqIdx = hasRun ? runAtLine[index] : -1;
3074
+ const isCursorLine = Boolean(cursor && cursor.lineIndex === index);
3075
+ const gutter = hasRun
3076
+ ? '<button type="button" class="http-run" data-req="' + reqIdx + '" title="Run request (Cmd/Alt+Enter)" aria-label="Run request">&#9654;</button>'
3077
+ : '';
3078
+ rows += '<tr class="source-row http-row' + (hasRun ? ' http-request-line' : '') + (isCursorLine ? ' cursor-line' : '') + '" data-line-index="' + index + '">'
3079
+ + '<td class="num http-gutter">' + gutter + '<span class="num-text">' + (index + 1) + '</span></td>'
3080
+ + '<td class="source-code">' + (isCursorLine ? renderHttpLineWithCursor(line, env, cursor.column) : highlightHttpLine(line, env)) + '</td>'
3081
+ + '</tr>';
3082
+ if (Object.prototype.hasOwnProperty.call(respAfterLine, index)) {
3083
+ const rIdx = respAfterLine[index];
3084
+ rows += '<tr class="http-response-row"><td class="num"></td><td class="source-code"><div class="http-response hidden" id="http-resp-' + rIdx + '"></div></td></tr>';
3085
+ }
3086
+ });
3087
+ return '<table class="source-table http-table"><tbody>' + rows + '</tbody></table>';
3088
+ }
3089
+ function renderHttpLineWithCursor(text, env, column) {
3090
+ var col = Math.max(0, Math.min(column, text.length));
3091
+ return highlightHttpLine(text.slice(0, col), env) + '<span class="code-cursor" aria-hidden="true"></span>' + highlightHttpLine(text.slice(col), env);
3092
+ }
3093
+
3094
+ function highlightHttpLine(line, env) {
3095
+ const trimmed = line.trim();
3096
+ if (trimmed.indexOf('###') === 0) return '<span class="http-sep">' + escapeHtml(line) + '</span>';
3097
+ if (trimmed.indexOf('#') === 0 || trimmed.indexOf('//') === 0) return '<span class="tok-comment">' + escapeHtml(line) + '</span>';
3098
+ let html = escapeHtml(line);
3099
+ html = html.replace(/^(\s*)(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)(\s)/, function (whole, pre, method, post) {
3100
+ return pre + '<span class="http-method">' + method + '</span>' + post;
3101
+ });
3102
+ html = html.replace(/\{\{\s*([\w.$-]+)\s*\}\}/g, function (whole, name) {
3103
+ const known = env && Object.prototype.hasOwnProperty.call(env, name);
3104
+ const title = known ? String(env[name]) : 'Undefined variable';
3105
+ return '<span class="http-var ' + (known ? 'known' : 'unknown') + '" title="' + escapeHtml(title) + '">' + escapeHtml(whole) + '</span>';
3106
+ });
3107
+ return html;
3108
+ }
3109
+
3110
+ function sendHttp(request) {
3111
+ if (window.monacoriHttp && typeof window.monacoriHttp.send === 'function') {
3112
+ return Promise.resolve(window.monacoriHttp.send(request));
3113
+ }
3114
+ return fetch('/__http_send', {
3115
+ method: 'POST',
3116
+ headers: { 'Content-Type': 'application/json' },
3117
+ body: JSON.stringify(request),
3118
+ }).then(function (response) { return response.json(); });
3119
+ }
3120
+
3121
+ function runHttpRequest(reqIndex) {
3122
+ const path = document.getElementById('source-viewer')?.dataset.openPath || '';
3123
+ const requests = httpRequestsByPath.get(path);
3124
+ if (!requests || !requests[reqIndex]) return;
3125
+ const req = requests[reqIndex];
3126
+ const env = Object.assign({}, httpVarsByPath.get(path) || {}, currentHttpEnv());
3127
+ const headers = {};
3128
+ req.headers.forEach(function (header) {
3129
+ const key = applyHttpVars(header.name, env);
3130
+ if (key) headers[key] = applyHttpVars(header.value, env);
3131
+ });
3132
+ const resolved = {
3133
+ method: req.method || 'GET',
3134
+ url: applyHttpVars(req.url, env),
3135
+ headers: headers,
3136
+ body: req.body ? applyHttpVars(req.body, env) : undefined,
3137
+ };
3138
+ const target = document.getElementById('http-resp-' + reqIndex);
3139
+ if (target) {
3140
+ target.className = 'http-response loading';
3141
+ target.textContent = resolved.method + ' ' + resolved.url;
3142
+ }
3143
+ sendHttp(resolved).then(function (result) {
3144
+ if (target) renderHttpResponse(target, result);
3145
+ }).catch(function (error) {
3146
+ if (target) {
3147
+ target.className = 'http-response error';
3148
+ target.innerHTML = '<div class="http-resp-head"><span class="http-status bad">Failed</span></div><pre class="http-resp-body">' + escapeHtml(String(error && error.message ? error.message : error)) + '</pre>';
3149
+ }
3150
+ });
3151
+ }
3152
+
3153
+ function runHttpAtCaret() {
3154
+ const path = document.getElementById('source-viewer')?.dataset.openPath || '';
3155
+ const requests = httpRequestsByPath.get(path);
3156
+ if (!requests || !requests.length) return;
3157
+ const caretLine = viewerCursor && viewerCursor.path === path ? viewerCursor.lineIndex : 0;
3158
+ let chosen = -1;
3159
+ for (let i = 0; i < requests.length; i++) {
3160
+ const req = requests[i];
3161
+ const from = req.boundaryLine >= 0 ? req.boundaryLine : req.startLine;
3162
+ if (from <= caretLine && caretLine <= req.endLine) { chosen = i; break; }
3163
+ if (from <= caretLine) chosen = i;
3164
+ }
3165
+ if (chosen < 0) chosen = 0;
3166
+ runHttpRequest(chosen);
3167
+ }
3168
+
3169
+ function renderHttpResponse(target, result) {
3170
+ if (!result || !result.ok) {
3171
+ target.className = 'http-response error';
3172
+ const message = result && result.error ? result.error : 'Request failed';
3173
+ target.innerHTML = '<div class="http-resp-head"><span class="http-status bad">Failed</span></div><pre class="http-resp-body">' + escapeHtml(message) + '</pre>';
3174
+ return;
3175
+ }
3176
+ target.className = 'http-response';
3177
+ const status = Number(result.status) || 0;
3178
+ const statusClass = status >= 200 && status < 300 ? 'ok' : (status >= 400 ? 'bad' : 'warn');
3179
+ const headers = result.headers || {};
3180
+ const headerKeys = Object.keys(headers).sort();
3181
+ const headerHtml = headerKeys.map(function (key) {
3182
+ return '<div class="http-h"><span class="http-h-k">' + escapeHtml(key) + '</span><span class="http-h-v">' + escapeHtml(String(headers[key])) + '</span></div>';
3183
+ }).join('');
3184
+ let contentType = '';
3185
+ for (let i = 0; i < headerKeys.length; i++) {
3186
+ if (headerKeys[i].toLowerCase() === 'content-type') { contentType = String(headers[headerKeys[i]]); break; }
3187
+ }
3188
+ const bodyText = result.body == null ? '' : String(result.body);
3189
+ const bodyHtml = formatHttpBody(bodyText, contentType);
3190
+ target.innerHTML =
3191
+ '<div class="http-resp-head">'
3192
+ + '<span class="http-status ' + statusClass + '">' + status + (result.statusText ? ' ' + escapeHtml(result.statusText) : '') + '</span>'
3193
+ + '<span class="http-resp-meta">' + (Number(result.durationMs) || 0) + ' ms</span>'
3194
+ + '<span class="http-resp-meta">' + formatBytes(bodyText.length) + '</span>'
3195
+ + (headerKeys.length ? '<button type="button" class="http-resp-toggle">Headers (' + headerKeys.length + ')</button>' : '')
3196
+ + '</div>'
3197
+ + '<div class="http-resp-headers hidden">' + headerHtml + '</div>'
3198
+ + '<pre class="http-resp-body">' + bodyHtml + '</pre>';
3199
+ }
3200
+
3201
+ function formatHttpBody(text, contentType) {
3202
+ if (!text) return '<span class="http-resp-empty">(empty body)</span>';
3203
+ const looksJson = /json/i.test(contentType) || /^[\[{]/.test(text.trim());
3204
+ if (looksJson) {
3205
+ try {
3206
+ const pretty = JSON.stringify(JSON.parse(text), null, 2);
3207
+ return pretty.split(/\r?\n/).map(function (line) { return highlightLine(line, 'json'); }).join('\n');
3208
+ } catch (error) {}
3209
+ }
3210
+ return escapeHtml(text);
3211
+ }
3212
+
3213
+ function populateHttpEnvSelect() {
3214
+ const select = document.getElementById('http-env-select');
3215
+ if (!select) return;
3216
+ let opts = '<option value="">No environment</option>';
3217
+ httpEnvNames.forEach(function (name) {
3218
+ opts += '<option value="' + escapeHtml(name) + '"' + (name === currentHttpEnvName ? ' selected' : '') + '>' + escapeHtml(name) + '</option>';
3219
+ });
3220
+ select.innerHTML = opts;
3221
+ select.addEventListener('change', function () {
3222
+ currentHttpEnvName = select.value;
3223
+ try { localStorage.setItem(httpEnvKey, currentHttpEnvName); } catch (error) {}
3224
+ const path = document.getElementById('source-viewer')?.dataset.openPath || '';
3225
+ if (path && isHttpFile(path)) {
3226
+ const file = sourceByPath.get(path);
3227
+ const body = document.getElementById('source-body');
3228
+ if (file && body) body.innerHTML = renderHttpTable(file);
3229
+ }
3230
+ });
3231
+ }
3232
+
3233
+ function renderSourceTable(file, query) {
3234
+ const normalizedQuery = query.trim().toLowerCase();
3235
+ const lines = file.content.split(/\r?\n/);
3236
+ const cursor = viewerCursor && viewerCursor.path === file.path ? viewerCursor : null;
3237
+ const changedSet = new Set(file.changedLines || []);
3238
+ const rows = lines.map((line, index) => {
3239
+ const hit = normalizedQuery.length > 0 && line.toLowerCase().includes(normalizedQuery);
3240
+ const isCursorLine = Boolean(cursor && cursor.lineIndex === index);
3241
+ const isSymbolTarget = Boolean(cursor && cursor.targetLine === index);
3242
+ const isChanged = changedSet.has(index + 1);
3243
+ const classes = [
3244
+ 'source-row',
3245
+ hit ? 'search-hit' : '',
3246
+ isChanged ? 'changed-line' : '',
3247
+ isCursorLine ? 'cursor-line' : '',
3248
+ isSymbolTarget ? 'symbol-target' : '',
3249
+ ].filter(Boolean).join(' ');
3250
+ return [
3251
+ '<tr class="' + classes + '" data-line-index="' + index + '">',
3252
+ '<td class="num">' + String(index + 1) + '</td>',
3253
+ '<td class="source-code">' + (isCursorLine ? renderLineWithCursor(line, file.language || 'text', cursor.column) : highlightLine(line, file.language || 'text')) + '</td>',
3254
+ '</tr>',
3255
+ ].join('');
3256
+ }).join('');
3257
+ return '<table class="source-table"><tbody>' + rows + '</tbody></table>';
3258
+ }
3259
+
3260
+ function renderLineWithCursor(text, language, column) {
3261
+ const boundedColumn = Math.max(0, Math.min(column, text.length));
3262
+ const before = text.slice(0, boundedColumn);
3263
+ const after = text.slice(boundedColumn);
3264
+ return highlightLine(before, language) + '<span class="code-cursor" aria-hidden="true"></span>' + highlightLine(after, language);
3265
+ }
3266
+
3267
+ function highlightLine(text, language) {
3268
+ if (language === 'text') return escapeHtml(text);
3269
+ if (language === 'markup') {
3270
+ return escapeHtml(text).replace(/(&lt;\/?)([\w:-]+)([^&]*?)(\/?&gt;)/g, '$1<span class="tok-tag">$2</span>$3$4');
3271
+ }
3272
+ if (language === 'markdown') {
3273
+ const escaped = escapeHtml(text);
3274
+ if (/^\s{0,3}#{1,6}\s/.test(text)) return '<span class="tok-keyword">' + escaped + '</span>';
3275
+ return escaped.replace(new RegExp(String.fromCharCode(96) + '[^' + String.fromCharCode(96) + ']+' + String.fromCharCode(96), 'g'), '<span class="tok-string">$&</span>');
3276
+ }
3277
+ const keywords = new Set(['as','async','await','break','case','catch','class','const','continue','def','default','defer','do','else','enum','export','extends','final','finally','fn','for','from','func','function','go','if','impl','import','in','interface','let','match','module','new','package','private','protected','public','return','select','static','struct','switch','throw','try','type','val','var','while','yield']);
3278
+ const literals = new Set(['False','None','True','false','nil','null','self','this','true','undefined']);
3279
+ const commentPrefixes = ['python','ruby','shell','yaml','toml'].includes(language) ? ['#'] : ['//'];
3280
+ let output = '';
3281
+ let index = 0;
3282
+ while (index < text.length) {
3283
+ const rest = text.slice(index);
3284
+ const commentPrefix = commentPrefixes.find((prefix) => rest.startsWith(prefix));
3285
+ if (commentPrefix) {
3286
+ output += '<span class="tok-comment">' + escapeHtml(rest) + '</span>';
3287
+ break;
3288
+ }
3289
+ const char = text[index];
3290
+ if (char === '"' || char === "'" || char === String.fromCharCode(96)) {
3291
+ const quote = char;
3292
+ let end = index + 1;
3293
+ let escaped = false;
3294
+ while (end < text.length) {
3295
+ const currentChar = text[end];
3296
+ if (currentChar === quote && !escaped) {
3297
+ end += 1;
3298
+ break;
3299
+ }
3300
+ escaped = currentChar === '\\' && !escaped;
3301
+ if (currentChar !== '\\') escaped = false;
3302
+ end += 1;
3303
+ }
3304
+ output += '<span class="tok-string">' + escapeHtml(text.slice(index, end)) + '</span>';
3305
+ index = end;
3306
+ continue;
3307
+ }
3308
+ const number = rest.match(/^\b\d+(?:\.\d+)?\b/);
3309
+ if (number) {
3310
+ output += '<span class="tok-number">' + escapeHtml(number[0]) + '</span>';
3311
+ index += number[0].length;
3312
+ continue;
3313
+ }
3314
+ const identifier = rest.match(/^[A-Za-z_$][\w$-]*/);
3315
+ if (identifier) {
3316
+ const value = identifier[0];
3317
+ if (keywords.has(value)) output += '<span class="tok-keyword">' + escapeHtml(value) + '</span>';
3318
+ else if (literals.has(value)) output += '<span class="tok-literal">' + escapeHtml(value) + '</span>';
3319
+ else output += escapeHtml(value);
3320
+ index += value.length;
3321
+ continue;
3322
+ }
3323
+ output += escapeHtml(char);
3324
+ index += 1;
3325
+ }
3326
+ return output;
3327
+ }
3328
+
3329
+ function escapeHtml(value) {
3330
+ return String(value)
3331
+ .replace(/&/g, '&amp;')
3332
+ .replace(/</g, '&lt;')
3333
+ .replace(/>/g, '&gt;')
3334
+ .replace(/"/g, '&quot;')
3335
+ .replace(/'/g, '&#39;');
3336
+ }
3337
+
3338
+ function formatBytes(bytes) {
3339
+ if (bytes < 1024) return bytes + ' B';
3340
+ const kib = bytes / 1024;
3341
+ if (kib < 1024) return kib.toFixed(1) + ' KiB';
3342
+ return (kib / 1024).toFixed(1) + ' MiB';
3343
+ }