@happy-nut/monacori 0.1.0 → 0.1.3

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