@happy-nut/monacori 0.1.11 → 0.1.12

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.
@@ -156,6 +156,21 @@ function applyI18n() {
156
156
  var sel = document.getElementById('settings-language');
157
157
  if (sel) sel.value = locale;
158
158
  }
159
+ // Theme mirrors the locale pattern: persisted choice, applied by toggling data-theme on <html> so the
160
+ // :root[data-theme="light"] palette takes over. Dark is the default (matches the inline :root). Applied
161
+ // immediately at script start to minimize a first-paint flash from the dark default to light.
162
+ var THEME_KEY = 'monacori-theme';
163
+ var theme = (function () {
164
+ var v = persistRead(THEME_KEY);
165
+ if (v !== 'light' && v !== 'dark') { try { v = localStorage.getItem(THEME_KEY); } catch (e) {} }
166
+ return (v === 'light' || v === 'dark') ? v : 'dark';
167
+ })();
168
+ function applyTheme() {
169
+ document.documentElement.setAttribute('data-theme', theme);
170
+ var sel = document.getElementById('settings-theme');
171
+ if (sel) sel.value = theme;
172
+ }
173
+ applyTheme();
159
174
  let fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
160
175
  let httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
161
176
  let httpEnvNames = Object.keys(httpEnvironments);
@@ -168,6 +183,10 @@ let sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
168
183
  // and the source view shows a brief loading state. Non-lazy-load modes embed source -> already loaded.
169
184
  var sourceLoaded = !REVIEW_LAZY_LOAD;
170
185
  var pendingSourceOpen = null;
186
+ // The path whose content is ACTUALLY painted in #source-body right now. dataset.openPath is the INTENDED
187
+ // path and gets set BEFORE the body paints in the lazy-LOAD branch, so the caret fast-path must check this
188
+ // instead — else it patches the caret onto a stale body, leaving one file's content under another's path.
189
+ var sourceBodyPath = null;
171
190
  var sourceLoading = false;
172
191
  var pendingSymbol = null;
173
192
  var sourceTabs = []; // Files-mode tab paths (session-only); see addSourceTab / renderSourceTabs.
@@ -831,17 +850,33 @@ function isTreeRowVisible(el) {
831
850
  function treeRows() {
832
851
  const panel = document.querySelector('.tab-panel:not(.hidden)');
833
852
  if (!panel) return [];
834
- return Array.from(panel.querySelectorAll('summary, .file-link')).filter((el) => el.getClientRects().length > 0 && isTreeRowVisible(el));
853
+ // isTreeRowVisible walks ancestor <details> (cheap, layout-free) and already excludes rows inside
854
+ // collapsed folders. The previous extra `getClientRects().length > 0` check forced a SYNCHRONOUS
855
+ // reflow per node — 6k forced layouts on every arrow key in a large source tree, which froze input.
856
+ // The details walk makes the rects check redundant, so drop it.
857
+ return Array.from(panel.querySelectorAll('summary, .file-link')).filter(isTreeRowVisible);
835
858
  }
836
859
 
837
860
  function focusTree(index) {
838
861
  const rows = treeRows();
839
862
  if (rows.length === 0) return;
840
- // Incremental: drop the old focus class and add the new one — no full forEach over every row per keystroke.
841
- if (treeFocusIndex >= 0 && treeFocusIndex < rows.length) rows[treeFocusIndex]?.classList.remove('tree-focus');
842
863
  treeFocusIndex = Math.max(0, Math.min(rows.length - 1, index));
843
- const el = rows[treeFocusIndex];
844
- if (el) { el.classList.add('tree-focus'); scheduleScrollIntoView(el); }
864
+ // Render the focus class AND scroll in the SAME frame. A fast key-repeat queues many ArrowDowns before a
865
+ // frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump ~one
866
+ // viewport (~20 rows) at a time. Coalescing both keeps focus + scroll in lockstep so it scrolls smoothly.
867
+ scheduleTreeFocus();
868
+ }
869
+ var treeFocusRaf = 0;
870
+ function scheduleTreeFocus() {
871
+ if (treeFocusRaf) return;
872
+ treeFocusRaf = requestAnimationFrame(function () {
873
+ treeFocusRaf = 0;
874
+ const rows = treeRows();
875
+ if (treeFocusIndex < 0 || treeFocusIndex >= rows.length) return;
876
+ const el = rows[treeFocusIndex];
877
+ document.querySelectorAll('.tree-focus').forEach((e) => { if (e !== el) e.classList.remove('tree-focus'); });
878
+ if (el) { el.classList.add('tree-focus'); el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }
879
+ });
845
880
  }
846
881
 
847
882
  function clearTreeFocus() {
@@ -1171,14 +1206,19 @@ document.addEventListener('keydown', (event) => {
1171
1206
 
1172
1207
  if (event.key === 'F7') {
1173
1208
  event.preventDefault();
1174
- if (!document.getElementById('source-viewer')?.classList.contains('hidden')) {
1175
- const sourceHunk = firstHunkForPath(document.getElementById('source-viewer')?.dataset.openPath || '');
1209
+ const delta = event.shiftKey ? -1 : 1;
1210
+ const sourceViewer = document.getElementById('source-viewer');
1211
+ // Forward F7 from the source view enters the diff at the open file's own hunk, so the reviewer lands
1212
+ // where they were reading. Shift+F7 — and any file with no hunk of its own — falls through to plain
1213
+ // prev/next-change navigation across the whole diff.
1214
+ if (delta > 0 && sourceViewer && !sourceViewer.classList.contains('hidden')) {
1215
+ const sourceHunk = firstHunkForPath(sourceViewer.dataset.openPath || '');
1176
1216
  if (sourceHunk >= 0) {
1177
1217
  setActive(sourceHunk);
1178
1218
  return;
1179
1219
  }
1180
1220
  }
1181
- next(event.shiftKey ? -1 : 1);
1221
+ next(delta);
1182
1222
  }
1183
1223
  });
1184
1224
 
@@ -1213,15 +1253,18 @@ document.getElementById('usages')?.addEventListener('click', function (event) {
1213
1253
  if (event.target && event.target.id === 'usages') closeUsages();
1214
1254
  });
1215
1255
 
1216
- links.forEach((link) => {
1217
- link.addEventListener('click', (event) => {
1218
- showDiffView(false);
1219
- const target = Number(link.dataset.hunk);
1220
- if (!Number.isNaN(target) && target >= 0 && target < hunkTotal()) {
1221
- event.preventDefault();
1222
- setActive(target);
1223
- }
1224
- });
1256
+ // Delegated (like #files-panel below) so it survives the in-place diff update that re-captures `links`
1257
+ // on every watch tick — per-element listeners would be lost on the new nodes, and then Cmd+0 → arrow →
1258
+ // Enter (which calls row.click()) would silently do nothing.
1259
+ document.getElementById('changes-panel')?.addEventListener('click', (event) => {
1260
+ const link = event.target && event.target.closest ? event.target.closest('.file-link') : null;
1261
+ if (!link) return;
1262
+ showDiffView(false);
1263
+ const target = Number(link.dataset.hunk);
1264
+ if (!Number.isNaN(target) && target >= 0 && target < hunkTotal()) {
1265
+ event.preventDefault();
1266
+ setActive(target);
1267
+ }
1225
1268
  });
1226
1269
 
1227
1270
  // Delegated so it works whether the tree is inline (small repos) or materialized later (big repos).
@@ -1262,8 +1305,11 @@ if (!REVIEW_LAZY_LOAD) scheduleSymbolIndex(); // non-lazy indexes when idle; laz
1262
1305
  const restored = restoreUiState();
1263
1306
  if (!restored) {
1264
1307
  const initial = location.hash.match(/^#hunk-(\d+)$/);
1308
+ const hasDiff = Boolean(document.querySelector('#diff2html-container .d2h-file-wrapper'));
1265
1309
  if (initial) setActive(Number(initial[1]), false);
1266
- 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
1310
+ // Clean tree (nothing to review): open a file (README first) instead of staring at an empty diff.
1311
+ else if (!hasDiff) openDefaultSourceFile();
1312
+ else if (REVIEW_LAZY_LOAD) showDiffView(false); // big repos with changes: open to the diff (Changes); the source tree stays deferred until the Files tab is opened
1267
1313
  else openDefaultSourceFile();
1268
1314
  }
1269
1315
  initSourceTreeFolds();
@@ -1476,11 +1522,32 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
1476
1522
  var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
1477
1523
  diffCursor = { path: path, side: side, rowIndex: ri, column: col };
1478
1524
  diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
1479
- renderDiffCaret();
1480
- applyDiffSelection();
1481
- if (reveal) scheduleScrollIntoView(diffRowAt(wrapper, side, ri));
1525
+ if (reveal) {
1526
+ // Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
1527
+ // before one frame; rendering the caret immediately (while the coalesced scroll lags) would push it many
1528
+ // rows past the viewport, then the view would snap ~one viewport at a time. Coalescing both keeps the
1529
+ // caret and scroll in lockstep, so holding ArrowDown scrolls smoothly instead of jumping every ~15 lines.
1530
+ scheduleDiffReveal(wrapper, side, ri);
1531
+ } else {
1532
+ renderDiffCaret();
1533
+ applyDiffSelection();
1534
+ }
1482
1535
  recordNav(navEntryOf('diff'));
1483
1536
  }
1537
+ var diffRevealRaf = 0, diffRevealTarget = null;
1538
+ function scheduleDiffReveal(wrapper, side, ri) {
1539
+ diffRevealTarget = { wrapper: wrapper, side: side, ri: ri };
1540
+ if (diffRevealRaf) return;
1541
+ diffRevealRaf = requestAnimationFrame(function () {
1542
+ diffRevealRaf = 0;
1543
+ var t = diffRevealTarget; diffRevealTarget = null;
1544
+ renderDiffCaret();
1545
+ applyDiffSelection();
1546
+ if (!t) return;
1547
+ var row = diffRowAt(t.wrapper, t.side, t.ri);
1548
+ if (row && row.scrollIntoView) { try { row.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (x) {} }
1549
+ });
1550
+ }
1484
1551
  function navEntryOf(kind) {
1485
1552
  if (kind === 'diff') {
1486
1553
  if (!diffCursor) return null;
@@ -1729,6 +1796,13 @@ function currentCommentTarget() {
1729
1796
  return { path: path, line: toLine, code: hasSel ? selText : '', from: hasSel ? Math.min(fromLine, toLine) : null, to: hasSel ? Math.max(fromLine, toLine) : null, side: side };
1730
1797
  }
1731
1798
 
1799
+ // "live_trading_engine.py:424" (or ":420–424" for a multi-line drag) — shown in the composer head so the
1800
+ // reviewer always sees WHICH file + line(s) a comment targets instead of a bare, context-free box.
1801
+ function composerTargetLabel(s) {
1802
+ var base = (s.path || '').split('/').pop() || s.path || '';
1803
+ var loc = (s.from != null && s.to != null && s.from !== s.to) ? (s.from + '–' + s.to) : String(s.line);
1804
+ return base + ':' + loc;
1805
+ }
1732
1806
  function threadHtml(path, line) {
1733
1807
  var html = '';
1734
1808
  commentsAt(path, line).forEach(function (c) {
@@ -1740,7 +1814,7 @@ function threadHtml(path, line) {
1740
1814
  if (composerState && composerState.path === path && composerState.line === line) {
1741
1815
  var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
1742
1816
  html += '<div class="mc-card mc-' + composerState.kind + ' mc-composer">'
1743
- + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(composerState.kind) + '</span></div>'
1817
+ + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(composerState.kind) + '</span><span class="mc-target" title="' + escapeHtml(composerState.path || '') + '">' + escapeHtml(composerTargetLabel(composerState)) + '</span></div>'
1744
1818
  + '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '"></textarea>'
1745
1819
  + '<div class="mc-actions"><button type="button" class="mc-btn mc-save">' + escapeHtml(t('composer.save')) + '</button>'
1746
1820
  + '<button type="button" class="mc-btn mc-ghost mc-cancel">' + escapeHtml(t('composer.cancel')) + '</button>'
@@ -1853,6 +1927,18 @@ function refreshComments() {
1853
1927
  if (isSourceViewerVisible()) renderSourceComments();
1854
1928
  renderCommentBadges();
1855
1929
  applyCommentSelectionHighlight();
1930
+ // Keep body.mc-composing (which hides the file caret) tied to the ACTUAL on-screen composer, not just
1931
+ // composerState. Leaving the composer by any path other than save/cancel (opening another file, switching
1932
+ // views) would otherwise leave the class stuck and hide EVERY caret — making arrow navigation and
1933
+ // comment-box selection look dead. This single sync point covers all refreshComments callers.
1934
+ var visibleComposer = false;
1935
+ var composerInputs = document.querySelectorAll('.mc-composer .mc-input');
1936
+ for (var ci = 0; ci < composerInputs.length; ci++) {
1937
+ if (composerInputs[ci].closest('#diff-view') && !isDiffViewVisible()) continue;
1938
+ if (composerInputs[ci].closest('#source-viewer') && !isSourceViewerVisible()) continue;
1939
+ visibleComposer = true; break;
1940
+ }
1941
+ document.body.classList.toggle('mc-composing', visibleComposer);
1856
1942
  if (composerState) {
1857
1943
  var composerFocusTries = 0;
1858
1944
  var tryFocusComposer = function () {
@@ -1881,7 +1967,8 @@ function openComposer(kind) {
1881
1967
  // Keep the dragged code visibly highlighted via the .mc-sel-line class (applyCommentSelectionHighlight),
1882
1968
  // and clear the native selection so its highlight doesn't bleed into the composer/cards below it.
1883
1969
  try { var psel = window.getSelection(); if (psel) psel.removeAllRanges(); } catch (e) {}
1884
- refreshComments();
1970
+ refreshComments(); // refreshComments syncs body.mc-composing from the on-screen composer
1971
+
1885
1972
  }
1886
1973
  function closeComposer() {
1887
1974
  if (!composerState) return;
@@ -1932,6 +2019,79 @@ function saveMergePrompt(kind, text) {
1932
2019
  persistSave(mergePromptsKey, saved);
1933
2020
  }
1934
2021
 
2022
+ // Reusable custom dropdown (keyboard + mouse). options: [{ label, onSelect }]. First item is pre-selected;
2023
+ // Arrow keys move, Enter chooses, Esc / click-outside dismiss. Replaces native <select>/menus everywhere.
2024
+ function showCustomDropdown(x, y, options) {
2025
+ var existing = document.getElementById('mc-dropdown');
2026
+ if (existing) existing.remove();
2027
+ var dd = document.createElement('div');
2028
+ dd.id = 'mc-dropdown';
2029
+ dd.className = 'mc-dropdown';
2030
+ var active = 0;
2031
+ function setActive(i) { active = i; for (var j = 0; j < dd.children.length; j++) dd.children[j].classList.toggle('active', j === i); }
2032
+ function close() { dd.remove(); document.removeEventListener('keydown', onKey, true); document.removeEventListener('mousedown', onOutside, true); }
2033
+ function onKey(e) {
2034
+ if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setActive(Math.min(active + 1, options.length - 1)); }
2035
+ else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setActive(Math.max(active - 1, 0)); }
2036
+ else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); var o = options[active]; close(); if (o) o.onSelect(); }
2037
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
2038
+ }
2039
+ function onOutside(e) { if (!dd.contains(e.target)) close(); }
2040
+ options.forEach(function (opt, i) {
2041
+ var item = document.createElement('button');
2042
+ item.type = 'button';
2043
+ item.className = 'mc-dropdown-item' + (i === 0 ? ' active' : '');
2044
+ item.textContent = opt.label;
2045
+ item.addEventListener('click', function () { close(); opt.onSelect(); });
2046
+ item.addEventListener('mousemove', function () { setActive(i); });
2047
+ dd.appendChild(item);
2048
+ });
2049
+ dd.style.left = Math.round(x) + 'px';
2050
+ dd.style.top = Math.round(y) + 'px';
2051
+ document.body.appendChild(dd);
2052
+ document.addEventListener('keydown', onKey, true);
2053
+ document.addEventListener('mousedown', onOutside, true);
2054
+ }
2055
+ // Map a char range in the merged textarea back to the comment seq(s) it covers. Each comment is a
2056
+ // "### path:line" block; the caret's block (or every block a selection spans) identifies the comment(s).
2057
+ function mergedCommentSeqs(kind, start, end) {
2058
+ var items = reviewComments.filter(function (c) { return c.kind === kind; });
2059
+ var text = buildMergedText(kind);
2060
+ var lines = text.split(String.fromCharCode(10));
2061
+ var seqs = [], pos = 0, idx = -1;
2062
+ for (var i = 0; i < lines.length; i++) {
2063
+ var lineStart = pos, lineEnd = pos + lines[i].length;
2064
+ if (lines[i].indexOf('### ') === 0) idx++;
2065
+ if (idx >= 0 && idx < items.length && lineEnd >= start && lineStart <= end) {
2066
+ var s = items[idx].seq;
2067
+ if (seqs.indexOf(s) < 0) seqs.push(s);
2068
+ }
2069
+ pos = lineEnd + 1;
2070
+ }
2071
+ return seqs;
2072
+ }
2073
+ function navigateToComment(seq) {
2074
+ var c = reviewComments.find(function (x) { return x.seq === seq; });
2075
+ if (!c) return;
2076
+ openSourceFile(c.path);
2077
+ requestAnimationFrame(function () { setSourceCursor(c.path, Math.max(0, (c.line || 1) - 1), 0, true, -1); });
2078
+ }
2079
+ // Move the merged-view caret to the next (dir=1) / previous (dir=-1) "### path:line" header and center it,
2080
+ // so Opt+Arrow steps comment-by-comment in the merged view.
2081
+ function jumpMergedComment(area, dir) {
2082
+ var text = area.value;
2083
+ var headers = [], pos = 0;
2084
+ text.split('\n').forEach(function (ln) { if (ln.indexOf('### ') === 0) headers.push(pos); pos += ln.length + 1; });
2085
+ if (!headers.length) return;
2086
+ var cur = area.selectionStart;
2087
+ var target;
2088
+ if (dir > 0) { target = headers.find(function (h) { return h > cur; }); if (target == null) target = headers[headers.length - 1]; }
2089
+ else { var before = headers.filter(function (h) { return h < cur; }); target = before.length ? before[before.length - 1] : headers[0]; }
2090
+ area.selectionStart = area.selectionEnd = target;
2091
+ var lineNum = text.slice(0, target).split('\n').length - 1;
2092
+ var lineH = parseFloat(getComputedStyle(area).lineHeight) || 18;
2093
+ area.scrollTop = Math.max(0, lineNum * lineH - area.clientHeight / 2);
2094
+ }
1935
2095
  function buildMergedText(kind) {
1936
2096
  var items = reviewComments.filter(function (c) { return c.kind === kind; });
1937
2097
  var nl = String.fromCharCode(10);
@@ -1969,8 +2129,44 @@ function openMergedView(kind) {
1969
2129
  closeBtn.textContent = t('merged.close');
1970
2130
  var area = document.createElement('textarea');
1971
2131
  area.className = 'mc-modal-text';
1972
- area.readOnly = true;
2132
+ // NOT readOnly: a readOnly textarea hides the caret in Chromium, yet we need it VISIBLE so the user sees
2133
+ // which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead — read-only in
2134
+ // effect while the caret and selection stay fully interactive.
1973
2135
  area.value = buildMergedText(kind);
2136
+ area.addEventListener('beforeinput', function (e) { e.preventDefault(); });
2137
+ // Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret — "Go to comment"
2138
+ // + "Remove" for a single caret; "Remove" only for a drag/select-all (can't navigate to many at once).
2139
+ // Removing here calls deleteComment(), which re-syncs the on-screen comment boxes via refreshComments.
2140
+ area.addEventListener('keydown', function (e) {
2141
+ // Opt/Alt + Arrow steps the caret to the next/previous comment block so you can move comment-to-comment
2142
+ // and act on each with Opt+Enter, without hand-scrolling.
2143
+ if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
2144
+ e.preventDefault();
2145
+ e.stopPropagation();
2146
+ jumpMergedComment(area, e.key === 'ArrowDown' ? 1 : -1);
2147
+ return;
2148
+ }
2149
+ if (!e.altKey || (e.key !== 'Enter' && e.code !== 'Enter')) return;
2150
+ e.preventDefault();
2151
+ e.stopPropagation();
2152
+ var seqs = mergedCommentSeqs(kind, area.selectionStart, area.selectionEnd);
2153
+ if (!seqs.length) return;
2154
+ var rect = area.getBoundingClientRect();
2155
+ var x = rect.left + 24, y = rect.top + 48;
2156
+ var rerender = function () {
2157
+ if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) { modal.remove(); return; }
2158
+ area.value = buildMergedText(kind);
2159
+ };
2160
+ if (area.selectionStart !== area.selectionEnd || seqs.length > 1) {
2161
+ showCustomDropdown(x, y, [{ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } }]);
2162
+ } else {
2163
+ var seq = seqs[0];
2164
+ showCustomDropdown(x, y, [
2165
+ { label: t('dropdown.navigate'), onSelect: function () { modal.remove(); navigateToComment(seq); } },
2166
+ { label: t('dropdown.remove'), onSelect: function () { deleteComment(seq); rerender(); } },
2167
+ ]);
2168
+ }
2169
+ });
1974
2170
  closeBtn.addEventListener('click', function () { modal.remove(); });
1975
2171
  // Terminal send (Electron, terminal open): close the modal and hand off to pane-pick mode ON the
1976
2172
  // terminal — the chosen pane is highlighted, the rest dimmed, arrows change the choice, Enter sends.
@@ -2314,12 +2510,21 @@ refreshComments();
2314
2510
  }
2315
2511
  }
2316
2512
  function toggle() { setOpen(!isOpen()); }
2513
+ // The keyboard shortcut is "focus-first": when the terminal is visible but focus is elsewhere, the first
2514
+ // press just moves focus INTO the terminal; only when it already owns focus does another press toggle it
2515
+ // closed. (The footer button stays a plain toggle — a mouse click should open/close in one step.)
2516
+ function toggleOrFocus() {
2517
+ if (!isOpen()) { setOpen(true); return; } // setOpen(true) also focuses the active pane
2518
+ var ae = document.activeElement;
2519
+ if (ae && panel.contains(ae)) { setOpen(false); return; } // focus already in the terminal → close
2520
+ if (active) { try { active.term.focus(); } catch (e) {} } // visible but unfocused → just grab focus
2521
+ }
2317
2522
 
2318
2523
  if (toggleBtn) toggleBtn.addEventListener('click', toggle);
2319
2524
  if (closeBtn) closeBtn.addEventListener('click', function () { setOpen(false); });
2320
2525
  // Toggle (Ctrl+`/Alt+F12) and split (Cmd+D) arrive from the Terminal menu accelerators (app-main),
2321
2526
  // because Chromium swallows Cmd+D before a renderer keydown would ever see it.
2322
- if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalToggle === 'function') window.monacoriMenu.onTerminalToggle(toggle);
2527
+ if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalToggle === 'function') window.monacoriMenu.onTerminalToggle(toggleOrFocus);
2323
2528
  if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalSplit === 'function') window.monacoriMenu.onTerminalSplit(split);
2324
2529
  if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneFocus === 'function') window.monacoriMenu.onTerminalPaneFocus(focusPaneByDelta);
2325
2530
  if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneRename === 'function') window.monacoriMenu.onTerminalPaneRename(function () { renamePane(active); });
@@ -2563,6 +2768,18 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
2563
2768
  if (mergedModal) { var mk = mergedModal.dataset.kind || 'q'; mergedModal.remove(); openMergedView(mk); }
2564
2769
  });
2565
2770
  }
2771
+ // Theme: flip data-theme on <html> live (no reload) and persist the choice.
2772
+ var themeSel = document.getElementById('settings-theme');
2773
+ if (themeSel) {
2774
+ themeSel.value = theme;
2775
+ themeSel.addEventListener('change', function () {
2776
+ var next = themeSel.value === 'light' ? 'light' : 'dark';
2777
+ if (next === theme) return;
2778
+ theme = next;
2779
+ persistSave(THEME_KEY, theme);
2780
+ applyTheme();
2781
+ });
2782
+ }
2566
2783
  })();
2567
2784
 
2568
2785
  function setTab(name) {
@@ -2707,6 +2924,7 @@ function applyDiffUpdate(u) {
2707
2924
  diffBootDone = false;
2708
2925
  sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
2709
2926
  sourceLoading = false;
2927
+ sourceBodyPath = null; // the new build may have changed the open file's content — force a body re-render on next open
2710
2928
  symbolIndex = null;
2711
2929
  if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
2712
2930
  else { prepareDiff2HtmlHunks(); diffBootDone = true; }
@@ -2785,7 +3003,14 @@ function updateTreeVisibility(root, query) {
2785
3003
  }
2786
3004
 
2787
3005
  function openDefaultSourceFile() {
3006
+ const isReadme = (candidate) => /^readme(\.|$)/i.test(candidate.name || '');
3007
+ const depthOf = (candidate) => (candidate.path || '').split('/').length;
3008
+ // Prefer the TOP-MOST README (root before any nested one), not just the first match in tree order.
3009
+ const rootReadme = sourceFiles
3010
+ .filter((candidate) => candidate.embedded && isReadme(candidate))
3011
+ .sort((a, b) => depthOf(a) - depthOf(b))[0];
2788
3012
  const file = sourceFiles.find((candidate) => candidate.changed && candidate.embedded)
3013
+ || rootReadme // top-most README when nothing changed
2789
3014
  || sourceFiles.find((candidate) => candidate.embedded)
2790
3015
  || sourceFiles.find((candidate) => candidate.changed)
2791
3016
  || sourceFiles[0];
@@ -2948,20 +3173,46 @@ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLi
2948
3173
  // Fast path: the file is already on screen and only the caret moved. Re-rendering the whole
2949
3174
  // file on every keystroke blocks the main thread on large files, so patch just the previous
2950
3175
  // and new caret lines in place instead.
3176
+ // sourceBodyPath (the file actually painted in the body) must match too — dataset.openPath/viewerCursor
3177
+ // are metadata that can be set before the body repaints (lazy fetch in flight, fast file switch, watch
3178
+ // refresh), so without this the caret patches a STALE body and one file's content shows under another's
3179
+ // breadcrumb. On mismatch we fall through to openSourceFile, which re-renders the body for `path`.
2951
3180
  const sameFileOpen = Boolean(viewer && viewer.dataset.openPath === path && !viewer.classList.contains('hidden')
2952
- && prev && prev.path === path && !isHttpFile(path));
3181
+ && prev && prev.path === path && !isHttpFile(path) && sourceBodyPath === path);
2953
3182
 
2954
3183
  viewerCursor = { path, lineIndex: boundedLine, column: boundedColumn, targetLine };
2955
3184
 
2956
3185
  if (sameFileOpen) {
2957
- updateSourceCaret(prev, lines, file.language || 'text');
3186
+ // Coalesce caret render + scroll into ONE frame on reveal (ArrowDown) so a fast key-repeat doesn't run
3187
+ // the caret several rows ahead of the lagging (rAF) scroll and snap ~one viewport at a time ("stutter
3188
+ // every ~26 lines"). Click (no reveal) stays instant.
3189
+ if (shouldReveal) scheduleSourceReveal(prev);
3190
+ else updateSourceCaret(prev, lines, file.language || 'text');
2958
3191
  } else {
2959
3192
  const shouldSwitch = !viewer || viewer.dataset.openPath !== path || viewer.classList.contains('hidden');
2960
3193
  openSourceFile(path, shouldSwitch);
3194
+ if (shouldReveal) scheduleScrollIntoView(document.querySelector('.source-row.cursor-line'));
2961
3195
  }
2962
- if (shouldReveal) scheduleScrollIntoView(document.querySelector('.source-row.cursor-line'));
2963
3196
  recordNav(navEntryOf('source'));
2964
3197
  }
3198
+ var sourceRevealRaf = 0, sourceRevealPrev = null;
3199
+ function scheduleSourceReveal(prev) {
3200
+ // First prev of a coalesced burst wins: a fast ArrowDown updates viewerCursor many times before the frame
3201
+ // fires; render the caret once (first prev -> final viewerCursor) and scroll in the SAME frame so caret and
3202
+ // scroll stay locked together instead of the scroll snapping a viewport behind.
3203
+ if (!sourceRevealRaf) sourceRevealPrev = prev;
3204
+ if (sourceRevealRaf) return;
3205
+ sourceRevealRaf = requestAnimationFrame(function () {
3206
+ sourceRevealRaf = 0;
3207
+ var p = sourceRevealPrev; sourceRevealPrev = null;
3208
+ var f = sourceByPath.get(viewerCursor.path);
3209
+ if (!f || !f.embedded) return;
3210
+ var lines = f.content.split(/\r?\n/);
3211
+ updateSourceCaret(p, lines, f.language || 'text');
3212
+ var cl = document.querySelector('.source-row.cursor-line');
3213
+ if (cl && cl.scrollIntoView) { try { cl.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (x) {} }
3214
+ });
3215
+ }
2965
3216
 
2966
3217
  // Move the caret by patching only the affected line cells, never the whole <table>. This keeps
2967
3218
  // large files responsive (no full re-highlight per keystroke) and, because the new caret line is
@@ -3399,17 +3650,21 @@ function startSymbolIndex() {
3399
3650
  function setIndexProgress(done, total) {
3400
3651
  var el = document.getElementById('index-status');
3401
3652
  var bar = document.getElementById('index-progress');
3402
- if (!el) return;
3403
- if (!total || done >= total) {
3404
- el.textContent = (total || 0) + ' ' + t('status.indexed');
3405
- if (bar) bar.classList.add('hidden');
3406
- return;
3653
+ var foot = document.getElementById('footer-progress');
3654
+ var running = Boolean(total) && done < total;
3655
+ var pct = running ? Math.round(done / total * 100) + '%' : '0%';
3656
+ if (el) {
3657
+ el.textContent = running ? (t('status.indexing') + ' ' + done + '/' + total + '…') : ((total || 0) + ' ' + t('status.indexed'));
3407
3658
  }
3408
- el.textContent = t('status.indexing') + ' ' + done + '/' + total + '…';
3409
3659
  if (bar) {
3410
- bar.classList.remove('hidden');
3411
- var fill = bar.firstElementChild;
3412
- if (fill) fill.style.width = Math.round(done / total * 100) + '%';
3660
+ bar.classList.toggle('hidden', !running);
3661
+ if (running && bar.firstElementChild) bar.firstElementChild.style.width = pct;
3662
+ }
3663
+ // The same signal, mirrored as a thin bar pinned under the version block at the bottom of the
3664
+ // sidebar — so background work (indexing) is visible even when the toolbar status is out of view.
3665
+ if (foot) {
3666
+ foot.classList.toggle('hidden', !running);
3667
+ if (foot.firstElementChild) foot.firstElementChild.style.width = pct;
3413
3668
  }
3414
3669
  }
3415
3670
  function wordAtDiffCaret() {
@@ -3530,12 +3785,16 @@ function cycleSourceTab(dir) {
3530
3785
  function openSourceFile(path, shouldSwitch = true) {
3531
3786
  const file = sourceByPath.get(path);
3532
3787
  if (!file) return;
3788
+ // Switching to another file abandons any in-progress comment elsewhere; closeComposer() clears
3789
+ // composerState and (via refreshComments) drops body.mc-composing so no caret stays hidden.
3790
+ if (composerState && composerState.path !== path) closeComposer();
3533
3791
  addSourceTab(path);
3534
3792
  renderSourceTabs(path);
3535
3793
  // lazy-LOAD: source content not fetched yet -> show a loading state; loadSourceData re-opens it.
3536
3794
  if (REVIEW_LAZY_LOAD && !sourceLoaded && file.embedded) {
3537
3795
  pendingSourceOpen = { path: path, shouldSwitch: shouldSwitch };
3538
3796
  loadSourceData();
3797
+ sourceBodyPath = null; // body shows a loading placeholder, not this path's content yet
3539
3798
  document.getElementById('source-viewer').dataset.openPath = path;
3540
3799
  sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
3541
3800
  renderBreadcrumb(document.getElementById('source-title'), path);
@@ -3548,6 +3807,7 @@ function openSourceFile(path, shouldSwitch = true) {
3548
3807
  return;
3549
3808
  }
3550
3809
  rememberRecent(path, 'source');
3810
+ sourceBodyPath = path; // past the lazy guard — every branch below paints THIS path's body (text/image/not-embedded)
3551
3811
  document.getElementById('source-viewer').dataset.openPath = path;
3552
3812
  sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
3553
3813
  renderBreadcrumb(document.getElementById('source-title'), path);
@@ -3705,6 +3965,34 @@ function renderInlineMd(text) {
3705
3965
  return s;
3706
3966
  }
3707
3967
 
3968
+ // Render HTML embedded in Markdown (GitHub-style) safely. Parse in an INERT <template> — scripts don't
3969
+ // run and resources don't load there — then strip dangerous tags + on*/javascript: attributes before
3970
+ // returning the HTML. The result is injected via innerHTML, so only the sanitized subset survives.
3971
+ function sanitizeHtml(html) {
3972
+ var tpl = document.createElement('template');
3973
+ tpl.innerHTML = String(html);
3974
+ var BAD = { SCRIPT: 1, STYLE: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, LINK: 1, META: 1, BASE: 1, FORM: 1, INPUT: 1, BUTTON: 1, TEXTAREA: 1, SELECT: 1, NOSCRIPT: 1 };
3975
+ var walk = function (node) {
3976
+ var kids = Array.prototype.slice.call(node.children || []);
3977
+ for (var k = 0; k < kids.length; k++) {
3978
+ var el = kids[k];
3979
+ if (BAD[el.tagName]) { el.parentNode.removeChild(el); continue; }
3980
+ var attrs = Array.prototype.slice.call(el.attributes);
3981
+ for (var a = 0; a < attrs.length; a++) {
3982
+ var nm = attrs[a].name.toLowerCase();
3983
+ if (nm.indexOf('on') === 0) { el.removeAttribute(attrs[a].name); continue; }
3984
+ if ((nm === 'href' || nm === 'src' || nm === 'xlink:href' || nm === 'srcset')
3985
+ && /^\s*(javascript|vbscript|data:text\/html):/i.test(attrs[a].value || '')) {
3986
+ el.removeAttribute(attrs[a].name);
3987
+ }
3988
+ }
3989
+ walk(el);
3990
+ }
3991
+ };
3992
+ walk(tpl.content);
3993
+ return tpl.innerHTML;
3994
+ }
3995
+
3708
3996
  function mdFenceLang(lang) {
3709
3997
  var l = (lang || '').toLowerCase();
3710
3998
  if (l === 'js' || l === 'jsx' || l === 'ts' || l === 'tsx') return 'typescript';
@@ -3740,6 +4028,16 @@ function renderMarkdownBlocks(content) {
3740
4028
  continue;
3741
4029
  }
3742
4030
  if (/^\s*$/.test(line)) { i++; continue; }
4031
+ // Raw HTML block (GitHub-flavored Markdown): a line beginning with a tag. Accumulate to the next
4032
+ // blank line and render it as sanitized HTML, so README markup (<div>, <img>, <table>, …) shows
4033
+ // rendered instead of as escaped text.
4034
+ if (/^\s*<(\/?[a-zA-Z][\w-]*|!--)/.test(line)) {
4035
+ var hbuf = [line];
4036
+ i++;
4037
+ while (i < lines.length && !/^\s*$/.test(lines[i])) { hbuf.push(lines[i]); i++; }
4038
+ blocks.push({ line: start, html: '<div class="md-html">' + sanitizeHtml(hbuf.join('\n')) + '</div>' });
4039
+ continue;
4040
+ }
3743
4041
  var h = line.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
3744
4042
  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; }
3745
4043
  if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) { blocks.push({ line: start, html: '<hr class="md-hr">' }); i++; continue; }
@@ -4167,6 +4465,14 @@ function highlightLine(text, language) {
4167
4465
  index = end;
4168
4466
  continue;
4169
4467
  }
4468
+ if (char === '@') {
4469
+ const decorator = rest.match(/^@[A-Za-z_$][\w$.]*/);
4470
+ if (decorator) {
4471
+ output += '<span class="tok-decorator">' + escapeHtml(decorator[0]) + '</span>';
4472
+ index += decorator[0].length;
4473
+ continue;
4474
+ }
4475
+ }
4170
4476
  const number = rest.match(/^\b\d+(?:\.\d+)?\b/);
4171
4477
  if (number) {
4172
4478
  output += '<span class="tok-number">' + escapeHtml(number[0]) + '</span>';
@@ -4176,8 +4482,11 @@ function highlightLine(text, language) {
4176
4482
  const identifier = rest.match(/^[A-Za-z_$][\w$-]*/);
4177
4483
  if (identifier) {
4178
4484
  const value = identifier[0];
4485
+ const trailing = text.slice(index + value.length);
4179
4486
  if (keywords.has(value)) output += '<span class="tok-keyword">' + escapeHtml(value) + '</span>';
4180
4487
  else if (literals.has(value)) output += '<span class="tok-literal">' + escapeHtml(value) + '</span>';
4488
+ else if (/^\s*\(/.test(trailing)) output += '<span class="tok-function">' + escapeHtml(value) + '</span>';
4489
+ else if (/^[A-Z]/.test(value) && /[a-z]/.test(value)) output += '<span class="tok-type">' + escapeHtml(value) + '</span>';
4181
4490
  else output += escapeHtml(value);
4182
4491
  index += value.length;
4183
4492
  continue;