@happy-nut/monacori 0.1.2 → 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.
@@ -116,6 +116,37 @@ if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true
116
116
  const links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
117
117
  let sourceLinks = Array.from(document.querySelectorAll('.source-link')); // re-captured when a deferred tree materializes
118
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
+ }
119
150
  const fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
120
151
  const httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
121
152
  const httpEnvNames = Object.keys(httpEnvironments);
@@ -130,6 +161,7 @@ var sourceLoaded = !REVIEW_LAZY_LOAD;
130
161
  var pendingSourceOpen = null;
131
162
  var sourceLoading = false;
132
163
  var pendingSymbol = null;
164
+ var sourceTabs = []; // Files-mode tab paths (session-only); see addSourceTab / renderSourceTabs.
133
165
  // The source blob (content + image base64) is large on big repos, so lazy-LOAD fetches it lazily — on
134
166
  // the first source-view open or go-to-definition — not eagerly at startup. Idempotent.
135
167
  function loadSourceData() {
@@ -202,7 +234,7 @@ let measuredCharWidth = 0;
202
234
  // restoreUiState()/openDefaultSourceFile() run on startup and try to render them.
203
235
  var COMMENTS_KEY = 'monacori-comments:' + location.pathname;
204
236
  var reviewComments = [];
205
- try { reviewComments = JSON.parse(localStorage.getItem(COMMENTS_KEY) || '[]'); } catch (commentsErr) { 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 []; } })();
206
238
  if (!Array.isArray(reviewComments)) reviewComments = [];
207
239
  var commentSeq = reviewComments.reduce(function (max, c) { return Math.max(max, c.seq || 0); }, 0);
208
240
  var composerState = null;
@@ -243,7 +275,7 @@ function prepareViewedControls() {
243
275
  const toggle = wrapper.querySelector('.d2h-file-collapse');
244
276
  const input = toggle?.querySelector('input');
245
277
  if (!fileName || !toggle || !input) return;
246
- toggle.title = 'Toggle viewed (<)';
278
+ toggle.title = t('btn.viewed.title');
247
279
  input.tabIndex = -1;
248
280
  toggle.addEventListener('click', (event) => {
249
281
  event.preventDefault();
@@ -407,6 +439,22 @@ function renderBreadcrumb(container, path) {
407
439
  });
408
440
  }
409
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
+
410
458
  function setActive(index, shouldScroll = true) {
411
459
  if (hunkTotal() === 0) return;
412
460
  current = ((index % hunkTotal()) + hunkTotal()) % hunkTotal();
@@ -438,15 +486,14 @@ function setActive(index, shouldScroll = true) {
438
486
  // F7/change navigation moves the caret but must NOT pollute the Cmd+[/] cursor history.
439
487
  navSuppress = true;
440
488
  try { focusDiffRow(targetRow); } finally { navSuppress = false; }
441
- if (shouldScroll && targetRow) targetRow.scrollIntoView({ block: 'center' });
489
+ if (shouldScroll && targetRow) scheduleDiffScroll(targetRow);
442
490
  });
443
491
  }
444
492
 
445
493
  function showOnlyFile(fileName) {
446
494
  if (REVIEW_LAZY) ensureFileReady(diffWrapperByPath(fileName));
447
495
  document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
448
- const name = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
449
- wrapper.classList.toggle('df-inactive', name !== fileName);
496
+ wrapper.classList.toggle('df-inactive', diffWrapperPathKey(wrapper) !== fileName);
450
497
  });
451
498
  ensureDiffCursor();
452
499
  }
@@ -474,8 +521,10 @@ function hunkIndexAtCaret() {
474
521
  // New-side row indices, one per change block — a run of change rows (ins/del) separated by context.
475
522
  // A wide context window merges several edits into one @@ hunk; stepping by these stops at each edit.
476
523
  function changeBlockAnchors(wrapper) {
524
+ if (!wrapper) return [];
525
+ if (wrapper.__anchors) return wrapper.__anchors;
477
526
  var right = diffSideTables(wrapper).right;
478
- if (!right) return [];
527
+ if (!right) return []; // body not materialized yet — don't cache an empty result
479
528
  var rows = diffRowsOf(right);
480
529
  var anchors = [];
481
530
  var prev = false;
@@ -484,6 +533,7 @@ function changeBlockAnchors(wrapper) {
484
533
  if (chg && !prev) anchors.push(i);
485
534
  prev = chg;
486
535
  }
536
+ wrapper.__anchors = anchors; // change-block layout is static once materialized
487
537
  return anchors;
488
538
  }
489
539
 
@@ -501,7 +551,7 @@ function next(delta) {
501
551
  else { for (let b = anchors.length - 1; b >= 0; b--) { if (anchors[b] < cur) { target = anchors[b]; break; } } }
502
552
  if (target != null) {
503
553
  const row = diffRowAt(w, 'new', target);
504
- if (row) { navSuppress = true; try { focusDiffRow(row); } finally { navSuppress = false; } row.scrollIntoView({ block: 'center' }); return; }
554
+ if (row) { navSuppress = true; try { focusDiffRow(row); } finally { navSuppress = false; } scheduleDiffScroll(row); return; }
505
555
  }
506
556
  }
507
557
  }
@@ -535,7 +585,7 @@ function firstHunkForPath(path) {
535
585
  function openQuickOpen(mode) {
536
586
  if (!quickOpen || !quickInput || !quickModeLabel) return;
537
587
  quickMode = mode;
538
- quickModeLabel.textContent = mode === 'recent' ? 'Recent files' : mode === 'content' ? 'Find in Files' : 'Search files';
588
+ quickModeLabel.textContent = mode === 'recent' ? t('quickopen.recent') : mode === 'content' ? t('quickopen.findInFiles') : t('quickopen.searchFiles');
539
589
  quickOpen.classList.remove('hidden');
540
590
  quickInput.value = '';
541
591
  renderQuickOpenResults();
@@ -590,7 +640,7 @@ function renderQuickOpenResults() {
590
640
  .slice(0, 80);
591
641
  quickActive = Math.min(quickActive, Math.max(quickItems.length - 1, 0));
592
642
  if (quickItems.length === 0) {
593
- quickResults.innerHTML = '<div class="quick-open-empty">No files found.</div>';
643
+ quickResults.innerHTML = '<div class="quick-open-empty">' + escapeHtml(t('quickopen.noFiles')) + '</div>';
594
644
  return;
595
645
  }
596
646
  quickResults.innerHTML = quickItems.map((item, index) => [
@@ -1063,6 +1113,9 @@ document.addEventListener('keydown', (event) => {
1063
1113
  }
1064
1114
 
1065
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
+ }
1066
1119
  if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && (event.key === '[' || event.key === ']')) {
1067
1120
  var navEl = document.activeElement;
1068
1121
  var navInField = navEl && (navEl.tagName === 'INPUT' || navEl.tagName === 'TEXTAREA' || navEl.tagName === 'SELECT');
@@ -1139,6 +1192,12 @@ document.querySelectorAll('.tab').forEach((button) => {
1139
1192
  });
1140
1193
 
1141
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
+ });
1142
1201
  document.getElementById('diff-viewed-toggle')?.addEventListener('click', function () {
1143
1202
  var btn = document.getElementById('diff-viewed-toggle');
1144
1203
  var path = btn ? (btn.dataset.file || '') : '';
@@ -1154,11 +1213,12 @@ document.addEventListener('keydown', function (event) {
1154
1213
  }, true);
1155
1214
  document.addEventListener('copy', handleSourceCopy);
1156
1215
 
1216
+ applyI18n(); // first paint already shows English (inline); this swaps to the saved locale before the rest of init renders dynamic text
1157
1217
  populateHttpEnvSelect();
1158
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
1159
1219
  const restored = restoreUiState();
1160
1220
  if (!restored) {
1161
- const initial = location.hash.match(/^#hunk-(\\d+)$/);
1221
+ const initial = location.hash.match(/^#hunk-(\d+)$/);
1162
1222
  if (initial) setActive(Number(initial[1]), false);
1163
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
1164
1224
  else openDefaultSourceFile();
@@ -1234,13 +1294,26 @@ function diffActiveWrapper() {
1234
1294
  return document.querySelector('#diff2html-container .d2h-file-wrapper:not(.df-inactive)')
1235
1295
  || document.querySelector('#diff2html-container .d2h-file-wrapper');
1236
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
+ }
1237
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();
1238
1311
  var ws = document.querySelectorAll('#diff2html-container .d2h-file-wrapper');
1239
1312
  for (var i = 0; i < ws.length; i++) {
1240
- var n = ws[i].querySelector('.d2h-file-name');
1241
- if (n && (n.textContent || '').trim() === path) return ws[i];
1313
+ var key = diffWrapperPathKey(ws[i]);
1314
+ if (key) wrapperPathMap.set(key, ws[i]);
1242
1315
  }
1243
- return null;
1316
+ return wrapperPathMap.get(path) || null;
1244
1317
  }
1245
1318
  function diffSideTables(wrapper) {
1246
1319
  var sides = wrapper ? wrapper.querySelectorAll('.d2h-file-side-diff') : [];
@@ -1338,6 +1411,7 @@ function renderDiffCaret() {
1338
1411
  } catch (e) { diffCaretSpan = null; }
1339
1412
  }
1340
1413
  function setDiffCursor(path, side, rowIndex, column, reveal) {
1414
+ markCaretBusy();
1341
1415
  var wrapper = diffWrapperByPath(path);
1342
1416
  if (!wrapper) return;
1343
1417
  var rows = diffRowsOf(diffSideTable(wrapper, side));
@@ -1513,13 +1587,13 @@ function handleDiffCaretKey(event) {
1513
1587
  // ===== Review comments: questions ("?") and change-requests (">") =====
1514
1588
  // (COMMENTS_KEY / reviewComments / commentSeq / composerState are declared near the top of the script)
1515
1589
  function saveComments() {
1516
- try { localStorage.setItem(COMMENTS_KEY, JSON.stringify(reviewComments)); } catch (e) {}
1590
+ persistSave(COMMENTS_KEY, reviewComments);
1517
1591
  }
1518
1592
  function commentsAt(path, line) {
1519
1593
  return reviewComments.filter(function (c) { return c.path === path && c.line === line; });
1520
1594
  }
1521
1595
  function commentKindLabel(kind) {
1522
- return kind === 'q' ? '❓ Question' : '✎ Change request';
1596
+ return kind === 'q' ? t('comment.kind.q') : t('comment.kind.c');
1523
1597
  }
1524
1598
  function relevantLines(path) {
1525
1599
  var set = {};
@@ -1607,17 +1681,17 @@ function threadHtml(path, line) {
1607
1681
  commentsAt(path, line).forEach(function (c) {
1608
1682
  html += '<div class="mc-card mc-' + c.kind + '">'
1609
1683
  + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(c.kind) + '</span>'
1610
- + '<button type="button" class="mc-del" data-seq="' + c.seq + '" title="Delete">×</button></div>'
1684
+ + '<button type="button" class="mc-del" data-seq="' + c.seq + '" title="' + escapeHtml(t('composer.delete')) + '">×</button></div>'
1611
1685
  + '<div class="mc-card-body">' + escapeHtml(c.text) + '</div></div>';
1612
1686
  });
1613
1687
  if (composerState && composerState.path === path && composerState.line === line) {
1614
- var ph = composerState.kind === 'q' ? 'Ask a question about this line' : 'Request a change for this line';
1688
+ var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
1615
1689
  html += '<div class="mc-card mc-' + composerState.kind + ' mc-composer">'
1616
1690
  + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(composerState.kind) + '</span></div>'
1617
- + '<textarea class="mc-input" rows="3" placeholder="' + ph + '"></textarea>'
1618
- + '<div class="mc-actions"><button type="button" class="mc-btn mc-save">Comment</button>'
1619
- + '<button type="button" class="mc-btn mc-ghost mc-cancel">Cancel</button>'
1620
- + '<span class="mc-hint">Cmd/Ctrl+Enter to save, Esc to cancel</span></div></div>';
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>';
1621
1695
  }
1622
1696
  return html;
1623
1697
  }
@@ -1684,8 +1758,8 @@ function renderCommentBadges() {
1684
1758
  var badge = document.createElement('span');
1685
1759
  badge.className = 'mc-file-badge';
1686
1760
  var html = '';
1687
- if (k.q) html += '<span class="mc-fb mc-fb-q" title="' + k.q + ' question(s)">' + k.q + '</span>';
1688
- if (k.c) html += '<span class="mc-fb mc-fb-c" title="' + k.c + ' change request(s)">' + k.c + '</span>';
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>';
1689
1763
  badge.innerHTML = html;
1690
1764
  return badge;
1691
1765
  }
@@ -1727,18 +1801,23 @@ function refreshComments() {
1727
1801
  renderCommentBadges();
1728
1802
  applyCommentSelectionHighlight();
1729
1803
  if (composerState) {
1730
- var focusComposerInput = function () {
1804
+ var composerFocusTries = 0;
1805
+ var tryFocusComposer = function () {
1731
1806
  var ta = document.querySelector('.mc-composer .mc-input');
1732
- if (ta && document.activeElement !== ta) {
1733
- try { ta.focus({ preventScroll: true }); } catch (e) { try { ta.focus(); } catch (e2) {} }
1734
- try { ta.selectionStart = ta.selectionEnd = ta.value.length; } catch (e3) {}
1735
- }
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;
1736
1812
  };
1737
- // Focus now, next frame, and next task: after a drag the browser may async-restore focus to
1738
- // the body (esp. in Electron), so retry across all three so the textarea reliably wins.
1739
- focusComposerInput();
1740
- requestAnimationFrame(focusComposerInput);
1741
- setTimeout(focusComposerInput, 0);
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
+ }
1742
1821
  }
1743
1822
  }
1744
1823
 
@@ -1765,18 +1844,34 @@ function saveComposer(ta) {
1765
1844
  refreshComments();
1766
1845
  }
1767
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
+
1768
1867
  function buildMergedText(kind) {
1769
1868
  var items = reviewComments.filter(function (c) { return c.kind === kind; });
1770
1869
  var nl = String.fromCharCode(10);
1771
1870
  var lines = [];
1772
- // Per-kind agent contract: questions are the understand-phase (answer, don't edit); change
1773
- // requests are the act-phase (edit the code). The merged view is an editable textarea, so the
1774
- // reviewer can trim or localize this before copying.
1775
- lines.push(kind === 'q'
1776
- ? 'The following are questions about code you just wrote. Answer each one — explain the intent, rationale, or context. Do not change any code; this clarifies understanding before any revisions.'
1777
- : 'The following are change requests for code you just wrote. For each, edit the code at the quoted location to satisfy the request. Keep changes minimal and focused; do not make unrelated edits.');
1871
+ // Per-kind agent contract heading (editable in Settings Merge prompts; default otherwise).
1872
+ lines.push(mergePromptFor(kind));
1778
1873
  lines.push('');
1779
- lines.push((kind === 'q' ? '# Questions' : '# Change requests') + ' (' + items.length + ')');
1874
+ lines.push((kind === 'q' ? t('merged.qHeading') : t('merged.cHeading')) + ' (' + items.length + ')');
1780
1875
  lines.push('');
1781
1876
  items.forEach(function (c) {
1782
1877
  lines.push('### ' + c.path + ':' + c.line);
@@ -1793,35 +1888,39 @@ function openMergedView(kind) {
1793
1888
  var modal = document.createElement('div');
1794
1889
  modal.id = 'mc-modal';
1795
1890
  modal.className = 'mc-modal';
1891
+ modal.dataset.kind = kind; // remembered so a live locale switch can re-render this same view
1796
1892
  var panel = document.createElement('div');
1797
1893
  panel.className = 'mc-modal-panel';
1798
1894
  var head = document.createElement('div');
1799
1895
  head.className = 'mc-modal-head';
1800
1896
  var title = document.createElement('span');
1801
- title.textContent = kind === 'q' ? 'Question comments' : 'Change-request comments';
1802
- var copyBtn = document.createElement('button');
1803
- copyBtn.type = 'button';
1804
- copyBtn.className = 'mc-btn';
1805
- copyBtn.textContent = 'Copy all';
1897
+ title.textContent = kind === 'q' ? t('merged.qTitle') : t('merged.cTitle');
1806
1898
  var closeBtn = document.createElement('button');
1807
1899
  closeBtn.type = 'button';
1808
1900
  closeBtn.className = 'mc-btn mc-ghost';
1809
- closeBtn.textContent = 'Close';
1901
+ closeBtn.textContent = t('merged.close');
1810
1902
  var area = document.createElement('textarea');
1811
1903
  area.className = 'mc-modal-text';
1812
1904
  area.readOnly = true;
1813
1905
  area.value = buildMergedText(kind);
1814
- copyBtn.addEventListener('click', function () {
1815
- area.focus(); area.select();
1816
- var ok = false;
1817
- try { ok = document.execCommand('copy'); } catch (e) {}
1818
- if (navigator.clipboard && navigator.clipboard.writeText) { try { navigator.clipboard.writeText(area.value); ok = true; } catch (e) {} }
1819
- copyBtn.textContent = ok ? 'Copied' : 'Copy failed';
1820
- setTimeout(function () { copyBtn.textContent = 'Copy all'; }, 1500);
1821
- });
1822
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
+ }
1823
1922
  head.appendChild(title);
1824
- head.appendChild(copyBtn);
1923
+ if (sendBtn) head.appendChild(sendBtn);
1825
1924
  head.appendChild(closeBtn);
1826
1925
  panel.appendChild(head);
1827
1926
  panel.appendChild(area);
@@ -1829,7 +1928,19 @@ function openMergedView(kind) {
1829
1928
  modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
1830
1929
  modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
1831
1930
  document.body.appendChild(modal);
1832
- requestAnimationFrame(function () { area.focus(); area.select(); });
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
+ }
1833
1944
  }
1834
1945
 
1835
1946
  document.addEventListener('click', function (event) {
@@ -1849,11 +1960,293 @@ document.addEventListener('keydown', function (event) {
1849
1960
 
1850
1961
  refreshComments();
1851
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
+
1852
2235
  // In Electron, the Review menu's Cmd/Ctrl+Shift+/ and +. accelerators arrive here via IPC
1853
2236
  // (macOS reserves Cmd+? for its Help search, so the menu claims it and routes to these views).
1854
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.
1855
2240
  window.monacoriMenu.onMergedView(function (kind) { openMergedView(kind); });
1856
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
+ }
1857
2250
 
1858
2251
  (function checkForUpdate() {
1859
2252
  var current = window.__MONACORI_VERSION__ || '';
@@ -1873,9 +2266,19 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onMergedView === 'function
1873
2266
  if (isNewer(latest, current)) {
1874
2267
  var flag = document.getElementById('app-update-flag');
1875
2268
  if (flag) flag.classList.remove('hidden');
1876
- if (status) { status.textContent = 'Update available: v' + latest; status.classList.add('has-update'); }
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
+ }
1877
2280
  } else if (status) {
1878
- status.textContent = 'Up to date (v' + current + ')';
2281
+ status.textContent = t('settings.upToDate') + ' (v' + current + ')';
1879
2282
  }
1880
2283
  };
1881
2284
  // Cache the npm result for the session so watch-mode reloads reuse it instead of refetching.
@@ -1893,28 +2296,81 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onMergedView === 'function
1893
2296
  .catch(function () {});
1894
2297
  })();
1895
2298
 
1896
- (function setupAppInfo() {
1897
- var btn = document.getElementById('app-info-btn');
1898
- var panel = document.getElementById('app-info');
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');
1899
2305
  var flag = document.getElementById('app-update-flag');
1900
- var copyBtn = document.getElementById('app-info-copy');
1901
- if (!btn || !panel) return;
1902
- var setOpen = function (open) { panel.classList.toggle('hidden', !open); };
1903
- btn.addEventListener('click', function (e) { e.stopPropagation(); setOpen(panel.classList.contains('hidden')); });
1904
- if (flag) flag.addEventListener('click', function (e) { e.stopPropagation(); setOpen(true); });
1905
- if (copyBtn) copyBtn.addEventListener('click', function () {
1906
- var cmd = 'npm i -g @happy-nut/monacori';
1907
- var done = function () { copyBtn.textContent = 'Copied'; setTimeout(function () { copyBtn.textContent = 'Copy'; }, 1200); };
1908
- if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(cmd).then(done).catch(function () {}); }
1909
- });
1910
- document.addEventListener('click', function (e) {
1911
- if (panel.classList.contains('hidden')) return;
1912
- if (panel.contains(e.target) || btn.contains(e.target)) return;
1913
- setOpen(false);
1914
- });
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).
1915
2331
  document.addEventListener('keydown', function (e) {
1916
- if (e.key === 'Escape' && !panel.classList.contains('hidden')) setOpen(false);
1917
- });
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
+ }
1918
2374
  })();
1919
2375
 
1920
2376
  function setTab(name) {
@@ -1933,7 +2389,7 @@ function ensureTreeRendered() {
1933
2389
  if (!panel || !island) return;
1934
2390
  var html = island.textContent || '';
1935
2391
  island.parentNode && island.parentNode.removeChild(island);
1936
- panel.innerHTML = '<div class="empty-nav">Building file tree…</div>';
2392
+ panel.innerHTML = '<div class="empty-nav">' + escapeHtml(t('source.buildingTree')) + '</div>';
1937
2393
  setTimeout(function () { // let "Building…" paint before the heavy innerHTML
1938
2394
  panel.innerHTML = html;
1939
2395
  sourceLinks = Array.from(document.querySelectorAll('.source-link'));
@@ -1975,6 +2431,11 @@ function saveUiState() {
1975
2431
  view: document.getElementById('source-viewer')?.classList.contains('hidden') ? 'diff' : 'source',
1976
2432
  sourcePath,
1977
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,
1978
2439
  }));
1979
2440
  }
1980
2441
 
@@ -1983,13 +2444,25 @@ function restoreUiState() {
1983
2444
  if (!raw) return false;
1984
2445
  try {
1985
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); });
1986
2449
  if (state.view === 'diff') {
1987
- const match = String(state.hash || location.hash || '').match(/^#hunk-(\\d+)$/);
2450
+ const match = String(state.hash || location.hash || '').match(/^#hunk-(\d+)$/);
1988
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
+ }
1989
2457
  return true;
1990
2458
  }
1991
2459
  if (state.sourcePath && sourceByPath.has(state.sourcePath)) {
1992
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
+ }
1993
2466
  return true;
1994
2467
  }
1995
2468
  } catch {
@@ -2007,14 +2480,14 @@ async function checkForLiveUpdate() {
2007
2480
  if (!response.ok) return;
2008
2481
  const state = await response.json();
2009
2482
  if (liveStatus && state.generatedAt) {
2010
- liveStatus.textContent = 'Live: updated ' + new Date(state.generatedAt).toLocaleTimeString();
2483
+ liveStatus.textContent = t('status.live.updated') + ' ' + new Date(state.generatedAt).toLocaleTimeString();
2011
2484
  }
2012
2485
  if (state.signature && state.signature !== currentSignature) {
2013
2486
  saveUiState();
2014
2487
  location.reload();
2015
2488
  }
2016
2489
  } catch {
2017
- if (liveStatus) liveStatus.textContent = 'Live: waiting for diff server';
2490
+ if (liveStatus) liveStatus.textContent = t('status.live.waiting');
2018
2491
  } finally {
2019
2492
  checkingForUpdates = false;
2020
2493
  }
@@ -2190,7 +2663,18 @@ function measureCharWidth(element) {
2190
2663
  return measuredCharWidth;
2191
2664
  }
2192
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
+
2193
2676
  function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLine = -1) {
2677
+ markCaretBusy();
2194
2678
  selectedCommentRow = null; // any explicit caret placement (click/move) ends a comment-box selection
2195
2679
  const file = sourceByPath.get(path);
2196
2680
  if (!file || !file.embedded) return;
@@ -2229,14 +2713,19 @@ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLi
2229
2713
  function updateSourceCaret(prev, lines, language) {
2230
2714
  const body = document.getElementById('source-body');
2231
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');
2232
2719
  const rowFor = (idx) => body.querySelector('.source-row[data-line-index="' + idx + '"]');
2233
2720
  // Restore the line the caret left: drop the caret span, re-highlight the full line.
2234
2721
  if (prev && prev.lineIndex !== viewerCursor.lineIndex) {
2235
2722
  const prevRow = rowFor(prev.lineIndex);
2236
2723
  if (prevRow) {
2237
2724
  prevRow.classList.remove('cursor-line');
2238
- const prevCell = prevRow.querySelector('.source-code');
2239
- if (prevCell) prevCell.innerHTML = highlightLine(lines[prev.lineIndex] || '', language);
2725
+ if (!rendered) {
2726
+ const prevCell = prevRow.querySelector('.source-code');
2727
+ if (prevCell) prevCell.innerHTML = highlightLine(lines[prev.lineIndex] || '', language);
2728
+ }
2240
2729
  }
2241
2730
  }
2242
2731
  // Reconcile the go-to-definition highlight (set only on symbol jumps, cleared on plain moves).
@@ -2244,10 +2733,12 @@ function updateSourceCaret(prev, lines, language) {
2244
2733
  if (viewerCursor.targetLine >= 0) rowFor(viewerCursor.targetLine)?.classList.add('symbol-target');
2245
2734
  // Rebuild the new caret line with the caret span.
2246
2735
  const row = rowFor(viewerCursor.lineIndex);
2247
- if (!row) { openSourceFile(viewerCursor.path, false); return; } // line not in the DOM — fall back to a full render
2736
+ if (!row) { if (!rendered) openSourceFile(viewerCursor.path, false); return; } // line not in the DOM — full re-render (eager source only)
2248
2737
  row.classList.add('cursor-line');
2249
- const cell = row.querySelector('.source-code');
2250
- if (cell) cell.innerHTML = renderLineWithCursor(lines[viewerCursor.lineIndex] || '', language, viewerCursor.column);
2738
+ if (!rendered) {
2739
+ const cell = row.querySelector('.source-code');
2740
+ if (cell) cell.innerHTML = renderLineWithCursor(lines[viewerCursor.lineIndex] || '', language, viewerCursor.column);
2741
+ }
2251
2742
  }
2252
2743
 
2253
2744
  function openSourceAt(path, lineIndex, column) {
@@ -2351,6 +2842,20 @@ function moveSourceCursor(dLine, dColumn, extend) {
2351
2842
  if (!viewerCursor) return;
2352
2843
  const file = sourceByPath.get(viewerCursor.path);
2353
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
+ }
2354
2859
  const lines = file.content.split(/\r?\n/);
2355
2860
  let line = viewerCursor.lineIndex;
2356
2861
  let col = viewerCursor.column;
@@ -2631,11 +3136,11 @@ function setIndexProgress(done, total) {
2631
3136
  var bar = document.getElementById('index-progress');
2632
3137
  if (!el) return;
2633
3138
  if (!total || done >= total) {
2634
- el.textContent = (total || 0) + ' indexed';
3139
+ el.textContent = (total || 0) + ' ' + t('status.indexed');
2635
3140
  if (bar) bar.classList.add('hidden');
2636
3141
  return;
2637
3142
  }
2638
- el.textContent = 'indexing ' + done + '/' + total + '…';
3143
+ el.textContent = t('status.indexing') + ' ' + done + '/' + total + '…';
2639
3144
  if (bar) {
2640
3145
  bar.classList.remove('hidden');
2641
3146
  var fill = bar.firstElementChild;
@@ -2712,9 +3217,56 @@ function setSourceTypeIcon(path) {
2712
3217
  var icon = link ? link.querySelector('.ftype') : null;
2713
3218
  holder.innerHTML = icon ? icon.outerHTML : '';
2714
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
+
2715
3265
  function openSourceFile(path, shouldSwitch = true) {
2716
3266
  const file = sourceByPath.get(path);
2717
3267
  if (!file) return;
3268
+ addSourceTab(path);
3269
+ renderSourceTabs(path);
2718
3270
  // lazy-LOAD: source content not fetched yet -> show a loading state; loadSourceData re-opens it.
2719
3271
  if (REVIEW_LAZY_LOAD && !sourceLoaded && file.embedded) {
2720
3272
  pendingSourceOpen = { path: path, shouldSwitch: shouldSwitch };
@@ -2726,7 +3278,7 @@ function openSourceFile(path, shouldSwitch = true) {
2726
3278
  revealTreeFor(path);
2727
3279
  var lb = document.getElementById('source-body');
2728
3280
  lb.className = 'source-body empty';
2729
- lb.textContent = 'Loading source';
3281
+ lb.textContent = t('source.loading');
2730
3282
  if (shouldSwitch) showSourceView();
2731
3283
  return;
2732
3284
  }
@@ -2736,12 +3288,9 @@ function openSourceFile(path, shouldSwitch = true) {
2736
3288
  renderBreadcrumb(document.getElementById('source-title'), path);
2737
3289
  setSourceTypeIcon(path);
2738
3290
  revealTreeFor(path);
2739
- const meta = [
2740
- file.language || 'text',
2741
- formatBytes(file.size || 0),
2742
- file.changed ? 'changed' : 'unchanged',
2743
- file.embedded ? 'searchable' : file.skippedReason || 'not embedded',
2744
- ].join(' | ');
3291
+ const meta = file.embedded
3292
+ ? formatBytes(file.size || 0)
3293
+ : formatBytes(file.size || 0) + ' · ' + (file.skippedReason || 'not embedded');
2745
3294
  document.getElementById('source-meta').textContent = meta;
2746
3295
  const body = document.getElementById('source-body');
2747
3296
  // Image files carry a data: URI preview instead of text — render inline (click to zoom).
@@ -2755,7 +3304,7 @@ function openSourceFile(path, shouldSwitch = true) {
2755
3304
  }
2756
3305
  if (!file.embedded) {
2757
3306
  body.className = 'source-body empty';
2758
- body.textContent = file.skippedReason ? 'Source preview unavailable: ' + file.skippedReason + '.' : 'Source preview unavailable.';
3307
+ body.textContent = file.skippedReason ? t('source.previewUnavailable').replace(/\.$/, '') + ': ' + file.skippedReason + '.' : t('source.previewUnavailable');
2759
3308
  document.getElementById('http-env-select')?.classList.add('hidden');
2760
3309
  updateRenderToggle(path);
2761
3310
  if (shouldSwitch) showSourceView();
@@ -2770,17 +3319,27 @@ function openSourceFile(path, shouldSwitch = true) {
2770
3319
  // is a .source-row keyed by its start line, so the gutter shows line numbers and line/block comments
2771
3320
  // work exactly as in the plain source view (renderSourceComments anchors on .source-row[data-line-index]).
2772
3321
  if (isMarkdownPath(path)) {
2773
- body.classList.add('rendered-body');
2774
- body.innerHTML = renderMarkdownRows(file.content);
3322
+ if (renderRawMode) {
3323
+ body.innerHTML = renderSourceTable(file, '');
3324
+ } else {
3325
+ body.classList.add('rendered-body');
3326
+ body.innerHTML = renderMarkdownRows(file.content);
3327
+ }
2775
3328
  if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
3329
+ updateRenderToggle(path);
2776
3330
  renderSourceComments();
2777
3331
  if (shouldSwitch) showSourceView();
2778
3332
  return;
2779
3333
  }
2780
3334
  if (isCsvPath(path)) {
2781
- body.classList.add('rendered-body');
2782
- body.innerHTML = renderCsvRows(file.content, 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
+ }
2783
3341
  if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
3342
+ updateRenderToggle(path);
2784
3343
  renderSourceComments();
2785
3344
  if (shouldSwitch) showSourceView();
2786
3345
  return;
@@ -2792,12 +3351,45 @@ function openSourceFile(path, shouldSwitch = true) {
2792
3351
  body.innerHTML = renderSourceTable(file, '');
2793
3352
  if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
2794
3353
  }
3354
+ updateRenderToggle(path);
2795
3355
  renderSourceComments();
2796
3356
  if (shouldSwitch) showSourceView();
2797
3357
  }
2798
3358
 
2799
3359
  function isMarkdownPath(p) { return /\.(md|mdx|markdown)$/i.test(p || ''); }
2800
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
+ })();
2801
3393
 
2802
3394
  function renderImageView(file) {
2803
3395
  return '<div class="image-view">'