@happy-nut/monacori 0.1.2 → 0.1.5

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.
@@ -113,16 +113,47 @@ function setupLazyDiff() {
113
113
  if (wrappers[0]) ensureFileReady(wrappers[0]); // first file ready so the initial caret has a row to land on
114
114
  }
115
115
  if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
116
- const links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
116
+ let links = Array.from(document.querySelectorAll('#changes-panel .file-link')); // re-captured on in-place diff update
117
117
  let sourceLinks = Array.from(document.querySelectorAll('.source-link')); // re-captured when a deferred tree materializes
118
- const sourceFiles = JSON.parse(document.getElementById('source-files-data')?.textContent || '[]');
119
- const fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
120
- const httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
121
- const httpEnvNames = Object.keys(httpEnvironments);
118
+ let sourceFiles = JSON.parse(document.getElementById('source-files-data')?.textContent || '[]');
119
+ // i18n: the message catalog (en + ko) is emitted server-side; the locale lives in localStorage and the
120
+ // whole UI switches live (no reload). t() feeds dynamically-built text; applyI18n() rewrites the static
121
+ // chrome (data-i18n / -ph / -title / -aria). English is the first-paint default.
122
+ var I18N = JSON.parse(document.getElementById('i18n-data')?.textContent || '{}');
123
+ // Cross-reopen persistence. Electron persists via the main process (window.monacoriSettings — survives
124
+ // app restart; file:// localStorage doesn't); browser/serve falls back to localStorage. persistRead
125
+ // returns the bridge value (native) if present, else undefined so callers parse localStorage themselves.
126
+ function persistRead(key) {
127
+ try { if (window.monacoriSettings && window.monacoriSettings.all && key in window.monacoriSettings.all) return window.monacoriSettings.all[key]; } catch (e) {}
128
+ return undefined;
129
+ }
130
+ function persistSave(key, value) {
131
+ try { localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value)); } catch (e) {}
132
+ try { if (window.monacoriSettings) window.monacoriSettings.set(key, value); } catch (e2) {}
133
+ }
134
+ var LOCALE_KEY = 'monacori-locale';
135
+ var locale = (function () {
136
+ var v = persistRead(LOCALE_KEY);
137
+ if (v !== 'ko' && v !== 'en') { try { v = localStorage.getItem(LOCALE_KEY); } catch (e) {} }
138
+ return (v === 'ko' || v === 'en') ? v : 'en';
139
+ })();
140
+ function t(key) { var m = (I18N[locale] || I18N.en || {}); return (m && key in m) ? m[key] : ((I18N.en && I18N.en[key]) || key); }
141
+ function applyI18n() {
142
+ document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = t(el.getAttribute('data-i18n')); });
143
+ document.querySelectorAll('[data-i18n-ph]').forEach(function (el) { el.setAttribute('placeholder', t(el.getAttribute('data-i18n-ph'))); });
144
+ document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.setAttribute('title', t(el.getAttribute('data-i18n-title'))); });
145
+ document.querySelectorAll('[data-i18n-aria]').forEach(function (el) { el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria'))); });
146
+ document.documentElement.lang = locale;
147
+ var sel = document.getElementById('settings-language');
148
+ if (sel) sel.value = locale;
149
+ }
150
+ let fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
151
+ let httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
152
+ let httpEnvNames = Object.keys(httpEnvironments);
122
153
  const httpEnvKey = 'monacori-http-env:' + location.pathname;
123
154
  const httpRequestsByPath = new Map();
124
155
  const httpVarsByPath = new Map();
125
- const sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
156
+ let sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
126
157
  // Phase 2b lazy-LOAD: source content is fetched once after first paint (serve /source-data or the
127
158
  // Electron bridge) and merged into the metadata-only source records; until then sourceLoaded is false
128
159
  // and the source view shows a brief loading state. Non-lazy-load modes embed source -> already loaded.
@@ -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() {
@@ -152,16 +184,16 @@ function loadSourceData() {
152
184
  }
153
185
  sourceLoaded = true;
154
186
  sourceLoading = false;
155
- try { startSymbolIndex(); } catch (e) {}
187
+ scheduleSymbolIndex();
156
188
  if (pendingSourceOpen) { var po = pendingSourceOpen; pendingSourceOpen = null; openSourceFile(po.path, po.shouldSwitch); }
157
189
  else if (isSourceViewerVisible() && document.getElementById('source-viewer').dataset.openPath) { openSourceFile(document.getElementById('source-viewer').dataset.openPath, false); }
158
190
  if (pendingSymbol) { var s = pendingSymbol; pendingSymbol = null; goToDefOrUsages(s); }
159
191
  }, function () { sourceLoaded = true; sourceLoading = false; });
160
192
  }
161
- const fileSignatureByPath = new Map(fileStates.map((file) => [file.path, file.signature]));
193
+ let fileSignatureByPath = new Map(fileStates.map((file) => [file.path, file.signature]));
162
194
  const reviewMeta = document.getElementById('review-meta');
163
195
  const watchEnabled = reviewMeta?.dataset.watch === 'true';
164
- const currentSignature = reviewMeta?.dataset.signature || '';
196
+ let currentSignature = reviewMeta?.dataset.signature || '';
165
197
  const uiStateKey = 'monacori-diff-ui:' + location.pathname;
166
198
  const recentKey = 'monacori-diff-recent:' + location.pathname;
167
199
  const viewedKey = 'monacori-diff-viewed:' + location.pathname;
@@ -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,14 +439,42 @@ 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
+
458
+ var setActiveRaf = 0, setActiveScrollPending = true;
410
459
  function setActive(index, shouldScroll = true) {
411
460
  if (hunkTotal() === 0) return;
412
461
  current = ((index % hunkTotal()) + hunkTotal()) % hunkTotal();
462
+ // Coalesce rapid presses (holding/spamming F7 or Shift+F7) into one DOM apply per animation frame. The
463
+ // key handler returns immediately and `current` updates synchronously (so next()/nav math stays correct),
464
+ // while the heavy DOM work (full link/wrapper sweeps, body materialize) runs at most once per frame
465
+ // instead of once per keystroke — the input queue never blocks and can't pile up on big repos.
466
+ setActiveScrollPending = shouldScroll;
467
+ if (setActiveRaf) return;
468
+ setActiveRaf = requestAnimationFrame(function () {
469
+ setActiveRaf = 0;
470
+ applySetActive(current, setActiveScrollPending);
471
+ });
472
+ }
473
+ function applySetActive(idx, shouldScroll) {
413
474
  document.getElementById('source-viewer')?.classList.add('hidden');
414
475
  document.getElementById('diff-view')?.classList.remove('hidden');
415
476
  setTab('changes');
416
- const file = hunkPathAt(current);
417
- const idx = current;
477
+ const file = hunkPathAt(idx);
418
478
  links.forEach((link) => link.classList.toggle('active', link.dataset.file === file));
419
479
  renderBreadcrumb(document.getElementById('diff-breadcrumb'), file);
420
480
  var dvt = document.getElementById('diff-viewed-toggle');
@@ -438,15 +498,14 @@ function setActive(index, shouldScroll = true) {
438
498
  // F7/change navigation moves the caret but must NOT pollute the Cmd+[/] cursor history.
439
499
  navSuppress = true;
440
500
  try { focusDiffRow(targetRow); } finally { navSuppress = false; }
441
- if (shouldScroll && targetRow) targetRow.scrollIntoView({ block: 'center' });
501
+ if (shouldScroll && targetRow) scheduleDiffScroll(targetRow);
442
502
  });
443
503
  }
444
504
 
445
505
  function showOnlyFile(fileName) {
446
506
  if (REVIEW_LAZY) ensureFileReady(diffWrapperByPath(fileName));
447
507
  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);
508
+ wrapper.classList.toggle('df-inactive', diffWrapperPathKey(wrapper) !== fileName);
450
509
  });
451
510
  ensureDiffCursor();
452
511
  }
@@ -474,8 +533,10 @@ function hunkIndexAtCaret() {
474
533
  // New-side row indices, one per change block — a run of change rows (ins/del) separated by context.
475
534
  // A wide context window merges several edits into one @@ hunk; stepping by these stops at each edit.
476
535
  function changeBlockAnchors(wrapper) {
536
+ if (!wrapper) return [];
537
+ if (wrapper.__anchors) return wrapper.__anchors;
477
538
  var right = diffSideTables(wrapper).right;
478
- if (!right) return [];
539
+ if (!right) return []; // body not materialized yet — don't cache an empty result
479
540
  var rows = diffRowsOf(right);
480
541
  var anchors = [];
481
542
  var prev = false;
@@ -484,6 +545,7 @@ function changeBlockAnchors(wrapper) {
484
545
  if (chg && !prev) anchors.push(i);
485
546
  prev = chg;
486
547
  }
548
+ wrapper.__anchors = anchors; // change-block layout is static once materialized
487
549
  return anchors;
488
550
  }
489
551
 
@@ -501,7 +563,7 @@ function next(delta) {
501
563
  else { for (let b = anchors.length - 1; b >= 0; b--) { if (anchors[b] < cur) { target = anchors[b]; break; } } }
502
564
  if (target != null) {
503
565
  const row = diffRowAt(w, 'new', target);
504
- if (row) { navSuppress = true; try { focusDiffRow(row); } finally { navSuppress = false; } row.scrollIntoView({ block: 'center' }); return; }
566
+ if (row) { navSuppress = true; try { focusDiffRow(row); } finally { navSuppress = false; } scheduleDiffScroll(row); return; }
505
567
  }
506
568
  }
507
569
  }
@@ -535,7 +597,7 @@ function firstHunkForPath(path) {
535
597
  function openQuickOpen(mode) {
536
598
  if (!quickOpen || !quickInput || !quickModeLabel) return;
537
599
  quickMode = mode;
538
- quickModeLabel.textContent = mode === 'recent' ? 'Recent files' : mode === 'content' ? 'Find in Files' : 'Search files';
600
+ quickModeLabel.textContent = mode === 'recent' ? t('quickopen.recent') : mode === 'content' ? t('quickopen.findInFiles') : t('quickopen.searchFiles');
539
601
  quickOpen.classList.remove('hidden');
540
602
  quickInput.value = '';
541
603
  renderQuickOpenResults();
@@ -590,7 +652,7 @@ function renderQuickOpenResults() {
590
652
  .slice(0, 80);
591
653
  quickActive = Math.min(quickActive, Math.max(quickItems.length - 1, 0));
592
654
  if (quickItems.length === 0) {
593
- quickResults.innerHTML = '<div class="quick-open-empty">No files found.</div>';
655
+ quickResults.innerHTML = '<div class="quick-open-empty">' + escapeHtml(t('quickopen.noFiles')) + '</div>';
594
656
  return;
595
657
  }
596
658
  quickResults.innerHTML = quickItems.map((item, index) => [
@@ -923,6 +985,13 @@ document.addEventListener('keydown', (event) => {
923
985
  openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
924
986
  return;
925
987
  }
988
+ // Cmd/Ctrl+Shift+N opens/closes the prompt memo. Electron also routes this via the Review menu; in the
989
+ // browser/serve build (no menu) this keydown is the only path. Match the physical key so layout/IME never swallows it.
990
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
991
+ event.preventDefault();
992
+ openMemoView();
993
+ return;
994
+ }
926
995
  // "?" = question, ">" = change-request composer on the current line/selection (no modifier).
927
996
  if (!event.altKey && !event.metaKey && !event.ctrlKey && (event.key === '?' || event.key === '>')) {
928
997
  const ce = document.activeElement;
@@ -1063,6 +1132,9 @@ document.addEventListener('keydown', (event) => {
1063
1132
  }
1064
1133
 
1065
1134
  // Cmd/Ctrl+[ / ] walk the cursor-position history (back / forward), like an editor's Go Back/Forward.
1135
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && (event.key === '[' || event.key === ']' || event.key === '{' || event.key === '}')) {
1136
+ if (isSourceViewerVisible() && sourceTabs.length > 1) { event.preventDefault(); cycleSourceTab((event.key === '[' || event.key === '{') ? -1 : 1); return; }
1137
+ }
1066
1138
  if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && (event.key === '[' || event.key === ']')) {
1067
1139
  var navEl = document.activeElement;
1068
1140
  var navInField = navEl && (navEl.tagName === 'INPUT' || navEl.tagName === 'TEXTAREA' || navEl.tagName === 'SELECT');
@@ -1139,6 +1211,12 @@ document.querySelectorAll('.tab').forEach((button) => {
1139
1211
  });
1140
1212
 
1141
1213
  document.getElementById('back-to-diff')?.addEventListener('click', () => showDiffView(true));
1214
+ document.getElementById('source-tabs')?.addEventListener('click', function (event) {
1215
+ var closeBtn = event.target && event.target.closest && event.target.closest('.source-tab-close');
1216
+ if (closeBtn) { event.stopPropagation(); event.preventDefault(); closeSourceTab(closeBtn.getAttribute('data-close-path')); return; }
1217
+ var tab = event.target && event.target.closest && event.target.closest('.source-tab');
1218
+ if (tab) openSourceFile(tab.getAttribute('data-tab-path'));
1219
+ });
1142
1220
  document.getElementById('diff-viewed-toggle')?.addEventListener('click', function () {
1143
1221
  var btn = document.getElementById('diff-viewed-toggle');
1144
1222
  var path = btn ? (btn.dataset.file || '') : '';
@@ -1154,11 +1232,12 @@ document.addEventListener('keydown', function (event) {
1154
1232
  }, true);
1155
1233
  document.addEventListener('copy', handleSourceCopy);
1156
1234
 
1235
+ applyI18n(); // first paint already shows English (inline); this swaps to the saved locale before the rest of init renders dynamic text
1157
1236
  populateHttpEnvSelect();
1158
- if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0); // non-lazy indexes now; lazy-LOAD defers the (large) source blob + index to the first source-view open / go-to-def
1237
+ if (!REVIEW_LAZY_LOAD) scheduleSymbolIndex(); // non-lazy indexes when idle; lazy-LOAD defers the (large) source blob + index to the first source-view open / go-to-def
1159
1238
  const restored = restoreUiState();
1160
1239
  if (!restored) {
1161
- const initial = location.hash.match(/^#hunk-(\\d+)$/);
1240
+ const initial = location.hash.match(/^#hunk-(\d+)$/);
1162
1241
  if (initial) setActive(Number(initial[1]), false);
1163
1242
  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
1243
  else openDefaultSourceFile();
@@ -1167,6 +1246,19 @@ initSourceTreeFolds();
1167
1246
  if (watchEnabled) setInterval(checkForLiveUpdate, 1500);
1168
1247
  window.addEventListener('beforeunload', saveUiState);
1169
1248
 
1249
+ // First render has painted — drop the boot overlay (it bridged the blank gap right after loadFile). Two
1250
+ // rAFs so the spinner stays until the diff/tree are actually on screen, then a short fade-out.
1251
+ (function () {
1252
+ var ov = document.getElementById('boot-overlay');
1253
+ if (!ov) return;
1254
+ requestAnimationFrame(function () {
1255
+ requestAnimationFrame(function () {
1256
+ ov.classList.add('hide');
1257
+ setTimeout(function () { ov.remove(); }, 240);
1258
+ });
1259
+ });
1260
+ })();
1261
+
1170
1262
  (function setupSidebarResize() {
1171
1263
  const resizer = document.querySelector('.sidebar-resizer');
1172
1264
  if (!resizer) return;
@@ -1234,13 +1326,26 @@ function diffActiveWrapper() {
1234
1326
  return document.querySelector('#diff2html-container .d2h-file-wrapper:not(.df-inactive)')
1235
1327
  || document.querySelector('#diff2html-container .d2h-file-wrapper');
1236
1328
  }
1329
+ // path -> wrapper, O(1) after the first build. Rebuilt only on a miss/disconnect
1330
+ // (the wrapper set is stable; only bodies materialize). This is called several times
1331
+ // per F7 press, so the old O(files) querySelector scan made each keystroke cost scale
1332
+ // with the file count — the main source of cross-file nav stutter on big diffs.
1333
+ var wrapperPathMap = null;
1334
+ function diffWrapperPathKey(w) {
1335
+ return (w.dataset && w.dataset.path) || ((w.querySelector('.d2h-file-name') || {}).textContent || '').trim();
1336
+ }
1237
1337
  function diffWrapperByPath(path) {
1338
+ if (wrapperPathMap) {
1339
+ var hit = wrapperPathMap.get(path);
1340
+ if (hit && hit.isConnected) return hit;
1341
+ }
1342
+ wrapperPathMap = new Map();
1238
1343
  var ws = document.querySelectorAll('#diff2html-container .d2h-file-wrapper');
1239
1344
  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];
1345
+ var key = diffWrapperPathKey(ws[i]);
1346
+ if (key) wrapperPathMap.set(key, ws[i]);
1242
1347
  }
1243
- return null;
1348
+ return wrapperPathMap.get(path) || null;
1244
1349
  }
1245
1350
  function diffSideTables(wrapper) {
1246
1351
  var sides = wrapper ? wrapper.querySelectorAll('.d2h-file-side-diff') : [];
@@ -1338,6 +1443,7 @@ function renderDiffCaret() {
1338
1443
  } catch (e) { diffCaretSpan = null; }
1339
1444
  }
1340
1445
  function setDiffCursor(path, side, rowIndex, column, reveal) {
1446
+ markCaretBusy();
1341
1447
  var wrapper = diffWrapperByPath(path);
1342
1448
  if (!wrapper) return;
1343
1449
  var rows = diffRowsOf(diffSideTable(wrapper, side));
@@ -1513,13 +1619,13 @@ function handleDiffCaretKey(event) {
1513
1619
  // ===== Review comments: questions ("?") and change-requests (">") =====
1514
1620
  // (COMMENTS_KEY / reviewComments / commentSeq / composerState are declared near the top of the script)
1515
1621
  function saveComments() {
1516
- try { localStorage.setItem(COMMENTS_KEY, JSON.stringify(reviewComments)); } catch (e) {}
1622
+ persistSave(COMMENTS_KEY, reviewComments);
1517
1623
  }
1518
1624
  function commentsAt(path, line) {
1519
1625
  return reviewComments.filter(function (c) { return c.path === path && c.line === line; });
1520
1626
  }
1521
1627
  function commentKindLabel(kind) {
1522
- return kind === 'q' ? '❓ Question' : '✎ Change request';
1628
+ return kind === 'q' ? t('comment.kind.q') : t('comment.kind.c');
1523
1629
  }
1524
1630
  function relevantLines(path) {
1525
1631
  var set = {};
@@ -1607,17 +1713,17 @@ function threadHtml(path, line) {
1607
1713
  commentsAt(path, line).forEach(function (c) {
1608
1714
  html += '<div class="mc-card mc-' + c.kind + '">'
1609
1715
  + '<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>'
1716
+ + '<button type="button" class="mc-del" data-seq="' + c.seq + '" title="' + escapeHtml(t('composer.delete')) + '">×</button></div>'
1611
1717
  + '<div class="mc-card-body">' + escapeHtml(c.text) + '</div></div>';
1612
1718
  });
1613
1719
  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';
1720
+ var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
1615
1721
  html += '<div class="mc-card mc-' + composerState.kind + ' mc-composer">'
1616
1722
  + '<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>';
1723
+ + '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '"></textarea>'
1724
+ + '<div class="mc-actions"><button type="button" class="mc-btn mc-save">' + escapeHtml(t('composer.save')) + '</button>'
1725
+ + '<button type="button" class="mc-btn mc-ghost mc-cancel">' + escapeHtml(t('composer.cancel')) + '</button>'
1726
+ + '<span class="mc-hint">' + escapeHtml(t('composer.hint')) + '</span></div></div>';
1621
1727
  }
1622
1728
  return html;
1623
1729
  }
@@ -1684,8 +1790,8 @@ function renderCommentBadges() {
1684
1790
  var badge = document.createElement('span');
1685
1791
  badge.className = 'mc-file-badge';
1686
1792
  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>';
1793
+ if (k.q) html += '<span class="mc-fb mc-fb-q" title="' + k.q + ' ' + escapeHtml(t('badge.questions')) + '">' + k.q + '</span>';
1794
+ if (k.c) html += '<span class="mc-fb mc-fb-c" title="' + k.c + ' ' + escapeHtml(t('badge.changeRequests')) + '">' + k.c + '</span>';
1689
1795
  badge.innerHTML = html;
1690
1796
  return badge;
1691
1797
  }
@@ -1727,18 +1833,23 @@ function refreshComments() {
1727
1833
  renderCommentBadges();
1728
1834
  applyCommentSelectionHighlight();
1729
1835
  if (composerState) {
1730
- var focusComposerInput = function () {
1836
+ var composerFocusTries = 0;
1837
+ var tryFocusComposer = function () {
1731
1838
  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
- }
1839
+ if (!ta) return true; // composer gone — stop retrying
1840
+ if (document.activeElement === ta) return true; // already focused done
1841
+ try { ta.focus({ preventScroll: true }); } catch (e) { try { ta.focus(); } catch (e2) {} }
1842
+ try { ta.selectionStart = ta.selectionEnd = ta.value.length; } catch (e3) {}
1843
+ return document.activeElement === ta;
1736
1844
  };
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);
1845
+ // A one-shot focus works in a plain browser, but Electron asynchronously restores focus to <body>
1846
+ // after the keydown, so the textarea loses that race. Retry on a short interval until it wins (or the
1847
+ // composer closes), capped at ~300ms so it never fights real user focus once they start typing.
1848
+ if (!tryFocusComposer()) {
1849
+ var composerFocusIv = setInterval(function () {
1850
+ if (tryFocusComposer() || ++composerFocusTries > 12) clearInterval(composerFocusIv);
1851
+ }, 25);
1852
+ }
1742
1853
  }
1743
1854
  }
1744
1855
 
@@ -1765,18 +1876,34 @@ function saveComposer(ta) {
1765
1876
  refreshComments();
1766
1877
  }
1767
1878
 
1879
+ // Default merge-prompt headings, localized: a Korean user gets Korean defaults. Editable in
1880
+ // Settings → Merge prompts (stored per browser in localStorage); buildMergedText + the textarea
1881
+ // placeholders fall back to these when the stored value is empty.
1882
+ function defaultMergePrompt(kind) {
1883
+ return t(kind === 'q' ? 'mergePrompt.default.q' : 'mergePrompt.default.c');
1884
+ }
1885
+ var mergePromptsKey = 'monacori-merge-prompts';
1886
+ function loadMergePrompts() {
1887
+ 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 {}; }
1888
+ }
1889
+ function mergePromptFor(kind) {
1890
+ var v = loadMergePrompts()[kind];
1891
+ return (typeof v === 'string' && v.trim()) ? v : defaultMergePrompt(kind);
1892
+ }
1893
+ function saveMergePrompt(kind, text) {
1894
+ var saved = loadMergePrompts();
1895
+ if (text && text.trim()) saved[kind] = text; else delete saved[kind];
1896
+ persistSave(mergePromptsKey, saved);
1897
+ }
1898
+
1768
1899
  function buildMergedText(kind) {
1769
1900
  var items = reviewComments.filter(function (c) { return c.kind === kind; });
1770
1901
  var nl = String.fromCharCode(10);
1771
1902
  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.');
1903
+ // Per-kind agent contract heading (editable in Settings Merge prompts; default otherwise).
1904
+ lines.push(mergePromptFor(kind));
1778
1905
  lines.push('');
1779
- lines.push((kind === 'q' ? '# Questions' : '# Change requests') + ' (' + items.length + ')');
1906
+ lines.push((kind === 'q' ? t('merged.qHeading') : t('merged.cHeading')) + ' (' + items.length + ')');
1780
1907
  lines.push('');
1781
1908
  items.forEach(function (c) {
1782
1909
  lines.push('### ' + c.path + ':' + c.line);
@@ -1793,35 +1920,39 @@ function openMergedView(kind) {
1793
1920
  var modal = document.createElement('div');
1794
1921
  modal.id = 'mc-modal';
1795
1922
  modal.className = 'mc-modal';
1923
+ modal.dataset.kind = kind; // remembered so a live locale switch can re-render this same view
1796
1924
  var panel = document.createElement('div');
1797
1925
  panel.className = 'mc-modal-panel';
1798
1926
  var head = document.createElement('div');
1799
1927
  head.className = 'mc-modal-head';
1800
1928
  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';
1929
+ title.textContent = kind === 'q' ? t('merged.qTitle') : t('merged.cTitle');
1806
1930
  var closeBtn = document.createElement('button');
1807
1931
  closeBtn.type = 'button';
1808
1932
  closeBtn.className = 'mc-btn mc-ghost';
1809
- closeBtn.textContent = 'Close';
1933
+ closeBtn.textContent = t('merged.close');
1810
1934
  var area = document.createElement('textarea');
1811
1935
  area.className = 'mc-modal-text';
1812
1936
  area.readOnly = true;
1813
1937
  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
1938
  closeBtn.addEventListener('click', function () { modal.remove(); });
1939
+ // Terminal send (Electron, terminal open): close the modal and hand off to pane-pick mode ON the
1940
+ // terminal — the chosen pane is highlighted, the rest dimmed, arrows change the choice, Enter sends.
1941
+ // One button here; the actual pick happens visually over the live claude/codex sessions.
1942
+ var sendBtn = null;
1943
+ if (window.__monacoriTerminal && typeof window.__monacoriTerminal.isOpen === 'function' && window.__monacoriTerminal.isOpen()) {
1944
+ sendBtn = document.createElement('button');
1945
+ sendBtn.type = 'button';
1946
+ sendBtn.className = 'mc-btn mc-send-term';
1947
+ sendBtn.textContent = t('merged.sendToTerminal');
1948
+ sendBtn.addEventListener('click', function () {
1949
+ var text = buildMergedText(kind);
1950
+ modal.remove();
1951
+ window.__monacoriTerminal.enterSendMode(text);
1952
+ });
1953
+ }
1823
1954
  head.appendChild(title);
1824
- head.appendChild(copyBtn);
1955
+ if (sendBtn) head.appendChild(sendBtn);
1825
1956
  head.appendChild(closeBtn);
1826
1957
  panel.appendChild(head);
1827
1958
  panel.appendChild(area);
@@ -1829,7 +1960,109 @@ function openMergedView(kind) {
1829
1960
  modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
1830
1961
  modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
1831
1962
  document.body.appendChild(modal);
1832
- requestAnimationFrame(function () { area.focus(); area.select(); });
1963
+ // Focus the send button (Enter starts pane-pick) when present, else the read-only text. Electron
1964
+ // async-restores focus to <body>, so retry briefly (same as the composer).
1965
+ var modalFocusTarget = sendBtn || area;
1966
+ var modalFocusTries = 0;
1967
+ var tryFocusModal = function () {
1968
+ if (!document.getElementById('mc-modal')) return true;
1969
+ if (document.activeElement === modalFocusTarget) return true;
1970
+ try { modalFocusTarget.focus(); if (modalFocusTarget === area) modalFocusTarget.select(); } catch (e) {}
1971
+ return document.activeElement === modalFocusTarget;
1972
+ };
1973
+ if (!tryFocusModal()) {
1974
+ var modalFocusIv = setInterval(function () { if (tryFocusModal() || ++modalFocusTries > 12) clearInterval(modalFocusIv); }, 25);
1975
+ }
1976
+ }
1977
+
1978
+ // Prompt memo (Cmd/Ctrl+Shift+N): one freeform Markdown scratchpad with a live split preview, persisted
1979
+ // across reopens via the same store as comments/locale. "Send to terminal" hands the current draft to the
1980
+ // same pane-pick mode the merged views use, so a half-formed prompt can target any live claude/codex session.
1981
+ var memoKey = 'monacori-memo';
1982
+ function loadMemo() {
1983
+ var v = persistRead(memoKey);
1984
+ if (typeof v === 'string') return v;
1985
+ try { var s = localStorage.getItem(memoKey); return typeof s === 'string' ? s : ''; } catch (e) { return ''; }
1986
+ }
1987
+ function saveMemo(text) { persistSave(memoKey, text || ''); }
1988
+ function renderMemoMd(text) {
1989
+ if (!text || !text.trim()) return '<div class="mc-memo-empty" data-i18n="memo.previewEmpty">' + escapeHtml(t('memo.previewEmpty')) + '</div>';
1990
+ return renderMarkdownBlocks(text).map(function (b) { return b.html; }).join('');
1991
+ }
1992
+ function openMemoView() {
1993
+ var existing = document.getElementById('mc-memo');
1994
+ if (existing) { existing.remove(); return; } // the shortcut toggles: a second press closes the memo
1995
+ var modal = document.createElement('div');
1996
+ modal.id = 'mc-memo';
1997
+ modal.className = 'mc-modal';
1998
+ var panel = document.createElement('div');
1999
+ panel.className = 'mc-modal-panel mc-memo-panel';
2000
+ var head = document.createElement('div');
2001
+ head.className = 'mc-modal-head';
2002
+ var title = document.createElement('span');
2003
+ title.setAttribute('data-i18n', 'memo.title');
2004
+ title.textContent = t('memo.title');
2005
+ var closeBtn = document.createElement('button');
2006
+ closeBtn.type = 'button';
2007
+ closeBtn.className = 'mc-btn mc-ghost';
2008
+ closeBtn.setAttribute('data-i18n', 'merged.close');
2009
+ closeBtn.textContent = t('merged.close');
2010
+ closeBtn.addEventListener('click', function () { modal.remove(); });
2011
+
2012
+ var body = document.createElement('div');
2013
+ body.className = 'mc-memo-body';
2014
+ var area = document.createElement('textarea');
2015
+ area.className = 'mc-modal-text mc-memo-edit';
2016
+ area.spellcheck = false;
2017
+ area.setAttribute('data-i18n-ph', 'memo.placeholder');
2018
+ area.placeholder = t('memo.placeholder');
2019
+ area.value = loadMemo();
2020
+ var preview = document.createElement('div');
2021
+ preview.className = 'md-cell mc-memo-preview';
2022
+ preview.innerHTML = renderMemoMd(area.value);
2023
+ area.addEventListener('input', function () {
2024
+ saveMemo(area.value);
2025
+ preview.innerHTML = renderMemoMd(area.value);
2026
+ });
2027
+
2028
+ // Terminal send: hand the current draft to pane-pick mode (arrows choose the session, Enter sends). Shown
2029
+ // only once a terminal pane exists; enterSendMode reopens the panel if it was closed.
2030
+ var sendBtn = null;
2031
+ if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
2032
+ sendBtn = document.createElement('button');
2033
+ sendBtn.type = 'button';
2034
+ sendBtn.className = 'mc-btn mc-send-term';
2035
+ sendBtn.setAttribute('data-i18n', 'merged.sendToTerminal');
2036
+ sendBtn.textContent = t('merged.sendToTerminal');
2037
+ sendBtn.addEventListener('click', function () {
2038
+ var text = area.value;
2039
+ modal.remove();
2040
+ window.__monacoriTerminal.enterSendMode(text);
2041
+ });
2042
+ }
2043
+
2044
+ head.appendChild(title);
2045
+ if (sendBtn) head.appendChild(sendBtn);
2046
+ head.appendChild(closeBtn);
2047
+ body.appendChild(area);
2048
+ body.appendChild(preview);
2049
+ panel.appendChild(head);
2050
+ panel.appendChild(body);
2051
+ modal.appendChild(panel);
2052
+ modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
2053
+ modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
2054
+ document.body.appendChild(modal);
2055
+ // Focus the editor; Electron async-restores focus to <body>, so retry briefly (same as the composer/merged view).
2056
+ var memoFocusTries = 0;
2057
+ var tryFocusMemo = function () {
2058
+ if (!document.getElementById('mc-memo')) return true;
2059
+ if (document.activeElement === area) return true;
2060
+ try { area.focus(); } catch (e) {}
2061
+ return document.activeElement === area;
2062
+ };
2063
+ if (!tryFocusMemo()) {
2064
+ var memoFocusIv = setInterval(function () { if (tryFocusMemo() || ++memoFocusTries > 12) clearInterval(memoFocusIv); }, 25);
2065
+ }
1833
2066
  }
1834
2067
 
1835
2068
  document.addEventListener('click', function (event) {
@@ -1849,11 +2082,322 @@ document.addEventListener('keydown', function (event) {
1849
2082
 
1850
2083
  refreshComments();
1851
2084
 
2085
+
2086
+ // Integrated terminal (Electron only): xterm panes wired to node-pty sessions in the main process.
2087
+ // Toggle with Ctrl+` / Opt+F12 / the footer ⌗ button; Cmd/Ctrl+D splits the active pane (side by side,
2088
+ // no tabs); drag the top edge to resize. window.__monacoriTerminal pipes the merged prompt into the
2089
+ // active pane. Cmd combos are released back to the app so shortcuts like Cmd+1 don't get stuck typing.
2090
+ (function setupTerminal() {
2091
+ if (!window.monacoriPty) return; // xterm (window.Terminal) is loaded lazily on first open
2092
+ var panel = document.getElementById('terminal-panel');
2093
+ var host = document.getElementById('terminal-host');
2094
+ var toggleBtn = document.getElementById('terminal-toggle');
2095
+ var closeBtn = document.getElementById('terminal-close');
2096
+ var resizer = panel ? panel.querySelector('.terminal-resizer') : null;
2097
+ if (!panel || !host) return;
2098
+ if (toggleBtn) toggleBtn.classList.remove('hidden'); // reveal the footer toggle in Electron
2099
+
2100
+ // xterm ships as an inert island (id=xterm-code) so ~490KB isn't parsed at startup. Inject it on the
2101
+ // first open; returns false if unavailable (e.g. the island is absent), so callers can bail gracefully.
2102
+ function ensureXterm() {
2103
+ if (typeof window.Terminal === 'function') return true;
2104
+ var code = document.getElementById('xterm-code');
2105
+ if (!code) return false;
2106
+ try {
2107
+ var s = document.createElement('script');
2108
+ s.textContent = code.textContent;
2109
+ document.head.appendChild(s);
2110
+ code.remove(); // free the inert text once compiled
2111
+ } catch (e) { return false; }
2112
+ return typeof window.Terminal === 'function';
2113
+ }
2114
+
2115
+ var panes = []; // { id, term, fit, el }
2116
+ var active = null;
2117
+ var MAX_PANES = 4;
2118
+ var heightKey = 'monacori-terminal-height';
2119
+ var openKey = 'monacori-terminal-open:' + location.pathname;
2120
+
2121
+ function applyHeight(px) {
2122
+ var h = Math.max(120, Math.min(px, window.innerHeight - 120));
2123
+ document.documentElement.style.setProperty('--terminal-height', h + 'px');
2124
+ }
2125
+ var savedH = parseInt(localStorage.getItem(heightKey) || '', 10);
2126
+ if (savedH) applyHeight(savedH);
2127
+
2128
+ function fitPane(p) {
2129
+ if (!p) return;
2130
+ try { p.fit.fit(); if (p.id != null) window.monacoriPty.resize({ id: p.id, cols: p.term.cols, rows: p.term.rows }); } catch (e) {}
2131
+ }
2132
+ function fitAll() { panes.forEach(fitPane); }
2133
+
2134
+ function setActive(p) {
2135
+ active = p;
2136
+ panes.forEach(function (q) {
2137
+ q.el.classList.toggle('is-active', q === p);
2138
+ // 2+ panes: dim every pane but the active one (no border, just a clean focus cue). A lone pane stays full.
2139
+ q.el.classList.toggle('is-inactive', panes.length > 1 && q !== p);
2140
+ });
2141
+ if (p) requestAnimationFrame(function () { try { p.term.focus(); } catch (e) {} });
2142
+ }
2143
+
2144
+ function makePane() {
2145
+ if (!ensureXterm()) return null; // xterm unavailable — leave the panel empty rather than throw
2146
+ var el = document.createElement('div');
2147
+ el.className = 'terminal-pane';
2148
+ var labelEl = document.createElement('div');
2149
+ labelEl.className = 'terminal-pane-label';
2150
+ var paneHost = document.createElement('div');
2151
+ paneHost.className = 'terminal-pane-host';
2152
+ el.appendChild(labelEl);
2153
+ el.appendChild(paneHost);
2154
+ host.appendChild(el);
2155
+ var term = new window.Terminal({
2156
+ fontSize: 12,
2157
+ fontFamily: 'Monaco, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
2158
+ theme: { background: '#161616', foreground: '#a9b7c6', cursor: '#a9b7c6', selectionBackground: '#214283' },
2159
+ cursorBlink: true,
2160
+ });
2161
+ var fit = new window.FitAddon.FitAddon();
2162
+ term.loadAddon(fit);
2163
+ term.open(paneHost);
2164
+ var pane = { id: null, term: term, fit: fit, el: el, labelEl: labelEl, name: 'Terminal ' + (panes.length + 1) };
2165
+ labelEl.textContent = pane.name;
2166
+ // Cmd combos are app shortcuts (Cmd+1/0 tab switch, Cmd+B go-to-def, …). Release the terminal and let
2167
+ // them bubble to the document handler instead of typing into the shell (fixes "Cmd+1 stuck in term").
2168
+ // Exception: keep focus for clipboard/selection combos (Cmd+C/V/X/A) so the terminal's own copy &
2169
+ // paste keep working — blurring on Cmd+V drops the textarea focus the paste event needs.
2170
+ term.attachCustomKeyEventHandler(function (e) {
2171
+ if (e.type === 'keydown' && e.metaKey) {
2172
+ var k = (e.key || '').toLowerCase();
2173
+ // The bare modifier press (Cmd goes down BEFORE the letter on macOS) must not blur — blurring
2174
+ // here drops the textarea focus the upcoming Cmd+V paste / Cmd+C copy needs, which broke them.
2175
+ if (k === 'meta' || k === 'control' || k === 'alt' || k === 'shift') return true;
2176
+ // Match the PHYSICAL key (e.code), not e.key: under a non-Latin layout/IME (e.g. Korean 한글)
2177
+ // Cmd+V reports e.key as 'ㅍ', so a key-based check misses it — blurring the terminal and
2178
+ // breaking paste/copy/cut/select-all whenever the Korean input source is active.
2179
+ if (e.code === 'KeyC' || e.code === 'KeyV' || e.code === 'KeyX' || e.code === 'KeyA') return true;
2180
+ try { term.blur(); } catch (x) {}
2181
+ return false;
2182
+ }
2183
+ return true;
2184
+ });
2185
+ term.onData(function (d) { if (pane.id != null) window.monacoriPty.write({ id: pane.id, data: d }); });
2186
+ el.addEventListener('mousedown', function (e) { if (e.target !== labelEl) setActive(pane); });
2187
+ labelEl.addEventListener('dblclick', function () { renamePane(pane); });
2188
+ panes.push(pane);
2189
+ try { fit.fit(); } catch (e) {}
2190
+ window.monacoriPty.spawn({ cols: term.cols || 80, rows: term.rows || 24 }).then(function (r) { pane.id = r && r.id; });
2191
+ setActive(pane);
2192
+ return pane;
2193
+ }
2194
+ // Rename a pane inline: the label becomes editable, Enter commits, Esc/blur reverts to the last name.
2195
+ function renamePane(pane) {
2196
+ if (!pane) { pane = active; }
2197
+ if (!pane) return;
2198
+ var el = pane.labelEl;
2199
+ if (el.getAttribute('contenteditable') === 'true') return;
2200
+ setActive(pane);
2201
+ el.contentEditable = 'true';
2202
+ // Electron asynchronously restores focus to <body> after the keydown, so a one-shot focus loses the
2203
+ // race and the label turns editable but never gets the caret — retry until it sticks, then select all
2204
+ // (same pattern as the composer/memo). This is why rename "did nothing" before.
2205
+ var renameTries = 0;
2206
+ var focusLabel = function () {
2207
+ if (el.getAttribute('contenteditable') !== 'true') return true; // finished/cancelled meanwhile
2208
+ try { el.focus(); } catch (e) {}
2209
+ if (document.activeElement !== el) return false;
2210
+ try { var range = document.createRange(); range.selectNodeContents(el); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } catch (e) {}
2211
+ return true;
2212
+ };
2213
+ if (!focusLabel()) { var renameIv = setInterval(function () { if (focusLabel() || ++renameTries > 12) clearInterval(renameIv); }, 25); }
2214
+ function finish(commit) {
2215
+ el.removeEventListener('keydown', onKey);
2216
+ el.removeEventListener('blur', onBlur);
2217
+ el.contentEditable = 'false';
2218
+ if (commit) pane.name = (el.textContent || '').trim() || pane.name;
2219
+ el.textContent = pane.name;
2220
+ try { if (pane.term) pane.term.focus(); } catch (e) {}
2221
+ }
2222
+ function onKey(e) {
2223
+ e.stopPropagation();
2224
+ if (e.key === 'Enter') { e.preventDefault(); finish(true); }
2225
+ else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
2226
+ }
2227
+ function onBlur() { finish(true); }
2228
+ el.addEventListener('keydown', onKey);
2229
+ el.addEventListener('blur', onBlur);
2230
+ }
2231
+
2232
+ function removePane(id) {
2233
+ var i = -1;
2234
+ for (var k = 0; k < panes.length; k++) { if (panes[k].id === id) { i = k; break; } }
2235
+ if (i < 0) return;
2236
+ var p = panes[i];
2237
+ try { p.term.dispose(); } catch (e) {}
2238
+ if (p.el.parentNode) p.el.parentNode.removeChild(p.el);
2239
+ panes.splice(i, 1);
2240
+ if (active === p) setActive(panes[panes.length - 1] || null);
2241
+ if (panes.length === 0) setOpen(false);
2242
+ else fitAll();
2243
+ }
2244
+
2245
+ function split() {
2246
+ if (panes.length >= MAX_PANES) return;
2247
+ makePane();
2248
+ fitAll();
2249
+ }
2250
+ // Move active focus between split panes (menu accelerators Cmd/Ctrl+Alt+[ and ]).
2251
+ function focusPaneByDelta(delta) {
2252
+ if (panes.length < 2) return;
2253
+ var i = panes.indexOf(active);
2254
+ if (i < 0) i = 0;
2255
+ setActive(panes[(i + delta + panes.length) % panes.length]);
2256
+ }
2257
+
2258
+ // Route per-pane pty output / exit by id (registered once for the window).
2259
+ window.monacoriPty.onData(function (msg) {
2260
+ for (var k = 0; k < panes.length; k++) { if (panes[k].id === msg.id) { panes[k].term.write(msg.data); return; } }
2261
+ });
2262
+ window.monacoriPty.onExit(function (msg) { removePane(msg.id); });
2263
+
2264
+ function isOpen() { return !panel.classList.contains('hidden'); }
2265
+ function setOpen(open) {
2266
+ panel.classList.toggle('hidden', !open);
2267
+ document.body.classList.toggle('terminal-open', open);
2268
+ if (toggleBtn) toggleBtn.classList.toggle('is-active', open);
2269
+ try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch (e) {}
2270
+ if (open) {
2271
+ if (panes.length === 0) makePane();
2272
+ requestAnimationFrame(function () { fitAll(); if (active) try { active.term.focus(); } catch (e) {} });
2273
+ }
2274
+ }
2275
+ function toggle() { setOpen(!isOpen()); }
2276
+
2277
+ if (toggleBtn) toggleBtn.addEventListener('click', toggle);
2278
+ if (closeBtn) closeBtn.addEventListener('click', function () { setOpen(false); });
2279
+ // Toggle (Ctrl+`/Alt+F12) and split (Cmd+D) arrive from the Terminal menu accelerators (app-main),
2280
+ // because Chromium swallows Cmd+D before a renderer keydown would ever see it.
2281
+ if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalToggle === 'function') window.monacoriMenu.onTerminalToggle(toggle);
2282
+ if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalSplit === 'function') window.monacoriMenu.onTerminalSplit(split);
2283
+ if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneFocus === 'function') window.monacoriMenu.onTerminalPaneFocus(focusPaneByDelta);
2284
+ if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneRename === 'function') window.monacoriMenu.onTerminalPaneRename(function () { renamePane(active); });
2285
+
2286
+ var ro = (typeof ResizeObserver === 'function') ? new ResizeObserver(function () { if (isOpen()) fitAll(); }) : null;
2287
+ if (ro) ro.observe(host);
2288
+ window.addEventListener('resize', function () { if (isOpen()) fitAll(); });
2289
+
2290
+ if (resizer) {
2291
+ resizer.addEventListener('mousedown', function (e) {
2292
+ e.preventDefault();
2293
+ resizer.classList.add('resizing');
2294
+ function move(ev) { applyHeight(window.innerHeight - ev.clientY); }
2295
+ function up() {
2296
+ resizer.classList.remove('resizing');
2297
+ document.removeEventListener('mousemove', move);
2298
+ document.removeEventListener('mouseup', up);
2299
+ var cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--terminal-height'), 10);
2300
+ if (cur) { try { localStorage.setItem(heightKey, String(cur)); } catch (e) {} }
2301
+ fitAll();
2302
+ }
2303
+ document.addEventListener('mousemove', move);
2304
+ document.addEventListener('mouseup', up);
2305
+ });
2306
+ }
2307
+
2308
+ // Kill this window's ptys on unload so a reload/close doesn't leak them in the main process.
2309
+ window.addEventListener('beforeunload', function () {
2310
+ panes.forEach(function (p) { if (p.id != null) { try { window.monacoriPty.kill({ id: p.id }); } catch (e) {} } });
2311
+ });
2312
+
2313
+ // Hook for the merged-prompt modal: pipe the combined text into a chosen pane (no trailing Enter —
2314
+ // the user reviews in the live session, then presses Enter, so multiline prompts stay intact).
2315
+ function writeToPane(p, text) {
2316
+ if (!p) return;
2317
+ setOpen(true);
2318
+ if (p.id != null) window.monacoriPty.write({ id: p.id, data: text });
2319
+ setActive(p);
2320
+ requestAnimationFrame(function () { try { p.term.focus(); } catch (e) {} });
2321
+ }
2322
+ // Pane-pick mode: triggered from the merged modal's "Send to terminal". The chosen pane is highlighted,
2323
+ // the rest are dimmed; arrows change the pick, Enter sends, Esc cancels. Single pane → send at once.
2324
+ var sendModeText = null, sendModeIdx = 0;
2325
+ function paintSendMode() {
2326
+ panes.forEach(function (p, i) {
2327
+ p.el.classList.toggle('is-send-target', i === sendModeIdx);
2328
+ p.el.classList.toggle('is-dimmed', i !== sendModeIdx);
2329
+ });
2330
+ }
2331
+ function exitSendMode() {
2332
+ if (sendModeText == null) return;
2333
+ sendModeText = null;
2334
+ panel.classList.remove('send-mode');
2335
+ document.body.classList.remove('terminal-send-mode'); // un-dim the rest of the app
2336
+ panes.forEach(function (p) { p.el.classList.remove('is-send-target', 'is-dimmed'); });
2337
+ }
2338
+ function enterSendMode(text) {
2339
+ if (panes.length === 0) return;
2340
+ setOpen(true);
2341
+ sendModeText = text;
2342
+ sendModeIdx = Math.max(0, panes.indexOf(active));
2343
+ panel.classList.add('send-mode');
2344
+ document.body.classList.add('terminal-send-mode'); // dim sidebar + file/diff view; only the terminal pops
2345
+ paintSendMode();
2346
+ }
2347
+ // Capture phase so the pick keys win over the focused xterm; while picking, every key is swallowed.
2348
+ document.addEventListener('keydown', function (e) {
2349
+ if (sendModeText == null) return;
2350
+ e.preventDefault(); e.stopPropagation();
2351
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
2352
+ var d = (e.key === 'ArrowRight' || e.key === 'ArrowDown') ? 1 : -1;
2353
+ sendModeIdx = (sendModeIdx + d + panes.length) % panes.length;
2354
+ paintSendMode();
2355
+ } else if (e.key === 'Enter') {
2356
+ var p = panes[sendModeIdx], text = sendModeText;
2357
+ exitSendMode();
2358
+ writeToPane(p, text);
2359
+ } else if (e.key === 'Escape') {
2360
+ exitSendMode();
2361
+ }
2362
+ }, true);
2363
+ window.__monacoriTerminal = {
2364
+ isOpen: isOpen,
2365
+ open: function () { setOpen(true); },
2366
+ paneCount: function () { return panes.length; },
2367
+ enterSendMode: enterSendMode,
2368
+ send: function (text) { writeToPane(active || panes[0], text); },
2369
+ sendToPane: function (i, text) { writeToPane(panes[i] || active || panes[0], text); },
2370
+ close: function () { setOpen(false); },
2371
+ };
2372
+
2373
+ // Restore the open state across reloads.
2374
+ try { if (sessionStorage.getItem(openKey) === '1') setOpen(true); } catch (e) {}
2375
+ })();
2376
+
1852
2377
  // In Electron, the Review menu's Cmd/Ctrl+Shift+/ and +. accelerators arrive here via IPC
1853
2378
  // (macOS reserves Cmd+? for its Help search, so the menu claims it and routes to these views).
1854
2379
  if (window.monacoriMenu && typeof window.monacoriMenu.onMergedView === 'function') {
2380
+ // Always open the merged-view modal; sending to a terminal pane is a button inside it (per-pane when
2381
+ // split), so the user can pick which claude/codex session receives the prompt.
1855
2382
  window.monacoriMenu.onMergedView(function (kind) { openMergedView(kind); });
1856
2383
  }
2384
+ if (window.monacoriMenu && typeof window.monacoriMenu.onOpenMemo === 'function') {
2385
+ // Cmd/Ctrl+Shift+N from the Review menu -> open/close the prompt memo.
2386
+ window.monacoriMenu.onOpenMemo(function () { openMemoView(); });
2387
+ }
2388
+ if (window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function') {
2389
+ // Electron watch: main rebuilds on working-tree changes and pushes the new HTML so we refresh the diff
2390
+ // in place — NO window reload — keeping the integrated terminal's pty sessions (claude/codex) alive.
2391
+ window.monacoriMenu.onDiffUpdate(function (html) { try { applyDiffUpdate(html); } catch (e) {} });
2392
+ }
2393
+ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function') {
2394
+ // Cmd/Ctrl+W: close the active Files-mode tab (no-op outside the source viewer).
2395
+ window.monacoriMenu.onCloseTab(function () {
2396
+ // Cmd/Ctrl+W closes the terminal panel first when it's open, otherwise the active Files-mode tab.
2397
+ if (window.__monacoriTerminal && window.__monacoriTerminal.isOpen()) { window.__monacoriTerminal.close(); return; }
2398
+ if (isSourceViewerVisible()) closeActiveSourceTab();
2399
+ });
2400
+ }
1857
2401
 
1858
2402
  (function checkForUpdate() {
1859
2403
  var current = window.__MONACORI_VERSION__ || '';
@@ -1873,9 +2417,19 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onMergedView === 'function
1873
2417
  if (isNewer(latest, current)) {
1874
2418
  var flag = document.getElementById('app-update-flag');
1875
2419
  if (flag) flag.classList.remove('hidden');
1876
- if (status) { status.textContent = 'Update available: v' + latest; status.classList.add('has-update'); }
2420
+ // One-click auto-update needs the Electron main process (it spawns npm). When available, reveal the
2421
+ // button so a click installs + restarts; otherwise (browser/static export) name the command instead.
2422
+ var ub = document.getElementById('app-info-update');
2423
+ if (ub && window.monacoriUpdate && typeof window.monacoriUpdate.run === 'function') {
2424
+ ub.textContent = t('settings.updateRestart') + ' (v' + latest + ')';
2425
+ ub.classList.remove('hidden');
2426
+ if (status) { status.textContent = t('settings.updateAvailable') + ': v' + latest; status.classList.add('has-update'); }
2427
+ } else if (status) {
2428
+ status.textContent = t('settings.updateAvailable') + ': v' + latest + ' — npm i -g @happy-nut/monacori';
2429
+ status.classList.add('has-update');
2430
+ }
1877
2431
  } else if (status) {
1878
- status.textContent = 'Up to date (v' + current + ')';
2432
+ status.textContent = t('settings.upToDate') + ' (v' + current + ')';
1879
2433
  }
1880
2434
  };
1881
2435
  // Cache the npm result for the session so watch-mode reloads reuse it instead of refetching.
@@ -1893,28 +2447,81 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onMergedView === 'function
1893
2447
  .catch(function () {});
1894
2448
  })();
1895
2449
 
1896
- (function setupAppInfo() {
1897
- var btn = document.getElementById('app-info-btn');
1898
- var panel = document.getElementById('app-info');
2450
+ // Unified settings modal: the sidebar-footer gear opens it (General category by default), with
2451
+ // About/update/shortcuts under General and the merge-prompt editor under Merge prompts.
2452
+ (function setupSettings() {
2453
+ var modal = document.getElementById('settings-modal');
2454
+ if (!modal) return;
2455
+ var gearBtn = document.getElementById('app-info-btn');
1899
2456
  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
- });
2457
+ var updateBtn = document.getElementById('app-info-update');
2458
+ var qta = document.getElementById('settings-prompt-q');
2459
+ var cta = document.getElementById('settings-prompt-c');
2460
+ var resetBtn = document.getElementById('settings-reset');
2461
+ var savedMsg = document.getElementById('settings-saved');
2462
+ var cats = Array.prototype.slice.call(modal.querySelectorAll('.settings-cat'));
2463
+ var secs = Array.prototype.slice.call(modal.querySelectorAll('.settings-section'));
2464
+ function showCat(cat) {
2465
+ cats.forEach(function (c) { c.classList.toggle('active', c.dataset.cat === cat); });
2466
+ secs.forEach(function (s) { s.classList.toggle('hidden', s.dataset.cat !== cat); });
2467
+ }
2468
+ function fill() {
2469
+ var s = loadMergePrompts();
2470
+ if (qta) { qta.value = typeof s.q === 'string' ? s.q : ''; qta.placeholder = defaultMergePrompt('q'); }
2471
+ if (cta) { cta.value = typeof s.c === 'string' ? s.c : ''; cta.placeholder = defaultMergePrompt('c'); }
2472
+ }
2473
+ function open(cat) { fill(); if (cat) showCat(cat); modal.classList.remove('hidden'); }
2474
+ function close() { modal.classList.add('hidden'); }
2475
+ var flashTimer = null;
2476
+ function flash() { if (!savedMsg) return; savedMsg.textContent = 'Saved'; if (flashTimer) clearTimeout(flashTimer); flashTimer = setTimeout(function () { savedMsg.textContent = ''; }, 1200); }
2477
+ if (gearBtn) gearBtn.addEventListener('click', function (e) { e.stopPropagation(); if (modal.classList.contains('hidden')) open('general'); else close(); });
2478
+ if (flag) flag.addEventListener('click', function (e) { e.stopPropagation(); open('general'); });
2479
+ cats.forEach(function (c) { c.addEventListener('click', function () { showCat(c.dataset.cat); }); });
2480
+ modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
2481
+ // Capture so closing settings wins over other Escape handlers (lightbox / composer).
1915
2482
  document.addEventListener('keydown', function (e) {
1916
- if (e.key === 'Escape' && !panel.classList.contains('hidden')) setOpen(false);
1917
- });
2483
+ if (e.key === 'Escape' && !modal.classList.contains('hidden')) { e.stopPropagation(); e.preventDefault(); close(); return; }
2484
+ // Cmd/Ctrl+, (the standard "Preferences" accelerator) toggles the settings panel from anywhere.
2485
+ if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === ',' || e.code === 'Comma')) {
2486
+ e.preventDefault(); e.stopPropagation();
2487
+ if (modal.classList.contains('hidden')) open('general'); else close();
2488
+ }
2489
+ }, true);
2490
+ // One-click self-update (Electron only): install latest globally via the main process, then relaunch.
2491
+ if (updateBtn && window.monacoriUpdate && typeof window.monacoriUpdate.run === 'function') {
2492
+ updateBtn.addEventListener('click', function () {
2493
+ if (updateBtn.disabled) return;
2494
+ updateBtn.disabled = true;
2495
+ var status = document.getElementById('app-info-status');
2496
+ if (status) { status.textContent = t('settings.updating'); status.classList.add('has-update'); }
2497
+ window.monacoriUpdate.run().then(function (r) {
2498
+ if (r && r.ok) { if (status) status.textContent = t('settings.updated'); }
2499
+ else { updateBtn.disabled = false; if (status) status.textContent = t('settings.updateFailed'); }
2500
+ }).catch(function () { updateBtn.disabled = false; if (status) status.textContent = t('settings.updateFailed'); });
2501
+ });
2502
+ }
2503
+ if (qta) qta.addEventListener('input', function () { saveMergePrompt('q', qta.value); flash(); });
2504
+ if (cta) cta.addEventListener('input', function () { saveMergePrompt('c', cta.value); flash(); });
2505
+ if (resetBtn) resetBtn.addEventListener('click', function () { saveMergePrompt('q', ''); saveMergePrompt('c', ''); fill(); flash(); });
2506
+ // Language: live-switch the whole UI (no reload). Persist, re-apply the static chrome, then re-render
2507
+ // any currently-shown dynamic text (open composer / merged modal / index status) so it follows too.
2508
+ var langSel = document.getElementById('settings-language');
2509
+ if (langSel) {
2510
+ langSel.value = locale;
2511
+ langSel.addEventListener('change', function () {
2512
+ var next = langSel.value === 'ko' ? 'ko' : 'en';
2513
+ if (next === locale) return;
2514
+ locale = next;
2515
+ persistSave(LOCALE_KEY, locale);
2516
+ applyI18n();
2517
+ // Merge-prompt placeholders are locale-dependent defaults; refresh them while the panel is open.
2518
+ fill();
2519
+ // Re-render dynamic, currently-visible text in the new locale.
2520
+ try { if (typeof refreshComments === 'function') refreshComments(); } catch (e) {}
2521
+ var mergedModal = document.getElementById('mc-modal');
2522
+ if (mergedModal) { var mk = mergedModal.dataset.kind || 'q'; mergedModal.remove(); openMergedView(mk); }
2523
+ });
2524
+ }
1918
2525
  })();
1919
2526
 
1920
2527
  function setTab(name) {
@@ -1933,7 +2540,7 @@ function ensureTreeRendered() {
1933
2540
  if (!panel || !island) return;
1934
2541
  var html = island.textContent || '';
1935
2542
  island.parentNode && island.parentNode.removeChild(island);
1936
- panel.innerHTML = '<div class="empty-nav">Building file tree…</div>';
2543
+ panel.innerHTML = '<div class="empty-nav">' + escapeHtml(t('source.buildingTree')) + '</div>';
1937
2544
  setTimeout(function () { // let "Building…" paint before the heavy innerHTML
1938
2545
  panel.innerHTML = html;
1939
2546
  sourceLinks = Array.from(document.querySelectorAll('.source-link'));
@@ -1975,6 +2582,11 @@ function saveUiState() {
1975
2582
  view: document.getElementById('source-viewer')?.classList.contains('hidden') ? 'diff' : 'source',
1976
2583
  sourcePath,
1977
2584
  hash: location.hash,
2585
+ // Preserve open tabs + the exact caret across watch reloads (otherwise the caret resets to the
2586
+ // hunk's first change / file top every time the working tree changes).
2587
+ tabs: sourceTabs,
2588
+ diffCursor: diffCursor,
2589
+ viewerCursor: viewerCursor,
1978
2590
  }));
1979
2591
  }
1980
2592
 
@@ -1983,13 +2595,25 @@ function restoreUiState() {
1983
2595
  if (!raw) return false;
1984
2596
  try {
1985
2597
  const state = JSON.parse(raw);
2598
+ // Restore Files-mode tabs first so a watch reload doesn't drop the open tabs.
2599
+ if (Array.isArray(state.tabs)) sourceTabs = state.tabs.filter(function (p) { return sourceByPath.has(p); });
1986
2600
  if (state.view === 'diff') {
1987
- const match = String(state.hash || location.hash || '').match(/^#hunk-(\\d+)$/);
2601
+ const match = String(state.hash || location.hash || '').match(/^#hunk-(\d+)$/);
1988
2602
  setActive(match ? Number(match[1]) : current >= 0 ? current : 0, false);
2603
+ // Restore the exact diff caret (setActive only lands on the hunk's first change).
2604
+ if (state.diffCursor && state.diffCursor.path) {
2605
+ var dc = state.diffCursor;
2606
+ setTimeout(function () { try { setDiffCursor(dc.path, dc.side, dc.rowIndex, dc.column, true); } catch (e) {} }, 60);
2607
+ }
1989
2608
  return true;
1990
2609
  }
1991
2610
  if (state.sourcePath && sourceByPath.has(state.sourcePath)) {
1992
2611
  openSourceFile(state.sourcePath);
2612
+ // Restore the exact source caret/scroll (openSourceFile alone resets it to the top).
2613
+ if (state.viewerCursor && state.viewerCursor.path === state.sourcePath) {
2614
+ var vc = state.viewerCursor;
2615
+ setTimeout(function () { try { setSourceCursor(state.sourcePath, vc.lineIndex, vc.column, true, -1); } catch (e) {} }, 60);
2616
+ }
1993
2617
  return true;
1994
2618
  }
1995
2619
  } catch {
@@ -1998,6 +2622,71 @@ function restoreUiState() {
1998
2622
  return false;
1999
2623
  }
2000
2624
 
2625
+ // In-place diff refresh (instead of a full window reload): apply a compact payload of just the changed
2626
+ // regions (diff container, sidebar trees, status, data) and re-run the bootstrap steps. The window never
2627
+ // reloads, so the integrated terminal's pty sessions (claude/codex) survive a watch refresh. Electron's
2628
+ // main pushes the payload over IPC (monacori:diff-update); serve mode's poller fetches /__ai_flow_update.
2629
+ function applyDiffUpdate(u) {
2630
+ if (!u || !u.signature || u.signature === currentSignature) return false; // unchanged — nothing to do
2631
+
2632
+ // Remember what to restore after the swap (comments/viewed persist on their own; these don't).
2633
+ var sv = document.getElementById('source-viewer');
2634
+ var openPath = (sv && sv.dataset.openPath) || '';
2635
+ var wasSource = isSourceViewerVisible();
2636
+ var container = document.getElementById('diff2html-container');
2637
+ var diffScrollTop = container ? container.scrollTop : 0;
2638
+
2639
+ // 1) Replace the visible regions straight from the payload (no full-HTML parse).
2640
+ if (container) container.innerHTML = u.diffContainer || '';
2641
+ var changesPanel = document.getElementById('changes-panel');
2642
+ if (changesPanel) changesPanel.innerHTML = u.changesPanel || '';
2643
+ // Files tree: keep the inert island (lazy, not yet opened) in sync, and refresh the live panel when it's
2644
+ // already materialized — or always, in eager mode where the panel holds the tree directly.
2645
+ var filesIsland = document.getElementById('files-tree-html');
2646
+ if (filesIsland) filesIsland.textContent = u.filesTree || '';
2647
+ var filesPanel = document.getElementById('files-panel');
2648
+ if (filesPanel && (!REVIEW_LAZY || filesPanel.innerHTML.trim())) filesPanel.innerHTML = u.filesTree || '';
2649
+ var statusEl = document.querySelector('.review-status');
2650
+ if (statusEl) statusEl.innerHTML = u.reviewStatus || '';
2651
+ if (reviewMeta) { reviewMeta.setAttribute('data-signature', u.signature); if (u.generatedAt) reviewMeta.setAttribute('data-generated-at', u.generatedAt); }
2652
+
2653
+ // 2) Re-derive module-level state directly from the payload objects.
2654
+ fileStates = u.fileStates || [];
2655
+ fileSignatureByPath = new Map(fileStates.map(function (f) { return [f.path, f.signature]; }));
2656
+ sourceFiles = u.sourceFilesMeta || [];
2657
+ sourceByPath = new Map(sourceFiles.map(function (f) { return [f.path, f]; }));
2658
+ httpEnvironments = u.httpEnvironments || {};
2659
+ httpEnvNames = Object.keys(httpEnvironments);
2660
+ currentSignature = u.signature;
2661
+ links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
2662
+ sourceLinks = Array.from(document.querySelectorAll('.source-link'));
2663
+
2664
+ // 3) Reset lazy-materialize + index state so the new diff bodies / source / symbols rebuild on demand.
2665
+ bodyPromise = {};
2666
+ diffBootDone = false;
2667
+ sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
2668
+ sourceLoading = false;
2669
+ symbolIndex = null;
2670
+ if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
2671
+ else { prepareDiff2HtmlHunks(); diffBootDone = true; }
2672
+ if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
2673
+
2674
+ // 4) Re-run the DOM-dependent bootstrap steps.
2675
+ applyI18n();
2676
+ populateHttpEnvSelect();
2677
+ initSourceTreeFolds();
2678
+ refreshComments();
2679
+
2680
+ // 5) Best-effort restore of what the user was looking at.
2681
+ if (wasSource && openPath && sourceByPath.has(openPath)) {
2682
+ openSourceFile(openPath, false);
2683
+ } else if (container) {
2684
+ showDiffView(false);
2685
+ container.scrollTop = diffScrollTop;
2686
+ }
2687
+ return true;
2688
+ }
2689
+
2001
2690
  async function checkForLiveUpdate() {
2002
2691
  if (checkingForUpdates) return;
2003
2692
  checkingForUpdates = true;
@@ -2007,14 +2696,18 @@ async function checkForLiveUpdate() {
2007
2696
  if (!response.ok) return;
2008
2697
  const state = await response.json();
2009
2698
  if (liveStatus && state.generatedAt) {
2010
- liveStatus.textContent = 'Live: updated ' + new Date(state.generatedAt).toLocaleTimeString();
2699
+ liveStatus.textContent = t('status.live.updated') + ' ' + new Date(state.generatedAt).toLocaleTimeString();
2011
2700
  }
2012
2701
  if (state.signature && state.signature !== currentSignature) {
2013
- saveUiState();
2014
- location.reload();
2702
+ // serve mode: fetch just the compact update payload and refresh in place (same path Electron uses
2703
+ // over IPC) rather than reloading — so an open integrated terminal keeps its sessions.
2704
+ try {
2705
+ var fresh = await fetch('__ai_flow_update', { cache: 'no-store' });
2706
+ if (fresh.ok) applyDiffUpdate(await fresh.json());
2707
+ } catch (e) {}
2015
2708
  }
2016
2709
  } catch {
2017
- if (liveStatus) liveStatus.textContent = 'Live: waiting for diff server';
2710
+ if (liveStatus) liveStatus.textContent = t('status.live.waiting');
2018
2711
  } finally {
2019
2712
  checkingForUpdates = false;
2020
2713
  }
@@ -2190,7 +2883,18 @@ function measureCharWidth(element) {
2190
2883
  return measuredCharWidth;
2191
2884
  }
2192
2885
 
2886
+ var caretBusyTimer = null;
2887
+ // While the caret is actively moving (held arrow key, typing), keep it solid and only resume the
2888
+ // blink animation after a short idle. Otherwise key-repeat exposes the blink's "off" frames between
2889
+ // moves and the caret appears to vanish intermittently.
2890
+ function markCaretBusy() {
2891
+ document.body.classList.add('caret-busy');
2892
+ if (caretBusyTimer) clearTimeout(caretBusyTimer);
2893
+ caretBusyTimer = setTimeout(function () { document.body.classList.remove('caret-busy'); }, 650);
2894
+ }
2895
+
2193
2896
  function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLine = -1) {
2897
+ markCaretBusy();
2194
2898
  selectedCommentRow = null; // any explicit caret placement (click/move) ends a comment-box selection
2195
2899
  const file = sourceByPath.get(path);
2196
2900
  if (!file || !file.embedded) return;
@@ -2229,14 +2933,19 @@ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLi
2229
2933
  function updateSourceCaret(prev, lines, language) {
2230
2934
  const body = document.getElementById('source-body');
2231
2935
  if (!body) return;
2936
+ // Markdown/CSV render to HTML cells (.rendered-body): the caret is a whole-row highlight there,
2937
+ // so never rewrite a cell's innerHTML (that would replace the rendered block with raw text).
2938
+ const rendered = body.classList.contains('rendered-body');
2232
2939
  const rowFor = (idx) => body.querySelector('.source-row[data-line-index="' + idx + '"]');
2233
2940
  // Restore the line the caret left: drop the caret span, re-highlight the full line.
2234
2941
  if (prev && prev.lineIndex !== viewerCursor.lineIndex) {
2235
2942
  const prevRow = rowFor(prev.lineIndex);
2236
2943
  if (prevRow) {
2237
2944
  prevRow.classList.remove('cursor-line');
2238
- const prevCell = prevRow.querySelector('.source-code');
2239
- if (prevCell) prevCell.innerHTML = highlightLine(lines[prev.lineIndex] || '', language);
2945
+ if (!rendered) {
2946
+ const prevCell = prevRow.querySelector('.source-code');
2947
+ if (prevCell) prevCell.innerHTML = highlightLine(lines[prev.lineIndex] || '', language);
2948
+ }
2240
2949
  }
2241
2950
  }
2242
2951
  // Reconcile the go-to-definition highlight (set only on symbol jumps, cleared on plain moves).
@@ -2244,10 +2953,12 @@ function updateSourceCaret(prev, lines, language) {
2244
2953
  if (viewerCursor.targetLine >= 0) rowFor(viewerCursor.targetLine)?.classList.add('symbol-target');
2245
2954
  // Rebuild the new caret line with the caret span.
2246
2955
  const row = rowFor(viewerCursor.lineIndex);
2247
- if (!row) { openSourceFile(viewerCursor.path, false); return; } // line not in the DOM — fall back to a full render
2956
+ if (!row) { if (!rendered) openSourceFile(viewerCursor.path, false); return; } // line not in the DOM — full re-render (eager source only)
2248
2957
  row.classList.add('cursor-line');
2249
- const cell = row.querySelector('.source-code');
2250
- if (cell) cell.innerHTML = renderLineWithCursor(lines[viewerCursor.lineIndex] || '', language, viewerCursor.column);
2958
+ if (!rendered) {
2959
+ const cell = row.querySelector('.source-code');
2960
+ if (cell) cell.innerHTML = renderLineWithCursor(lines[viewerCursor.lineIndex] || '', language, viewerCursor.column);
2961
+ }
2251
2962
  }
2252
2963
 
2253
2964
  function openSourceAt(path, lineIndex, column) {
@@ -2351,6 +3062,20 @@ function moveSourceCursor(dLine, dColumn, extend) {
2351
3062
  if (!viewerCursor) return;
2352
3063
  const file = sourceByPath.get(viewerCursor.path);
2353
3064
  if (!file || !file.embedded) return;
3065
+ // Markdown/CSV rendered view: rows are blocks (sparse data-line-index), so any arrow steps to the
3066
+ // adjacent block row rather than into a (non-existent) raw line. No text column / selection there.
3067
+ const renderedBody = document.getElementById('source-body');
3068
+ if (renderedBody && renderedBody.classList.contains('rendered-body')) {
3069
+ const rows = Array.from(renderedBody.querySelectorAll('.source-row'));
3070
+ if (!rows.length) return;
3071
+ let ci = rows.indexOf(renderedBody.querySelector('.source-row[data-line-index="' + viewerCursor.lineIndex + '"]'));
3072
+ if (ci < 0) ci = 0;
3073
+ const step = (dLine || 0) + (dColumn > 0 ? 1 : dColumn < 0 ? -1 : 0);
3074
+ const ni = Math.max(0, Math.min(rows.length - 1, ci + (step || 0)));
3075
+ selectionAnchor = null;
3076
+ setSourceCursor(viewerCursor.path, Number(rows[ni].dataset.lineIndex) || 0, 0, true, -1);
3077
+ return;
3078
+ }
2354
3079
  const lines = file.content.split(/\r?\n/);
2355
3080
  let line = viewerCursor.lineIndex;
2356
3081
  let col = viewerCursor.column;
@@ -2599,6 +3324,14 @@ function symbolIndexWorker() {
2599
3324
  self.postMessage({ index: index, total: total });
2600
3325
  };
2601
3326
  }
3327
+ // Run symbol indexing off the critical path: requestIdleCallback so the heavy postMessage of the whole
3328
+ // source blob to the worker (structured-clone serialization is synchronous on the main thread) never
3329
+ // competes with key handling — especially on big repos right after the diff/tree first paints.
3330
+ function scheduleSymbolIndex() {
3331
+ var run = function () { try { startSymbolIndex(); } catch (e) {} };
3332
+ if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') window.requestIdleCallback(run, { timeout: 3000 });
3333
+ else setTimeout(run, 0);
3334
+ }
2602
3335
  function startSymbolIndex() {
2603
3336
  try {
2604
3337
  if (typeof Worker === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) return;
@@ -2631,11 +3364,11 @@ function setIndexProgress(done, total) {
2631
3364
  var bar = document.getElementById('index-progress');
2632
3365
  if (!el) return;
2633
3366
  if (!total || done >= total) {
2634
- el.textContent = (total || 0) + ' indexed';
3367
+ el.textContent = (total || 0) + ' ' + t('status.indexed');
2635
3368
  if (bar) bar.classList.add('hidden');
2636
3369
  return;
2637
3370
  }
2638
- el.textContent = 'indexing ' + done + '/' + total + '…';
3371
+ el.textContent = t('status.indexing') + ' ' + done + '/' + total + '…';
2639
3372
  if (bar) {
2640
3373
  bar.classList.remove('hidden');
2641
3374
  var fill = bar.firstElementChild;
@@ -2712,9 +3445,56 @@ function setSourceTypeIcon(path) {
2712
3445
  var icon = link ? link.querySelector('.ftype') : null;
2713
3446
  holder.innerHTML = icon ? icon.outerHTML : '';
2714
3447
  }
3448
+ // Files-mode tabs: each distinct file opened in the source viewer becomes a tab (session-only).
3449
+ // Cmd/Ctrl+W closes the active tab; Cmd/Ctrl+Shift+[ / ] cycle tabs; the × button closes one.
3450
+ // (sourceTabs is declared near the other source state up top so early restore-state openSourceFile
3451
+ // calls run before this block don't see an undefined array.)
3452
+ function addSourceTab(path) { if (path && sourceTabs.indexOf(path) < 0) sourceTabs.push(path); }
3453
+ function sourceTabLabel(path) { var p = String(path || ''); var s = p.lastIndexOf('/'); return s >= 0 ? p.slice(s + 1) : p; }
3454
+ function currentSourceTabPath() { var v = document.getElementById('source-viewer'); return (v && v.dataset.openPath) || ''; }
3455
+ function renderSourceTabs(activePath) {
3456
+ var bar = document.getElementById('source-tabs');
3457
+ if (!bar) return;
3458
+ if (!sourceTabs.length) { bar.classList.add('hidden'); bar.innerHTML = ''; return; }
3459
+ bar.classList.remove('hidden');
3460
+ bar.innerHTML = sourceTabs.map(function (p) {
3461
+ var active = p === activePath;
3462
+ return '<div class="source-tab' + (active ? ' active' : '') + '" data-tab-path="' + escapeHtml(p) + '" title="' + escapeHtml(p) + '">'
3463
+ + '<span class="source-tab-name">' + escapeHtml(sourceTabLabel(p)) + '</span>'
3464
+ + '<button type="button" class="source-tab-close" data-close-path="' + escapeHtml(p) + '" aria-label="Close tab" title="Close (Cmd/Ctrl+W)">×</button>'
3465
+ + '</div>';
3466
+ }).join('');
3467
+ var act = bar.querySelector('.source-tab.active');
3468
+ if (act && act.scrollIntoView) act.scrollIntoView({ block: 'nearest', inline: 'nearest' });
3469
+ }
3470
+ function closeSourceTab(path) {
3471
+ var idx = sourceTabs.indexOf(path);
3472
+ if (idx < 0) return;
3473
+ var wasActive = path === currentSourceTabPath();
3474
+ sourceTabs.splice(idx, 1);
3475
+ if (!wasActive) { renderSourceTabs(currentSourceTabPath()); return; }
3476
+ var nextPath = sourceTabs[idx] || sourceTabs[idx - 1] || '';
3477
+ if (nextPath) { openSourceFile(nextPath); return; }
3478
+ // No tabs left: reset the source view to its empty state.
3479
+ var v = document.getElementById('source-viewer'); if (v) v.dataset.openPath = '';
3480
+ var body = document.getElementById('source-body');
3481
+ if (body) { body.className = 'source-body empty'; body.textContent = t('source.selectFile'); }
3482
+ sourceLinks.forEach(function (l) { l.classList.remove('active'); });
3483
+ renderSourceTabs('');
3484
+ }
3485
+ function closeActiveSourceTab() { var p = currentSourceTabPath(); if (p) { closeSourceTab(p); return true; } return false; }
3486
+ function cycleSourceTab(dir) {
3487
+ if (sourceTabs.length < 2) return;
3488
+ var cur = sourceTabs.indexOf(currentSourceTabPath());
3489
+ if (cur < 0) cur = 0;
3490
+ openSourceFile(sourceTabs[(cur + dir + sourceTabs.length) % sourceTabs.length]);
3491
+ }
3492
+
2715
3493
  function openSourceFile(path, shouldSwitch = true) {
2716
3494
  const file = sourceByPath.get(path);
2717
3495
  if (!file) return;
3496
+ addSourceTab(path);
3497
+ renderSourceTabs(path);
2718
3498
  // lazy-LOAD: source content not fetched yet -> show a loading state; loadSourceData re-opens it.
2719
3499
  if (REVIEW_LAZY_LOAD && !sourceLoaded && file.embedded) {
2720
3500
  pendingSourceOpen = { path: path, shouldSwitch: shouldSwitch };
@@ -2726,7 +3506,7 @@ function openSourceFile(path, shouldSwitch = true) {
2726
3506
  revealTreeFor(path);
2727
3507
  var lb = document.getElementById('source-body');
2728
3508
  lb.className = 'source-body empty';
2729
- lb.textContent = 'Loading source';
3509
+ lb.textContent = t('source.loading');
2730
3510
  if (shouldSwitch) showSourceView();
2731
3511
  return;
2732
3512
  }
@@ -2736,12 +3516,9 @@ function openSourceFile(path, shouldSwitch = true) {
2736
3516
  renderBreadcrumb(document.getElementById('source-title'), path);
2737
3517
  setSourceTypeIcon(path);
2738
3518
  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(' | ');
3519
+ const meta = file.embedded
3520
+ ? formatBytes(file.size || 0)
3521
+ : formatBytes(file.size || 0) + ' · ' + (file.skippedReason || 'not embedded');
2745
3522
  document.getElementById('source-meta').textContent = meta;
2746
3523
  const body = document.getElementById('source-body');
2747
3524
  // Image files carry a data: URI preview instead of text — render inline (click to zoom).
@@ -2755,7 +3532,7 @@ function openSourceFile(path, shouldSwitch = true) {
2755
3532
  }
2756
3533
  if (!file.embedded) {
2757
3534
  body.className = 'source-body empty';
2758
- body.textContent = file.skippedReason ? 'Source preview unavailable: ' + file.skippedReason + '.' : 'Source preview unavailable.';
3535
+ body.textContent = file.skippedReason ? t('source.previewUnavailable').replace(/\.$/, '') + ': ' + file.skippedReason + '.' : t('source.previewUnavailable');
2759
3536
  document.getElementById('http-env-select')?.classList.add('hidden');
2760
3537
  updateRenderToggle(path);
2761
3538
  if (shouldSwitch) showSourceView();
@@ -2770,17 +3547,27 @@ function openSourceFile(path, shouldSwitch = true) {
2770
3547
  // is a .source-row keyed by its start line, so the gutter shows line numbers and line/block comments
2771
3548
  // work exactly as in the plain source view (renderSourceComments anchors on .source-row[data-line-index]).
2772
3549
  if (isMarkdownPath(path)) {
2773
- body.classList.add('rendered-body');
2774
- body.innerHTML = renderMarkdownRows(file.content);
3550
+ if (renderRawMode) {
3551
+ body.innerHTML = renderSourceTable(file, '');
3552
+ } else {
3553
+ body.classList.add('rendered-body');
3554
+ body.innerHTML = renderMarkdownRows(file.content);
3555
+ }
2775
3556
  if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
3557
+ updateRenderToggle(path);
2776
3558
  renderSourceComments();
2777
3559
  if (shouldSwitch) showSourceView();
2778
3560
  return;
2779
3561
  }
2780
3562
  if (isCsvPath(path)) {
2781
- body.classList.add('rendered-body');
2782
- body.innerHTML = renderCsvRows(file.content, path);
3563
+ if (renderRawMode) {
3564
+ body.innerHTML = renderSourceTable(file, '');
3565
+ } else {
3566
+ body.classList.add('rendered-body');
3567
+ body.innerHTML = renderCsvRows(file.content, path);
3568
+ }
2783
3569
  if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
3570
+ updateRenderToggle(path);
2784
3571
  renderSourceComments();
2785
3572
  if (shouldSwitch) showSourceView();
2786
3573
  return;
@@ -2792,12 +3579,45 @@ function openSourceFile(path, shouldSwitch = true) {
2792
3579
  body.innerHTML = renderSourceTable(file, '');
2793
3580
  if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
2794
3581
  }
3582
+ updateRenderToggle(path);
2795
3583
  renderSourceComments();
2796
3584
  if (shouldSwitch) showSourceView();
2797
3585
  }
2798
3586
 
2799
3587
  function isMarkdownPath(p) { return /\.(md|mdx|markdown)$/i.test(p || ''); }
2800
3588
  function isCsvPath(p) { return /\.(csv|tsv)$/i.test(p || ''); }
3589
+ function isRenderToggleable(p) { return isMarkdownPath(p) || isCsvPath(p); }
3590
+
3591
+ // Markdown/CSV open rendered by default; this flips the open file to raw line-numbered text and back.
3592
+ // Session-global so the choice carries across files. The toolbar button + Cmd/Ctrl+Shift+M both call it.
3593
+ var renderRawMode = false;
3594
+ function updateRenderToggle(path) {
3595
+ var btn = document.getElementById('render-toggle');
3596
+ if (!btn) return;
3597
+ var on = isRenderToggleable(path);
3598
+ btn.classList.toggle('hidden', !on);
3599
+ if (!on) return;
3600
+ btn.textContent = renderRawMode ? t('source.viewRendered') : t('source.viewRaw'); // label = the mode you switch TO
3601
+ btn.setAttribute('aria-pressed', renderRawMode ? 'true' : 'false');
3602
+ }
3603
+ function toggleRenderMode() {
3604
+ var sv = document.getElementById('source-viewer');
3605
+ var open = sv && sv.dataset.openPath;
3606
+ if (!open || !isRenderToggleable(open)) return;
3607
+ renderRawMode = !renderRawMode;
3608
+ openSourceFile(open, false); // re-render the current file in the new mode
3609
+ }
3610
+ (function wireRenderToggle() {
3611
+ var btn = document.getElementById('render-toggle');
3612
+ if (btn) btn.addEventListener('click', function () { toggleRenderMode(); });
3613
+ document.addEventListener('keydown', function (e) {
3614
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && !e.altKey && (e.key === 'M' || e.key === 'm' || e.code === 'KeyM')) {
3615
+ var sv = document.getElementById('source-viewer');
3616
+ var open = sv && sv.dataset.openPath;
3617
+ if (open && isRenderToggleable(open) && isSourceViewerVisible()) { e.preventDefault(); toggleRenderMode(); }
3618
+ }
3619
+ });
3620
+ })();
2801
3621
 
2802
3622
  function renderImageView(file) {
2803
3623
  return '<div class="image-view">'
@@ -3218,16 +4038,21 @@ function populateHttpEnvSelect() {
3218
4038
  opts += '<option value="' + escapeHtml(name) + '"' + (name === currentHttpEnvName ? ' selected' : '') + '>' + escapeHtml(name) + '</option>';
3219
4039
  });
3220
4040
  select.innerHTML = opts;
3221
- select.addEventListener('change', function () {
3222
- currentHttpEnvName = select.value;
3223
- try { localStorage.setItem(httpEnvKey, currentHttpEnvName); } catch (error) {}
3224
- const path = document.getElementById('source-viewer')?.dataset.openPath || '';
3225
- if (path && isHttpFile(path)) {
3226
- const file = sourceByPath.get(path);
3227
- const body = document.getElementById('source-body');
3228
- if (file && body) body.innerHTML = renderHttpTable(file);
3229
- }
3230
- });
4041
+ // The <select> lives in the toolbar (not swapped on in-place diff updates), so wire the change handler
4042
+ // exactly once — populateHttpEnvSelect is re-called by applyDiffUpdate to refresh the options.
4043
+ if (!select.dataset.wired) {
4044
+ select.dataset.wired = '1';
4045
+ select.addEventListener('change', function () {
4046
+ currentHttpEnvName = select.value;
4047
+ try { localStorage.setItem(httpEnvKey, currentHttpEnvName); } catch (error) {}
4048
+ const path = document.getElementById('source-viewer')?.dataset.openPath || '';
4049
+ if (path && isHttpFile(path)) {
4050
+ const file = sourceByPath.get(path);
4051
+ const body = document.getElementById('source-body');
4052
+ if (file && body) body.innerHTML = renderHttpTable(file);
4053
+ }
4054
+ });
4055
+ }
3231
4056
  }
3232
4057
 
3233
4058
  function renderSourceTable(file, query) {