@happy-nut/monacori 0.1.11 → 0.1.13

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.
@@ -147,15 +147,51 @@ var locale = (function () {
147
147
  return (v === 'ko' || v === 'en') ? v : 'en';
148
148
  })();
149
149
  function t(key) { var m = (I18N[locale] || I18N.en || {}); return (m && key in m) ? m[key] : ((I18N.en && I18N.en[key]) || key); }
150
+ var langSelectRef = null, themeSelectRef = null;
151
+ // Replace a native <select> with a button that opens our custom dropdown (consistent with the comment
152
+ // dropdown + themable; native <select> popups ignore the app theme). getOptions() -> [{value,label}];
153
+ // returns { render } so localized labels can be refreshed on a language switch.
154
+ function setupCustomSelect(id, getOptions, getCurrent, onPick) {
155
+ var el = document.getElementById(id);
156
+ if (!el) return null;
157
+ function render() {
158
+ var cur = getCurrent();
159
+ var match = getOptions().filter(function (o) { return o.value === cur; })[0];
160
+ el.textContent = match ? match.label : cur;
161
+ }
162
+ el.addEventListener('click', function (e) {
163
+ e.preventDefault();
164
+ var r = el.getBoundingClientRect(), cur = getCurrent();
165
+ showCustomDropdown(r.left, r.bottom + 4, getOptions().map(function (o) {
166
+ return { label: (o.value === cur ? '✓ ' : '  ') + o.label, onSelect: function () { onPick(o.value); render(); } };
167
+ }));
168
+ });
169
+ render();
170
+ return { render: render };
171
+ }
150
172
  function applyI18n() {
151
173
  document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = t(el.getAttribute('data-i18n')); });
152
174
  document.querySelectorAll('[data-i18n-ph]').forEach(function (el) { el.setAttribute('placeholder', t(el.getAttribute('data-i18n-ph'))); });
153
175
  document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.setAttribute('title', t(el.getAttribute('data-i18n-title'))); });
154
176
  document.querySelectorAll('[data-i18n-aria]').forEach(function (el) { el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria'))); });
155
177
  document.documentElement.lang = locale;
156
- var sel = document.getElementById('settings-language');
157
- if (sel) sel.value = locale;
178
+ if (langSelectRef) langSelectRef.render();
179
+ if (themeSelectRef) themeSelectRef.render(); // theme labels are localized — refresh on a language switch too
180
+ }
181
+ // Theme mirrors the locale pattern: persisted choice, applied by toggling data-theme on <html> so the
182
+ // :root[data-theme="light"] palette takes over. Dark is the default (matches the inline :root). Applied
183
+ // immediately at script start to minimize a first-paint flash from the dark default to light.
184
+ var THEME_KEY = 'monacori-theme';
185
+ var theme = (function () {
186
+ var v = persistRead(THEME_KEY);
187
+ if (v !== 'light' && v !== 'dark') { try { v = localStorage.getItem(THEME_KEY); } catch (e) {} }
188
+ return (v === 'light' || v === 'dark') ? v : 'dark';
189
+ })();
190
+ function applyTheme() {
191
+ document.documentElement.setAttribute('data-theme', theme);
192
+ if (themeSelectRef) themeSelectRef.render();
158
193
  }
194
+ applyTheme();
159
195
  let fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
160
196
  let httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
161
197
  let httpEnvNames = Object.keys(httpEnvironments);
@@ -168,6 +204,10 @@ let sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
168
204
  // and the source view shows a brief loading state. Non-lazy-load modes embed source -> already loaded.
169
205
  var sourceLoaded = !REVIEW_LAZY_LOAD;
170
206
  var pendingSourceOpen = null;
207
+ // The path whose content is ACTUALLY painted in #source-body right now. dataset.openPath is the INTENDED
208
+ // path and gets set BEFORE the body paints in the lazy-LOAD branch, so the caret fast-path must check this
209
+ // instead — else it patches the caret onto a stale body, leaving one file's content under another's path.
210
+ var sourceBodyPath = null;
171
211
  var sourceLoading = false;
172
212
  var pendingSymbol = null;
173
213
  var sourceTabs = []; // Files-mode tab paths (session-only); see addSourceTab / renderSourceTabs.
@@ -194,6 +234,7 @@ function loadSourceData() {
194
234
  sourceLoaded = true;
195
235
  sourceLoading = false;
196
236
  scheduleSymbolIndex();
237
+ remapComments(); // content just arrived — reconcile comment anchors against it
197
238
  if (pendingSourceOpen) { var po = pendingSourceOpen; pendingSourceOpen = null; openSourceFile(po.path, po.shouldSwitch); }
198
239
  else if (isSourceViewerVisible() && document.getElementById('source-viewer').dataset.openPath) { openSourceFile(document.getElementById('source-viewer').dataset.openPath, false); }
199
240
  if (pendingSymbol) { var s = pendingSymbol; pendingSymbol = null; goToDefOrUsages(s); }
@@ -831,17 +872,33 @@ function isTreeRowVisible(el) {
831
872
  function treeRows() {
832
873
  const panel = document.querySelector('.tab-panel:not(.hidden)');
833
874
  if (!panel) return [];
834
- return Array.from(panel.querySelectorAll('summary, .file-link')).filter((el) => el.getClientRects().length > 0 && isTreeRowVisible(el));
875
+ // isTreeRowVisible walks ancestor <details> (cheap, layout-free) and already excludes rows inside
876
+ // collapsed folders. The previous extra `getClientRects().length > 0` check forced a SYNCHRONOUS
877
+ // reflow per node — 6k forced layouts on every arrow key in a large source tree, which froze input.
878
+ // The details walk makes the rects check redundant, so drop it.
879
+ return Array.from(panel.querySelectorAll('summary, .file-link')).filter(isTreeRowVisible);
835
880
  }
836
881
 
837
882
  function focusTree(index) {
838
883
  const rows = treeRows();
839
884
  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
885
  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); }
886
+ // Render the focus class AND scroll in the SAME frame. A fast key-repeat queues many ArrowDowns before a
887
+ // frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump ~one
888
+ // viewport (~20 rows) at a time. Coalescing both keeps focus + scroll in lockstep so it scrolls smoothly.
889
+ scheduleTreeFocus();
890
+ }
891
+ var treeFocusRaf = 0;
892
+ function scheduleTreeFocus() {
893
+ if (treeFocusRaf) return;
894
+ treeFocusRaf = requestAnimationFrame(function () {
895
+ treeFocusRaf = 0;
896
+ const rows = treeRows();
897
+ if (treeFocusIndex < 0 || treeFocusIndex >= rows.length) return;
898
+ const el = rows[treeFocusIndex];
899
+ document.querySelectorAll('.tree-focus').forEach((e) => { if (e !== el) e.classList.remove('tree-focus'); });
900
+ if (el) { el.classList.add('tree-focus'); el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }
901
+ });
845
902
  }
846
903
 
847
904
  function clearTreeFocus() {
@@ -1171,14 +1228,19 @@ document.addEventListener('keydown', (event) => {
1171
1228
 
1172
1229
  if (event.key === 'F7') {
1173
1230
  event.preventDefault();
1174
- if (!document.getElementById('source-viewer')?.classList.contains('hidden')) {
1175
- const sourceHunk = firstHunkForPath(document.getElementById('source-viewer')?.dataset.openPath || '');
1231
+ const delta = event.shiftKey ? -1 : 1;
1232
+ const sourceViewer = document.getElementById('source-viewer');
1233
+ // Forward F7 from the source view enters the diff at the open file's own hunk, so the reviewer lands
1234
+ // where they were reading. Shift+F7 — and any file with no hunk of its own — falls through to plain
1235
+ // prev/next-change navigation across the whole diff.
1236
+ if (delta > 0 && sourceViewer && !sourceViewer.classList.contains('hidden')) {
1237
+ const sourceHunk = firstHunkForPath(sourceViewer.dataset.openPath || '');
1176
1238
  if (sourceHunk >= 0) {
1177
1239
  setActive(sourceHunk);
1178
1240
  return;
1179
1241
  }
1180
1242
  }
1181
- next(event.shiftKey ? -1 : 1);
1243
+ next(delta);
1182
1244
  }
1183
1245
  });
1184
1246
 
@@ -1213,15 +1275,18 @@ document.getElementById('usages')?.addEventListener('click', function (event) {
1213
1275
  if (event.target && event.target.id === 'usages') closeUsages();
1214
1276
  });
1215
1277
 
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
- });
1278
+ // Delegated (like #files-panel below) so it survives the in-place diff update that re-captures `links`
1279
+ // on every watch tick — per-element listeners would be lost on the new nodes, and then Cmd+0 → arrow →
1280
+ // Enter (which calls row.click()) would silently do nothing.
1281
+ document.getElementById('changes-panel')?.addEventListener('click', (event) => {
1282
+ const link = event.target && event.target.closest ? event.target.closest('.file-link') : null;
1283
+ if (!link) return;
1284
+ showDiffView(false);
1285
+ const target = Number(link.dataset.hunk);
1286
+ if (!Number.isNaN(target) && target >= 0 && target < hunkTotal()) {
1287
+ event.preventDefault();
1288
+ setActive(target);
1289
+ }
1225
1290
  });
1226
1291
 
1227
1292
  // Delegated so it works whether the tree is inline (small repos) or materialized later (big repos).
@@ -1262,8 +1327,11 @@ if (!REVIEW_LAZY_LOAD) scheduleSymbolIndex(); // non-lazy indexes when idle; laz
1262
1327
  const restored = restoreUiState();
1263
1328
  if (!restored) {
1264
1329
  const initial = location.hash.match(/^#hunk-(\d+)$/);
1330
+ const hasDiff = Boolean(document.querySelector('#diff2html-container .d2h-file-wrapper'));
1265
1331
  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
1332
+ // Clean tree (nothing to review): open a file (README first) instead of staring at an empty diff.
1333
+ else if (!hasDiff) openDefaultSourceFile();
1334
+ 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
1335
  else openDefaultSourceFile();
1268
1336
  }
1269
1337
  initSourceTreeFolds();
@@ -1476,11 +1544,32 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
1476
1544
  var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
1477
1545
  diffCursor = { path: path, side: side, rowIndex: ri, column: col };
1478
1546
  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));
1547
+ if (reveal) {
1548
+ // Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
1549
+ // before one frame; rendering the caret immediately (while the coalesced scroll lags) would push it many
1550
+ // rows past the viewport, then the view would snap ~one viewport at a time. Coalescing both keeps the
1551
+ // caret and scroll in lockstep, so holding ArrowDown scrolls smoothly instead of jumping every ~15 lines.
1552
+ scheduleDiffReveal(wrapper, side, ri);
1553
+ } else {
1554
+ renderDiffCaret();
1555
+ applyDiffSelection();
1556
+ }
1482
1557
  recordNav(navEntryOf('diff'));
1483
1558
  }
1559
+ var diffRevealRaf = 0, diffRevealTarget = null;
1560
+ function scheduleDiffReveal(wrapper, side, ri) {
1561
+ diffRevealTarget = { wrapper: wrapper, side: side, ri: ri };
1562
+ if (diffRevealRaf) return;
1563
+ diffRevealRaf = requestAnimationFrame(function () {
1564
+ diffRevealRaf = 0;
1565
+ var t = diffRevealTarget; diffRevealTarget = null;
1566
+ renderDiffCaret();
1567
+ applyDiffSelection();
1568
+ if (!t) return;
1569
+ var row = diffRowAt(t.wrapper, t.side, t.ri);
1570
+ if (row && row.scrollIntoView) { try { row.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (x) {} }
1571
+ });
1572
+ }
1484
1573
  function navEntryOf(kind) {
1485
1574
  if (kind === 'diff') {
1486
1575
  if (!diffCursor) return null;
@@ -1639,6 +1728,52 @@ function handleDiffCaretKey(event) {
1639
1728
 
1640
1729
  // ===== Review comments: questions ("?") and change-requests (">") =====
1641
1730
  // (COMMENTS_KEY / reviewComments / commentSeq / composerState are declared near the top of the script)
1731
+ // Bottom-left, non-blocking toast stack; each toast auto-dismisses. Used to tell the user when a file
1732
+ // change made some comments untrackable (they were removed).
1733
+ function showToast(message) {
1734
+ var stack = document.getElementById('mc-toasts');
1735
+ if (!stack) { stack = document.createElement('div'); stack.id = 'mc-toasts'; document.body.appendChild(stack); }
1736
+ var el = document.createElement('div');
1737
+ el.className = 'mc-toast';
1738
+ el.textContent = message;
1739
+ stack.appendChild(el);
1740
+ requestAnimationFrame(function () { el.classList.add('show'); });
1741
+ setTimeout(function () {
1742
+ el.classList.add('hide');
1743
+ setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
1744
+ }, 4500);
1745
+ }
1746
+ // When a file changes, follow each comment to its snapshot line (c.code) in the new content: same line if
1747
+ // unchanged, else the nearest exact match of that line. If the line can't be found the change is too large
1748
+ // to trust — drop the comment and toast. Files whose content isn't loaded yet (lazy) are skipped here and
1749
+ // reconciled once loadSourceData brings the content in.
1750
+ function remapComments() {
1751
+ if (!reviewComments.length) return;
1752
+ var dropped = [], moved = 0;
1753
+ reviewComments = reviewComments.filter(function (c) {
1754
+ var file = sourceByPath.get(c.path);
1755
+ if (!file || !file.embedded || typeof file.content !== 'string' || !file.content) return true;
1756
+ var code = c.code == null ? '' : String(c.code);
1757
+ if (!code.trim()) return true;
1758
+ var lines = file.content.split(/\r?\n/);
1759
+ if (lines[c.line - 1] === code) return true;
1760
+ var best = -1, bestDist = Infinity;
1761
+ for (var i = 0; i < lines.length; i++) {
1762
+ if (lines[i] === code) { var d = Math.abs(i - (c.line - 1)); if (d < bestDist) { bestDist = d; best = i; } }
1763
+ }
1764
+ if (best >= 0) { if (c.line !== best + 1) moved++; c.line = best + 1; return true; }
1765
+ dropped.push(c);
1766
+ return false;
1767
+ });
1768
+ if (!dropped.length && !moved) return; // nothing changed — skip the save/re-render
1769
+ saveComments();
1770
+ var byPath = {};
1771
+ dropped.forEach(function (c) { byPath[c.path] = (byPath[c.path] || 0) + 1; });
1772
+ Object.keys(byPath).forEach(function (p) {
1773
+ showToast(t('toast.commentsDropped').replace('{n}', byPath[p]).replace('{file}', String(p).split('/').pop()));
1774
+ });
1775
+ refreshComments();
1776
+ }
1642
1777
  function saveComments() {
1643
1778
  persistSave(COMMENTS_KEY, reviewComments);
1644
1779
  }
@@ -1689,14 +1824,16 @@ function currentCommentTarget() {
1689
1824
  var f = Math.min(sa, sb), t = Math.max(sa, sb);
1690
1825
  return { path: viewerCursor.path, line: t + 1, code: selText, from: f + 1, to: t + 1, side: null };
1691
1826
  }
1692
- return { path: viewerCursor.path, line: viewerCursor.lineIndex + 1, code: '', from: null, to: null, side: null };
1827
+ var scaretFile = sourceByPath.get(viewerCursor.path);
1828
+ var scaretCode = (scaretFile && typeof scaretFile.content === 'string') ? (scaretFile.content.split(/\r?\n/)[viewerCursor.lineIndex] || '') : '';
1829
+ return { path: viewerCursor.path, line: viewerCursor.lineIndex + 1, code: scaretCode, from: null, to: null, side: null };
1693
1830
  }
1694
1831
  // Diff view: prefer the explicit diff caret when there is no text selection.
1695
1832
  if (!hasSel && diffCursor && isDiffViewVisible()) {
1696
1833
  var dwrap = diffWrapperByPath(diffCursor.path);
1697
1834
  var drow = dwrap ? diffRowAt(dwrap, diffCursor.side, diffCursor.rowIndex) : null;
1698
1835
  var dline = drow ? diffLineNumber(drow) : null;
1699
- if (dline != null) return { path: diffCursor.path, line: dline, code: '', from: null, to: null, side: null };
1836
+ if (dline != null) return { path: diffCursor.path, line: dline, code: diffLineText(drow) || '', from: null, to: null, side: null };
1700
1837
  }
1701
1838
  // Diff view with a selection (or click): anchor at the LAST line so the composer drops BELOW the
1702
1839
  // drag; capture the selected code + line span (used to keep the drag highlighted via .mc-sel-line).
@@ -1729,6 +1866,13 @@ function currentCommentTarget() {
1729
1866
  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
1867
  }
1731
1868
 
1869
+ // "live_trading_engine.py:424" (or ":420–424" for a multi-line drag) — shown in the composer head so the
1870
+ // reviewer always sees WHICH file + line(s) a comment targets instead of a bare, context-free box.
1871
+ function composerTargetLabel(s) {
1872
+ var base = (s.path || '').split('/').pop() || s.path || '';
1873
+ var loc = (s.from != null && s.to != null && s.from !== s.to) ? (s.from + '–' + s.to) : String(s.line);
1874
+ return base + ':' + loc;
1875
+ }
1732
1876
  function threadHtml(path, line) {
1733
1877
  var html = '';
1734
1878
  commentsAt(path, line).forEach(function (c) {
@@ -1740,7 +1884,7 @@ function threadHtml(path, line) {
1740
1884
  if (composerState && composerState.path === path && composerState.line === line) {
1741
1885
  var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
1742
1886
  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>'
1887
+ + '<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
1888
  + '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '"></textarea>'
1745
1889
  + '<div class="mc-actions"><button type="button" class="mc-btn mc-save">' + escapeHtml(t('composer.save')) + '</button>'
1746
1890
  + '<button type="button" class="mc-btn mc-ghost mc-cancel">' + escapeHtml(t('composer.cancel')) + '</button>'
@@ -1853,6 +1997,18 @@ function refreshComments() {
1853
1997
  if (isSourceViewerVisible()) renderSourceComments();
1854
1998
  renderCommentBadges();
1855
1999
  applyCommentSelectionHighlight();
2000
+ // Keep body.mc-composing (which hides the file caret) tied to the ACTUAL on-screen composer, not just
2001
+ // composerState. Leaving the composer by any path other than save/cancel (opening another file, switching
2002
+ // views) would otherwise leave the class stuck and hide EVERY caret — making arrow navigation and
2003
+ // comment-box selection look dead. This single sync point covers all refreshComments callers.
2004
+ var visibleComposer = false;
2005
+ var composerInputs = document.querySelectorAll('.mc-composer .mc-input');
2006
+ for (var ci = 0; ci < composerInputs.length; ci++) {
2007
+ if (composerInputs[ci].closest('#diff-view') && !isDiffViewVisible()) continue;
2008
+ if (composerInputs[ci].closest('#source-viewer') && !isSourceViewerVisible()) continue;
2009
+ visibleComposer = true; break;
2010
+ }
2011
+ document.body.classList.toggle('mc-composing', visibleComposer);
1856
2012
  if (composerState) {
1857
2013
  var composerFocusTries = 0;
1858
2014
  var tryFocusComposer = function () {
@@ -1881,7 +2037,8 @@ function openComposer(kind) {
1881
2037
  // Keep the dragged code visibly highlighted via the .mc-sel-line class (applyCommentSelectionHighlight),
1882
2038
  // and clear the native selection so its highlight doesn't bleed into the composer/cards below it.
1883
2039
  try { var psel = window.getSelection(); if (psel) psel.removeAllRanges(); } catch (e) {}
1884
- refreshComments();
2040
+ refreshComments(); // refreshComments syncs body.mc-composing from the on-screen composer
2041
+
1885
2042
  }
1886
2043
  function closeComposer() {
1887
2044
  if (!composerState) return;
@@ -1932,6 +2089,79 @@ function saveMergePrompt(kind, text) {
1932
2089
  persistSave(mergePromptsKey, saved);
1933
2090
  }
1934
2091
 
2092
+ // Reusable custom dropdown (keyboard + mouse). options: [{ label, onSelect }]. First item is pre-selected;
2093
+ // Arrow keys move, Enter chooses, Esc / click-outside dismiss. Replaces native <select>/menus everywhere.
2094
+ function showCustomDropdown(x, y, options) {
2095
+ var existing = document.getElementById('mc-dropdown');
2096
+ if (existing) existing.remove();
2097
+ var dd = document.createElement('div');
2098
+ dd.id = 'mc-dropdown';
2099
+ dd.className = 'mc-dropdown';
2100
+ var active = 0;
2101
+ function setActive(i) { active = i; for (var j = 0; j < dd.children.length; j++) dd.children[j].classList.toggle('active', j === i); }
2102
+ function close() { dd.remove(); document.removeEventListener('keydown', onKey, true); document.removeEventListener('mousedown', onOutside, true); }
2103
+ function onKey(e) {
2104
+ if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setActive(Math.min(active + 1, options.length - 1)); }
2105
+ else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setActive(Math.max(active - 1, 0)); }
2106
+ else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); var o = options[active]; close(); if (o) o.onSelect(); }
2107
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
2108
+ }
2109
+ function onOutside(e) { if (!dd.contains(e.target)) close(); }
2110
+ options.forEach(function (opt, i) {
2111
+ var item = document.createElement('button');
2112
+ item.type = 'button';
2113
+ item.className = 'mc-dropdown-item' + (i === 0 ? ' active' : '');
2114
+ item.textContent = opt.label;
2115
+ item.addEventListener('click', function () { close(); opt.onSelect(); });
2116
+ item.addEventListener('mousemove', function () { setActive(i); });
2117
+ dd.appendChild(item);
2118
+ });
2119
+ dd.style.left = Math.round(x) + 'px';
2120
+ dd.style.top = Math.round(y) + 'px';
2121
+ document.body.appendChild(dd);
2122
+ document.addEventListener('keydown', onKey, true);
2123
+ document.addEventListener('mousedown', onOutside, true);
2124
+ }
2125
+ // Map a char range in the merged textarea back to the comment seq(s) it covers. Each comment is a
2126
+ // "### path:line" block; the caret's block (or every block a selection spans) identifies the comment(s).
2127
+ function mergedCommentSeqs(kind, start, end) {
2128
+ var items = reviewComments.filter(function (c) { return c.kind === kind; });
2129
+ var text = buildMergedText(kind);
2130
+ var lines = text.split(String.fromCharCode(10));
2131
+ var seqs = [], pos = 0, idx = -1;
2132
+ for (var i = 0; i < lines.length; i++) {
2133
+ var lineStart = pos, lineEnd = pos + lines[i].length;
2134
+ if (lines[i].indexOf('### ') === 0) idx++;
2135
+ if (idx >= 0 && idx < items.length && lineEnd >= start && lineStart <= end) {
2136
+ var s = items[idx].seq;
2137
+ if (seqs.indexOf(s) < 0) seqs.push(s);
2138
+ }
2139
+ pos = lineEnd + 1;
2140
+ }
2141
+ return seqs;
2142
+ }
2143
+ function navigateToComment(seq) {
2144
+ var c = reviewComments.find(function (x) { return x.seq === seq; });
2145
+ if (!c) return;
2146
+ openSourceFile(c.path);
2147
+ requestAnimationFrame(function () { setSourceCursor(c.path, Math.max(0, (c.line || 1) - 1), 0, true, -1); });
2148
+ }
2149
+ // Move the merged-view caret to the next (dir=1) / previous (dir=-1) "### path:line" header and center it,
2150
+ // so Opt+Arrow steps comment-by-comment in the merged view.
2151
+ function jumpMergedComment(area, dir) {
2152
+ var text = area.value;
2153
+ var headers = [], pos = 0;
2154
+ text.split('\n').forEach(function (ln) { if (ln.indexOf('### ') === 0) headers.push(pos); pos += ln.length + 1; });
2155
+ if (!headers.length) return;
2156
+ var cur = area.selectionStart;
2157
+ var target;
2158
+ if (dir > 0) { target = headers.find(function (h) { return h > cur; }); if (target == null) target = headers[headers.length - 1]; }
2159
+ else { var before = headers.filter(function (h) { return h < cur; }); target = before.length ? before[before.length - 1] : headers[0]; }
2160
+ area.selectionStart = area.selectionEnd = target;
2161
+ var lineNum = text.slice(0, target).split('\n').length - 1;
2162
+ var lineH = parseFloat(getComputedStyle(area).lineHeight) || 18;
2163
+ area.scrollTop = Math.max(0, lineNum * lineH - area.clientHeight / 2);
2164
+ }
1935
2165
  function buildMergedText(kind) {
1936
2166
  var items = reviewComments.filter(function (c) { return c.kind === kind; });
1937
2167
  var nl = String.fromCharCode(10);
@@ -1969,8 +2199,44 @@ function openMergedView(kind) {
1969
2199
  closeBtn.textContent = t('merged.close');
1970
2200
  var area = document.createElement('textarea');
1971
2201
  area.className = 'mc-modal-text';
1972
- area.readOnly = true;
2202
+ // NOT readOnly: a readOnly textarea hides the caret in Chromium, yet we need it VISIBLE so the user sees
2203
+ // which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead — read-only in
2204
+ // effect while the caret and selection stay fully interactive.
1973
2205
  area.value = buildMergedText(kind);
2206
+ area.addEventListener('beforeinput', function (e) { e.preventDefault(); });
2207
+ // Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret — "Go to comment"
2208
+ // + "Remove" for a single caret; "Remove" only for a drag/select-all (can't navigate to many at once).
2209
+ // Removing here calls deleteComment(), which re-syncs the on-screen comment boxes via refreshComments.
2210
+ area.addEventListener('keydown', function (e) {
2211
+ // Opt/Alt + Arrow steps the caret to the next/previous comment block so you can move comment-to-comment
2212
+ // and act on each with Opt+Enter, without hand-scrolling.
2213
+ if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
2214
+ e.preventDefault();
2215
+ e.stopPropagation();
2216
+ jumpMergedComment(area, e.key === 'ArrowDown' ? 1 : -1);
2217
+ return;
2218
+ }
2219
+ if (!e.altKey || (e.key !== 'Enter' && e.code !== 'Enter')) return;
2220
+ e.preventDefault();
2221
+ e.stopPropagation();
2222
+ var seqs = mergedCommentSeqs(kind, area.selectionStart, area.selectionEnd);
2223
+ if (!seqs.length) return;
2224
+ var rect = area.getBoundingClientRect();
2225
+ var x = rect.left + 24, y = rect.top + 48;
2226
+ var rerender = function () {
2227
+ if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) { modal.remove(); return; }
2228
+ area.value = buildMergedText(kind);
2229
+ };
2230
+ if (area.selectionStart !== area.selectionEnd || seqs.length > 1) {
2231
+ showCustomDropdown(x, y, [{ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } }]);
2232
+ } else {
2233
+ var seq = seqs[0];
2234
+ showCustomDropdown(x, y, [
2235
+ { label: t('dropdown.navigate'), onSelect: function () { modal.remove(); navigateToComment(seq); } },
2236
+ { label: t('dropdown.remove'), onSelect: function () { deleteComment(seq); rerender(); } },
2237
+ ]);
2238
+ }
2239
+ });
1974
2240
  closeBtn.addEventListener('click', function () { modal.remove(); });
1975
2241
  // Terminal send (Electron, terminal open): close the modal and hand off to pane-pick mode ON the
1976
2242
  // terminal — the chosen pane is highlighted, the rest dimmed, arrows change the choice, Enter sends.
@@ -2314,12 +2580,21 @@ refreshComments();
2314
2580
  }
2315
2581
  }
2316
2582
  function toggle() { setOpen(!isOpen()); }
2583
+ // The keyboard shortcut is "focus-first": when the terminal is visible but focus is elsewhere, the first
2584
+ // press just moves focus INTO the terminal; only when it already owns focus does another press toggle it
2585
+ // closed. (The footer button stays a plain toggle — a mouse click should open/close in one step.)
2586
+ function toggleOrFocus() {
2587
+ if (!isOpen()) { setOpen(true); return; } // setOpen(true) also focuses the active pane
2588
+ var ae = document.activeElement;
2589
+ if (ae && panel.contains(ae)) { setOpen(false); return; } // focus already in the terminal → close
2590
+ if (active) { try { active.term.focus(); } catch (e) {} } // visible but unfocused → just grab focus
2591
+ }
2317
2592
 
2318
2593
  if (toggleBtn) toggleBtn.addEventListener('click', toggle);
2319
2594
  if (closeBtn) closeBtn.addEventListener('click', function () { setOpen(false); });
2320
2595
  // Toggle (Ctrl+`/Alt+F12) and split (Cmd+D) arrive from the Terminal menu accelerators (app-main),
2321
2596
  // 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);
2597
+ if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalToggle === 'function') window.monacoriMenu.onTerminalToggle(toggleOrFocus);
2323
2598
  if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalSplit === 'function') window.monacoriMenu.onTerminalSplit(split);
2324
2599
  if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneFocus === 'function') window.monacoriMenu.onTerminalPaneFocus(focusPaneByDelta);
2325
2600
  if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneRename === 'function') window.monacoriMenu.onTerminalPaneRename(function () { renamePane(active); });
@@ -2546,23 +2821,28 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
2546
2821
  if (resetBtn) resetBtn.addEventListener('click', function () { saveMergePrompt('q', ''); saveMergePrompt('c', ''); fill(); flash(); });
2547
2822
  // Language: live-switch the whole UI (no reload). Persist, re-apply the static chrome, then re-render
2548
2823
  // any currently-shown dynamic text (open composer / merged modal / index status) so it follows too.
2549
- var langSel = document.getElementById('settings-language');
2550
- if (langSel) {
2551
- langSel.value = locale;
2552
- langSel.addEventListener('change', function () {
2553
- var next = langSel.value === 'ko' ? 'ko' : 'en';
2824
+ langSelectRef = setupCustomSelect('settings-language',
2825
+ function () { return [{ value: 'en', label: 'English' }, { value: 'ko', label: '한국어' }]; },
2826
+ function () { return locale; },
2827
+ function (next) {
2554
2828
  if (next === locale) return;
2555
2829
  locale = next;
2556
2830
  persistSave(LOCALE_KEY, locale);
2557
2831
  applyI18n();
2558
- // Merge-prompt placeholders are locale-dependent defaults; refresh them while the panel is open.
2559
- fill();
2560
- // Re-render dynamic, currently-visible text in the new locale.
2832
+ fill(); // merge-prompt placeholders are locale-dependent defaults
2561
2833
  try { if (typeof refreshComments === 'function') refreshComments(); } catch (e) {}
2562
2834
  var mergedModal = document.getElementById('mc-modal');
2563
2835
  if (mergedModal) { var mk = mergedModal.dataset.kind || 'q'; mergedModal.remove(); openMergedView(mk); }
2564
2836
  });
2565
- }
2837
+ themeSelectRef = setupCustomSelect('settings-theme',
2838
+ function () { return [{ value: 'dark', label: t('theme.dark') }, { value: 'light', label: t('theme.light') }]; },
2839
+ function () { return theme; },
2840
+ function (next) {
2841
+ if (next === theme) return;
2842
+ theme = next;
2843
+ persistSave(THEME_KEY, theme);
2844
+ applyTheme();
2845
+ });
2566
2846
  })();
2567
2847
 
2568
2848
  function setTab(name) {
@@ -2707,6 +2987,7 @@ function applyDiffUpdate(u) {
2707
2987
  diffBootDone = false;
2708
2988
  sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
2709
2989
  sourceLoading = false;
2990
+ sourceBodyPath = null; // the new build may have changed the open file's content — force a body re-render on next open
2710
2991
  symbolIndex = null;
2711
2992
  if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
2712
2993
  else { prepareDiff2HtmlHunks(); diffBootDone = true; }
@@ -2716,6 +2997,7 @@ function applyDiffUpdate(u) {
2716
2997
  applyI18n();
2717
2998
  populateHttpEnvSelect();
2718
2999
  initSourceTreeFolds();
3000
+ remapComments(); // follow/drop comments whose anchor line moved or vanished in the new build
2719
3001
  refreshComments();
2720
3002
 
2721
3003
  // 5) Best-effort restore of what the user was looking at.
@@ -2785,7 +3067,14 @@ function updateTreeVisibility(root, query) {
2785
3067
  }
2786
3068
 
2787
3069
  function openDefaultSourceFile() {
3070
+ const isReadme = (candidate) => /^readme(\.|$)/i.test(candidate.name || '');
3071
+ const depthOf = (candidate) => (candidate.path || '').split('/').length;
3072
+ // Prefer the TOP-MOST README (root before any nested one), not just the first match in tree order.
3073
+ const rootReadme = sourceFiles
3074
+ .filter((candidate) => candidate.embedded && isReadme(candidate))
3075
+ .sort((a, b) => depthOf(a) - depthOf(b))[0];
2788
3076
  const file = sourceFiles.find((candidate) => candidate.changed && candidate.embedded)
3077
+ || rootReadme // top-most README when nothing changed
2789
3078
  || sourceFiles.find((candidate) => candidate.embedded)
2790
3079
  || sourceFiles.find((candidate) => candidate.changed)
2791
3080
  || sourceFiles[0];
@@ -2948,20 +3237,46 @@ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLi
2948
3237
  // Fast path: the file is already on screen and only the caret moved. Re-rendering the whole
2949
3238
  // file on every keystroke blocks the main thread on large files, so patch just the previous
2950
3239
  // and new caret lines in place instead.
3240
+ // sourceBodyPath (the file actually painted in the body) must match too — dataset.openPath/viewerCursor
3241
+ // are metadata that can be set before the body repaints (lazy fetch in flight, fast file switch, watch
3242
+ // refresh), so without this the caret patches a STALE body and one file's content shows under another's
3243
+ // breadcrumb. On mismatch we fall through to openSourceFile, which re-renders the body for `path`.
2951
3244
  const sameFileOpen = Boolean(viewer && viewer.dataset.openPath === path && !viewer.classList.contains('hidden')
2952
- && prev && prev.path === path && !isHttpFile(path));
3245
+ && prev && prev.path === path && !isHttpFile(path) && sourceBodyPath === path);
2953
3246
 
2954
3247
  viewerCursor = { path, lineIndex: boundedLine, column: boundedColumn, targetLine };
2955
3248
 
2956
3249
  if (sameFileOpen) {
2957
- updateSourceCaret(prev, lines, file.language || 'text');
3250
+ // Coalesce caret render + scroll into ONE frame on reveal (ArrowDown) so a fast key-repeat doesn't run
3251
+ // the caret several rows ahead of the lagging (rAF) scroll and snap ~one viewport at a time ("stutter
3252
+ // every ~26 lines"). Click (no reveal) stays instant.
3253
+ if (shouldReveal) scheduleSourceReveal(prev);
3254
+ else updateSourceCaret(prev, lines, file.language || 'text');
2958
3255
  } else {
2959
3256
  const shouldSwitch = !viewer || viewer.dataset.openPath !== path || viewer.classList.contains('hidden');
2960
3257
  openSourceFile(path, shouldSwitch);
3258
+ if (shouldReveal) scheduleScrollIntoView(document.querySelector('.source-row.cursor-line'));
2961
3259
  }
2962
- if (shouldReveal) scheduleScrollIntoView(document.querySelector('.source-row.cursor-line'));
2963
3260
  recordNav(navEntryOf('source'));
2964
3261
  }
3262
+ var sourceRevealRaf = 0, sourceRevealPrev = null;
3263
+ function scheduleSourceReveal(prev) {
3264
+ // First prev of a coalesced burst wins: a fast ArrowDown updates viewerCursor many times before the frame
3265
+ // fires; render the caret once (first prev -> final viewerCursor) and scroll in the SAME frame so caret and
3266
+ // scroll stay locked together instead of the scroll snapping a viewport behind.
3267
+ if (!sourceRevealRaf) sourceRevealPrev = prev;
3268
+ if (sourceRevealRaf) return;
3269
+ sourceRevealRaf = requestAnimationFrame(function () {
3270
+ sourceRevealRaf = 0;
3271
+ var p = sourceRevealPrev; sourceRevealPrev = null;
3272
+ var f = sourceByPath.get(viewerCursor.path);
3273
+ if (!f || !f.embedded) return;
3274
+ var lines = f.content.split(/\r?\n/);
3275
+ updateSourceCaret(p, lines, f.language || 'text');
3276
+ var cl = document.querySelector('.source-row.cursor-line');
3277
+ if (cl && cl.scrollIntoView) { try { cl.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (x) {} }
3278
+ });
3279
+ }
2965
3280
 
2966
3281
  // Move the caret by patching only the affected line cells, never the whole <table>. This keeps
2967
3282
  // large files responsive (no full re-highlight per keystroke) and, because the new caret line is
@@ -3399,17 +3714,21 @@ function startSymbolIndex() {
3399
3714
  function setIndexProgress(done, total) {
3400
3715
  var el = document.getElementById('index-status');
3401
3716
  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;
3717
+ var foot = document.getElementById('footer-progress');
3718
+ var running = Boolean(total) && done < total;
3719
+ var pct = running ? Math.round(done / total * 100) + '%' : '0%';
3720
+ if (el) {
3721
+ el.textContent = running ? (t('status.indexing') + ' ' + done + '/' + total + '…') : ((total || 0) + ' ' + t('status.indexed'));
3407
3722
  }
3408
- el.textContent = t('status.indexing') + ' ' + done + '/' + total + '…';
3409
3723
  if (bar) {
3410
- bar.classList.remove('hidden');
3411
- var fill = bar.firstElementChild;
3412
- if (fill) fill.style.width = Math.round(done / total * 100) + '%';
3724
+ bar.classList.toggle('hidden', !running);
3725
+ if (running && bar.firstElementChild) bar.firstElementChild.style.width = pct;
3726
+ }
3727
+ // The same signal, mirrored as a thin bar pinned under the version block at the bottom of the
3728
+ // sidebar — so background work (indexing) is visible even when the toolbar status is out of view.
3729
+ if (foot) {
3730
+ foot.classList.toggle('hidden', !running);
3731
+ if (foot.firstElementChild) foot.firstElementChild.style.width = pct;
3413
3732
  }
3414
3733
  }
3415
3734
  function wordAtDiffCaret() {
@@ -3530,12 +3849,16 @@ function cycleSourceTab(dir) {
3530
3849
  function openSourceFile(path, shouldSwitch = true) {
3531
3850
  const file = sourceByPath.get(path);
3532
3851
  if (!file) return;
3852
+ // Switching to another file abandons any in-progress comment elsewhere; closeComposer() clears
3853
+ // composerState and (via refreshComments) drops body.mc-composing so no caret stays hidden.
3854
+ if (composerState && composerState.path !== path) closeComposer();
3533
3855
  addSourceTab(path);
3534
3856
  renderSourceTabs(path);
3535
3857
  // lazy-LOAD: source content not fetched yet -> show a loading state; loadSourceData re-opens it.
3536
3858
  if (REVIEW_LAZY_LOAD && !sourceLoaded && file.embedded) {
3537
3859
  pendingSourceOpen = { path: path, shouldSwitch: shouldSwitch };
3538
3860
  loadSourceData();
3861
+ sourceBodyPath = null; // body shows a loading placeholder, not this path's content yet
3539
3862
  document.getElementById('source-viewer').dataset.openPath = path;
3540
3863
  sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
3541
3864
  renderBreadcrumb(document.getElementById('source-title'), path);
@@ -3548,6 +3871,7 @@ function openSourceFile(path, shouldSwitch = true) {
3548
3871
  return;
3549
3872
  }
3550
3873
  rememberRecent(path, 'source');
3874
+ sourceBodyPath = path; // past the lazy guard — every branch below paints THIS path's body (text/image/not-embedded)
3551
3875
  document.getElementById('source-viewer').dataset.openPath = path;
3552
3876
  sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
3553
3877
  renderBreadcrumb(document.getElementById('source-title'), path);
@@ -3705,6 +4029,34 @@ function renderInlineMd(text) {
3705
4029
  return s;
3706
4030
  }
3707
4031
 
4032
+ // Render HTML embedded in Markdown (GitHub-style) safely. Parse in an INERT <template> — scripts don't
4033
+ // run and resources don't load there — then strip dangerous tags + on*/javascript: attributes before
4034
+ // returning the HTML. The result is injected via innerHTML, so only the sanitized subset survives.
4035
+ function sanitizeHtml(html) {
4036
+ var tpl = document.createElement('template');
4037
+ tpl.innerHTML = String(html);
4038
+ 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 };
4039
+ var walk = function (node) {
4040
+ var kids = Array.prototype.slice.call(node.children || []);
4041
+ for (var k = 0; k < kids.length; k++) {
4042
+ var el = kids[k];
4043
+ if (BAD[el.tagName]) { el.parentNode.removeChild(el); continue; }
4044
+ var attrs = Array.prototype.slice.call(el.attributes);
4045
+ for (var a = 0; a < attrs.length; a++) {
4046
+ var nm = attrs[a].name.toLowerCase();
4047
+ if (nm.indexOf('on') === 0) { el.removeAttribute(attrs[a].name); continue; }
4048
+ if ((nm === 'href' || nm === 'src' || nm === 'xlink:href' || nm === 'srcset')
4049
+ && /^\s*(javascript|vbscript|data:text\/html):/i.test(attrs[a].value || '')) {
4050
+ el.removeAttribute(attrs[a].name);
4051
+ }
4052
+ }
4053
+ walk(el);
4054
+ }
4055
+ };
4056
+ walk(tpl.content);
4057
+ return tpl.innerHTML;
4058
+ }
4059
+
3708
4060
  function mdFenceLang(lang) {
3709
4061
  var l = (lang || '').toLowerCase();
3710
4062
  if (l === 'js' || l === 'jsx' || l === 'ts' || l === 'tsx') return 'typescript';
@@ -3740,6 +4092,16 @@ function renderMarkdownBlocks(content) {
3740
4092
  continue;
3741
4093
  }
3742
4094
  if (/^\s*$/.test(line)) { i++; continue; }
4095
+ // Raw HTML block (GitHub-flavored Markdown): a line beginning with a tag. Accumulate to the next
4096
+ // blank line and render it as sanitized HTML, so README markup (<div>, <img>, <table>, …) shows
4097
+ // rendered instead of as escaped text.
4098
+ if (/^\s*<(\/?[a-zA-Z][\w-]*|!--)/.test(line)) {
4099
+ var hbuf = [line];
4100
+ i++;
4101
+ while (i < lines.length && !/^\s*$/.test(lines[i])) { hbuf.push(lines[i]); i++; }
4102
+ blocks.push({ line: start, html: '<div class="md-html">' + sanitizeHtml(hbuf.join('\n')) + '</div>' });
4103
+ continue;
4104
+ }
3743
4105
  var h = line.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
3744
4106
  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
4107
  if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) { blocks.push({ line: start, html: '<hr class="md-hr">' }); i++; continue; }
@@ -4167,6 +4529,14 @@ function highlightLine(text, language) {
4167
4529
  index = end;
4168
4530
  continue;
4169
4531
  }
4532
+ if (char === '@') {
4533
+ const decorator = rest.match(/^@[A-Za-z_$][\w$.]*/);
4534
+ if (decorator) {
4535
+ output += '<span class="tok-decorator">' + escapeHtml(decorator[0]) + '</span>';
4536
+ index += decorator[0].length;
4537
+ continue;
4538
+ }
4539
+ }
4170
4540
  const number = rest.match(/^\b\d+(?:\.\d+)?\b/);
4171
4541
  if (number) {
4172
4542
  output += '<span class="tok-number">' + escapeHtml(number[0]) + '</span>';
@@ -4176,8 +4546,11 @@ function highlightLine(text, language) {
4176
4546
  const identifier = rest.match(/^[A-Za-z_$][\w$-]*/);
4177
4547
  if (identifier) {
4178
4548
  const value = identifier[0];
4549
+ const trailing = text.slice(index + value.length);
4179
4550
  if (keywords.has(value)) output += '<span class="tok-keyword">' + escapeHtml(value) + '</span>';
4180
4551
  else if (literals.has(value)) output += '<span class="tok-literal">' + escapeHtml(value) + '</span>';
4552
+ else if (/^\s*\(/.test(trailing)) output += '<span class="tok-function">' + escapeHtml(value) + '</span>';
4553
+ else if (/^[A-Z]/.test(value) && /[a-z]/.test(value)) output += '<span class="tok-type">' + escapeHtml(value) + '</span>';
4181
4554
  else output += escapeHtml(value);
4182
4555
  index += value.length;
4183
4556
  continue;