@happy-nut/monacori 0.1.19 → 0.1.21

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.
@@ -98,14 +98,19 @@ function whenFileReady(wrapper, cb) {
98
98
  if (bodyPromise[idx]) { bodyPromise[idx].then(function () { cb(); }); return; }
99
99
  cb();
100
100
  }
101
+ var lazyIO = null; // remembered so each setupLazyDiff (re-run on every watch refresh) disconnects the prior
102
+ // observer instead of leaving a new one bound to detached wrappers — otherwise observers
103
+ // (and the old DOM they retain) pile up over a long-running session and slowly choke it.
101
104
  function setupLazyDiff() {
102
105
  var container = document.getElementById('diff2html-container');
103
106
  if (!container) return;
107
+ if (lazyIO) { try { lazyIO.disconnect(); } catch (e) {} lazyIO = null; }
104
108
  var wrappers = Array.prototype.slice.call(container.querySelectorAll('.d2h-file-wrapper'));
105
109
  if (typeof IntersectionObserver !== 'undefined') {
106
110
  var io = new IntersectionObserver(function (entries) {
107
111
  entries.forEach(function (e) { if (e.isIntersecting) { ensureFileReady(e.target); io.unobserve(e.target); } });
108
112
  }, { root: null, rootMargin: '600px 0px' });
113
+ lazyIO = io; // track this observer so the NEXT setupLazyDiff can disconnect it (callback keeps using local io)
109
114
  wrappers.forEach(function (w) { io.observe(w); });
110
115
  } else {
111
116
  wrappers.forEach(function (w) { ensureFileReady(w); }); // no IntersectionObserver -> materialize all
@@ -319,7 +324,6 @@ function prepareDiff2HtmlHunks() {
319
324
  prepareViewedControls();
320
325
 
321
326
  function prepareViewedControls() {
322
- pruneViewedState();
323
327
  document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
324
328
  const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
325
329
  const toggle = wrapper.querySelector('.d2h-file-collapse');
@@ -356,34 +360,23 @@ function currentFileSignature(path) {
356
360
 
357
361
  function isFileViewed(path) {
358
362
  const viewed = loadViewedState();
359
- const signature = currentFileSignature(path);
360
- return Boolean(signature && viewed[path] === signature);
363
+ return Boolean(viewed[path]); // boolean now; legacy signature strings are also truthy, so old marks still read as viewed
361
364
  }
362
365
 
363
366
  function setFileViewed(path, viewed) {
364
367
  const state = loadViewedState();
365
- if (viewed) {
366
- const signature = currentFileSignature(path);
367
- if (signature) state[path] = signature;
368
- } else {
369
- delete state[path];
370
- }
368
+ // Persist a plain boolean (not the file signature) so a viewed mark survives a restart/refresh the way
369
+ // comments do. Tying it to the signature meant any re-generation that changed the signature silently
370
+ // cleared every viewed mark — exactly the "viewed didn't persist" the user hit.
371
+ if (viewed) state[path] = true;
372
+ else delete state[path];
371
373
  saveViewedState(state);
372
374
  applyViewedState();
373
375
  }
374
376
 
375
- function pruneViewedState() {
376
- const state = loadViewedState();
377
- let changed = false;
378
- Object.keys(state).forEach((path) => {
379
- if (state[path] !== currentFileSignature(path)) {
380
- delete state[path];
381
- changed = true;
382
- }
383
- });
384
- if (changed) saveViewedState(state);
385
- }
386
-
377
+ // Viewed marks persist by path (a plain boolean), like comments — we deliberately DON'T prune on signature
378
+ // change or restart. Tying persistence to the file signature is what made viewed marks vanish on every
379
+ // re-generation; the user wants them to survive restarts the way comments do.
387
380
  function applyViewedState() {
388
381
  document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
389
382
  const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
@@ -518,6 +511,18 @@ function revealAt(el, scroller, fraction) {
518
511
  var off = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
519
512
  scroller.scrollTop += off - scroller.clientHeight * fraction;
520
513
  }
514
+ // Scrolloff variant: scroll ONLY when `el` would otherwise leave the viewport, keeping it within `marginFrac`
515
+ // of the top/bottom edge. While the row moves comfortably inside that band the view stays put — continuous
516
+ // centering scrolled the file even when everything was visible (dizzying). Used by the diff caret and the sidebar tree.
517
+ function scrolloffReveal(el, scroller, marginFrac) {
518
+ if (!el || !scroller || !scroller.clientHeight) return;
519
+ var top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
520
+ var rowH = el.offsetHeight || 18;
521
+ var ch = scroller.clientHeight;
522
+ var margin = Math.round(ch * marginFrac);
523
+ if (top < margin) scroller.scrollTop += top - margin;
524
+ else if (top + rowH > ch - margin) scroller.scrollTop += (top + rowH) - (ch - margin);
525
+ }
521
526
  function scheduleScrollIntoView(el) {
522
527
  pendingScrollEl = el || null;
523
528
  if (scrollElRaf) return;
@@ -557,7 +562,7 @@ function applySetActive(idx, shouldScroll) {
557
562
  history.replaceState(null, '', '#hunk-' + idx);
558
563
  // Row-dependent work waits for the file body (sync for eager/Phase 1, async for cold lazy-LOAD).
559
564
  whenFileReady(diffWrapperByPath(file), function () {
560
- showOnlyFile(file);
565
+ showOnlyFile(file, true); // materialize + isolate the file, but leave the caret to focusDiffRow (skip ensureDiffCursor)
561
566
  const active = document.getElementById('hunk-' + idx);
562
567
  if (!active) return;
563
568
  if (REVIEW_LAZY) {
@@ -571,16 +576,24 @@ function applySetActive(idx, shouldScroll) {
571
576
  // F7/change navigation moves the caret but must NOT pollute the Cmd+[/] cursor history.
572
577
  navSuppress = true;
573
578
  try { focusDiffRow(targetRow); } finally { navSuppress = false; }
574
- if (shouldScroll && targetRow) scheduleDiffScroll(targetRow);
579
+ // Scroll inline in THIS frame, NOT via scheduleDiffScroll's extra rAF. showOnlyFile just display:none'd
580
+ // the previous file, but the scroll container keeps its old (larger) scrollTop — so for one frame the new
581
+ // file renders at that stale offset (≈ line 146) before a deferred scroll snaps to the change (≈ line 21):
582
+ // the visible 146→21 double jump on F7 across a file boundary. Scrolling synchronously here lands the
583
+ // view on the change before this frame paints, so the new file appears already at its first change.
584
+ if (shouldScroll && targetRow && targetRow.scrollIntoView) targetRow.scrollIntoView({ block: 'center' });
575
585
  });
576
586
  }
577
587
 
578
- function showOnlyFile(fileName) {
588
+ function showOnlyFile(fileName, skipCursor) {
579
589
  if (REVIEW_LAZY) ensureFileReady(diffWrapperByPath(fileName));
580
590
  document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
581
591
  wrapper.classList.toggle('df-inactive', diffWrapperPathKey(wrapper) !== fileName);
582
592
  });
583
- ensureDiffCursor();
593
+ // applySetActive passes skipCursor: it sets the caret itself via focusDiffRow(targetRow). Letting
594
+ // ensureDiffCursor run here would first place the caret on the file's FIRST code row, then focusDiffRow
595
+ // overrides it to the change — a visible double jump (the F7 "first line → change" flash).
596
+ if (!skipCursor) ensureDiffCursor();
584
597
  }
585
598
 
586
599
  // The hunk the diff caret currently sits in. Arrow keys move the caret without touching the active
@@ -622,6 +635,10 @@ function changeBlockAnchors(wrapper) {
622
635
  return anchors;
623
636
  }
624
637
 
638
+ // Forward F7 at a file's last change announces "last change — press F7 again" once before crossing to the
639
+ // next file, giving a beat to mark-viewed. Holds the path we've already announced; any caret move clears it
640
+ // (see setDiffCursor), so leaving and returning to the last change re-arms the announcement.
641
+ var pendingFileBoundary = null;
625
642
  function next(delta) {
626
643
  if (hunkTotal() === 0) return;
627
644
  // Within the caret's (unviewed) file, step change-block by change-block so a context-merged hunk
@@ -640,7 +657,18 @@ function next(delta) {
640
657
  }
641
658
  }
642
659
  }
643
- // File boundary (no more change blocks this file) hunk-level nav to the next/prev unviewed file.
660
+ // File boundary: no more change blocks in this file. Forward F7 announces "last change press F7 again
661
+ // to go to the next file" on the FIRST press (a beat to mark-viewed) and only crosses on the SECOND
662
+ // consecutive press. Already-viewed files (and backward nav) cross immediately — no announcement.
663
+ if (delta > 0 && diffCursor && isDiffViewVisible() && !isFileViewed(diffCursor.path)) {
664
+ if (pendingFileBoundary !== diffCursor.path) {
665
+ pendingFileBoundary = diffCursor.path;
666
+ showToast(t('diff.lastHunk'));
667
+ return;
668
+ }
669
+ pendingFileBoundary = null; // second consecutive press on the same file → fall through and cross
670
+ }
671
+ // hunk-level nav to the next/prev unviewed file.
644
672
  const caretHunk = hunkIndexAtCaret();
645
673
  const base = caretHunk >= 0 ? caretHunk : current;
646
674
  let idx = base < 0 ? initialHunkForNavigation(delta) : base + delta;
@@ -652,6 +680,22 @@ function next(delta) {
652
680
  // Every changed file is marked viewed — nothing left to review, so F7/[/] stay put.
653
681
  }
654
682
 
683
+ // Jump to the first change of the next unviewed file after `path` (wrapping). Used right after marking a
684
+ // file viewed: its diff body is now hidden, so staying would blank the content — we advance to the next
685
+ // change instead. Returns false when every changed file is viewed (nothing to advance to).
686
+ function gotoNextUnviewedFile(path) {
687
+ const total = hunkTotal();
688
+ if (total === 0) return false;
689
+ const start = firstHunkForPath(path);
690
+ let idx = (start >= 0 ? start : (current >= 0 ? current : 0)) + 1;
691
+ for (let step = 0; step < total; step++) {
692
+ const norm = ((idx % total) + total) % total;
693
+ if (!isFileViewed(hunkPathAt(norm) || '')) { setActive(norm); return true; }
694
+ idx += 1;
695
+ }
696
+ return false;
697
+ }
698
+
655
699
  function initialHunkForNavigation(delta) {
656
700
  const openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
657
701
  const sourceHunk = firstHunkForPath(openPath);
@@ -893,8 +937,10 @@ function focusTree(index) {
893
937
  if (rows.length === 0) return;
894
938
  treeFocusIndex = Math.max(0, Math.min(rows.length - 1, index));
895
939
  // Render the focus class AND scroll in the SAME frame. A fast key-repeat queues many ArrowDowns before a
896
- // frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump ~one
897
- // viewport (~20 rows) at a time. Coalescing both keeps focus + scroll in lockstep so it scrolls smoothly.
940
+ // frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump. Coalescing
941
+ // both keeps focus + scroll in lockstep, and scrolloffReveal scrolls ONLY when the focused row nears the
942
+ // top/bottom edge — a row moving inside the visible band must never drag the whole panel (revealAt did,
943
+ // re-centering on every move so even a mid-list row scrolled the sidebar).
898
944
  scheduleTreeFocus();
899
945
  }
900
946
  var treeFocusRaf = 0;
@@ -906,7 +952,7 @@ function scheduleTreeFocus() {
906
952
  if (treeFocusIndex < 0 || treeFocusIndex >= rows.length) return;
907
953
  const el = rows[treeFocusIndex];
908
954
  document.querySelectorAll('.tree-focus').forEach((e) => { if (e !== el) e.classList.remove('tree-focus'); });
909
- if (el) { el.classList.add('tree-focus'); revealAt(el, document.querySelector('.sidebar-scroll'), 0.42); }
955
+ if (el) { el.classList.add('tree-focus'); scrolloffReveal(el, document.querySelector('.sidebar-scroll'), 0.15); }
910
956
  });
911
957
  }
912
958
 
@@ -1026,6 +1072,16 @@ function handleTreeKey(event) {
1026
1072
  if (Math.abs(e.deltaY) >= Math.abs(e.deltaX) && e.deltaY !== 0) { dsc.scrollTop += e.deltaY; e.preventDefault(); }
1027
1073
  }, { passive: false });
1028
1074
  })();
1075
+ // A floating, focus-grabbing overlay (merged-comments, prompt memo, settings) is open. While one is up it
1076
+ // owns focus AND the only caret, so global shortcuts stand down until Esc/close — we must not navigate a
1077
+ // panel the user can't even see behind the overlay (nor leave a second blinking caret in it).
1078
+ function isFloatingModalOpen() {
1079
+ var sm = document.getElementById('settings-modal');
1080
+ if (sm && !sm.classList.contains('hidden')) return true;
1081
+ // The merged/memo panels are now docked (inline), not overlays — but while one OWNS focus we still stand
1082
+ // down the global nav shortcuts so typing / ▲▼ inside it isn't hijacked. Focus elsewhere -> shortcuts run.
1083
+ return isDockFocused();
1084
+ }
1029
1085
  document.addEventListener('keydown', (event) => {
1030
1086
  if (!quickOpen?.classList.contains('hidden')) {
1031
1087
  if (handleQuickOpenKey(event)) return;
@@ -1035,8 +1091,42 @@ document.addEventListener('keydown', (event) => {
1035
1091
  if (handleUsagesKey(event)) return;
1036
1092
  }
1037
1093
 
1094
+ // Dock controls fire regardless of focus (terminal / merged / memo) — they sit ABOVE the focus guard so
1095
+ // they still work from inside a dock panel. Cmd/Ctrl+Shift+' maximizes the active dock; Cmd/Ctrl+Shift+/
1096
+ // and +. open the merged views; Cmd/Ctrl+Shift+N toggles the memo. (Match event.code so IME/layout never
1097
+ // swallows the combo.) Settings is a true overlay, so these stand down while it is up.
1098
+ var settingsUp = (function () { var s = document.getElementById('settings-modal'); return !!(s && !s.classList.contains('hidden')); })();
1099
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.code === 'Quote') {
1100
+ event.preventDefault();
1101
+ toggleDockMaximized();
1102
+ return;
1103
+ }
1104
+ if (!settingsUp && (event.metaKey || event.ctrlKey) && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
1105
+ event.preventDefault();
1106
+ openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
1107
+ return;
1108
+ }
1109
+ if (!settingsUp && (event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
1110
+ event.preventDefault();
1111
+ openMemoView();
1112
+ return;
1113
+ }
1114
+
1115
+ // Settings overlay (or a focused merged/memo dock) captures keys: stand down the rest of the global
1116
+ // shortcuts (Cmd+1, F7, Cmd+[/], Cmd+B, …). Each has its own Esc + editing handlers.
1117
+ if (isFloatingModalOpen()) return;
1118
+
1038
1119
  if ((event.metaKey || event.ctrlKey) && event.key === '1') {
1039
1120
  event.preventDefault();
1121
+ // Coming from the diff: open the file you were viewing as source so Cmd+1 lands ON it (not a stale/blank
1122
+ // source pane), and the tree below points at the same file. Capture the path BEFORE openSourceFile flips
1123
+ // the view (isDiffViewVisible would then be false).
1124
+ if (isDiffViewVisible()) {
1125
+ var dw1 = diffActiveWrapper();
1126
+ var dn1 = dw1 && dw1.querySelector('.d2h-file-name');
1127
+ var dpath1 = (diffCursor && diffCursor.path) || (dn1 ? (dn1.textContent || '').trim() : '');
1128
+ if (dpath1 && sourceByPath.has(dpath1)) openSourceFile(dpath1);
1129
+ }
1040
1130
  setTab('files');
1041
1131
  focusOpenFileInTree();
1042
1132
  return;
@@ -1076,21 +1166,8 @@ document.addEventListener('keydown', (event) => {
1076
1166
  }
1077
1167
  }
1078
1168
 
1079
- // Merged comment views see every saved comment of one kind at once + copy-all to paste into a prompt:
1080
- // Cmd/Ctrl+Shift+/ ("?") = all questions, Cmd/Ctrl+Shift+. (">") = all change-requests.
1081
- // Match the PHYSICAL key (event.code) so macOS/IME/layout never swallows the combo; fires in any focus.
1082
- if ((event.metaKey || event.ctrlKey) && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
1083
- event.preventDefault();
1084
- openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
1085
- return;
1086
- }
1087
- // Cmd/Ctrl+Shift+N opens/closes the prompt memo. Electron also routes this via the Review menu; in the
1088
- // browser/serve build (no menu) this keydown is the only path. Match the physical key so layout/IME never swallows it.
1089
- if ((event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
1090
- event.preventDefault();
1091
- openMemoView();
1092
- return;
1093
- }
1169
+ // (Merged views Cmd/Ctrl+Shift+/ +. and the memo Cmd/Ctrl+Shift+N are handled above the focus guard so
1170
+ // they work from inside a dock too.)
1094
1171
  // "?" = question, ">" = change-request composer on the current line/selection (no modifier).
1095
1172
  if (!event.altKey && !event.metaKey && !event.ctrlKey && (event.key === '?' || event.key === '>')) {
1096
1173
  const ce = document.activeElement;
@@ -1115,7 +1192,12 @@ document.addEventListener('keydown', (event) => {
1115
1192
  }
1116
1193
  if (vp && currentFileSignature(vp)) {
1117
1194
  event.preventDefault();
1118
- setFileViewed(vp, !isFileViewed(vp));
1195
+ const willView = !isFileViewed(vp);
1196
+ setFileViewed(vp, willView);
1197
+ // Marking viewed hides this file's diff body — don't strand the caret on the now-blank file.
1198
+ // Auto-advance to the next unviewed change (the user's flow: mark viewed -> jump to next).
1199
+ // Unmarking stays put. If every file is viewed, gotoNextUnviewedFile is a no-op.
1200
+ if (willView) gotoNextUnviewedFile(vp);
1119
1201
  return;
1120
1202
  }
1121
1203
  }
@@ -1139,6 +1221,10 @@ document.addEventListener('keydown', (event) => {
1139
1221
  var psc = isDiffViewVisible() ? document.getElementById('diff2html-container') : (isSourceViewerVisible() ? document.getElementById('source-body') : null);
1140
1222
  if (psc) { event.preventDefault(); psc.scrollTop += (event.key === 'PageDown' ? 0.9 : -0.9) * psc.clientHeight; return; }
1141
1223
  }
1224
+ // A non-Shift keystroke between the two Shifts cancels the pending double-Shift quick-open. Without this,
1225
+ // "Shift → type something → Shift" within 300ms still popped the search, so it fired on nearly every other
1226
+ // keystroke. Reset BEFORE the caret handlers below (they swallow arrows) so arrow keys break it too.
1227
+ if (event.key !== 'Shift') { lastShiftAt = 0; lastShiftSide = 0; }
1142
1228
  if (treeFocusIndex >= 0 && handleTreeKey(event)) return;
1143
1229
  if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isSourceViewerVisible() && handleSourceCaretKey(event)) return;
1144
1230
  if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isDiffViewVisible() && handleDiffCaretKey(event)) return;
@@ -1259,8 +1345,12 @@ document.addEventListener('keydown', (event) => {
1259
1345
  // where they were reading. Shift+F7 — and any file with no hunk of its own — falls through to plain
1260
1346
  // prev/next-change navigation across the whole diff.
1261
1347
  if (delta > 0 && sourceViewer && !sourceViewer.classList.contains('hidden')) {
1262
- const sourceHunk = firstHunkForPath(sourceViewer.dataset.openPath || '');
1263
- if (sourceHunk >= 0) {
1348
+ const sp = sourceViewer.dataset.openPath || '';
1349
+ const sourceHunk = firstHunkForPath(sp);
1350
+ // Enter the diff at the open file's own hunk — UNLESS it's already viewed. A viewed file's diff body
1351
+ // is hidden (display:none), so landing on it blanks the content and F7 appears stuck; fall through to
1352
+ // next() instead so we skip to an unviewed change.
1353
+ if (sourceHunk >= 0 && !isFileViewed(sp)) {
1264
1354
  setActive(sourceHunk);
1265
1355
  return;
1266
1356
  }
@@ -1360,7 +1450,11 @@ if (!restored) {
1360
1450
  else openDefaultSourceFile();
1361
1451
  }
1362
1452
  initSourceTreeFolds();
1363
- if (watchEnabled) setInterval(checkForLiveUpdate, 1500);
1453
+ // Electron receives live updates over IPC (monacoriMenu.onDiffUpdate); only serve/browser needs the HTTP
1454
+ // poller. Under file:// its fetch just fails every 1.5s for the app's whole life, so skip it in Electron.
1455
+ if (watchEnabled && !(window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function')) {
1456
+ setInterval(checkForLiveUpdate, 1500);
1457
+ }
1364
1458
  window.addEventListener('beforeunload', saveUiState);
1365
1459
 
1366
1460
  // First render has painted — drop the boot overlay (it bridged the blank gap right after loadFile). Two
@@ -1542,9 +1636,17 @@ function renderDiffCaret() {
1542
1636
  row.classList.add('mc-diff-cursor-row');
1543
1637
  var ctn = diffCellCtn(row);
1544
1638
  if (!ctn) return;
1545
- // Empty line (ctn is just a <br>): the row highlight marks the caret. Inserting a caret span
1546
- // next to the <br> would push it onto a second visual line and break the row's height.
1547
- if ((ctn.textContent || '').length === 0) return;
1639
+ // Empty line (ctn is just a <br>): an inline caret span would wrap onto a 2nd visual line and break the
1640
+ // row height, so position the caret absolutely it shows without affecting the layout.
1641
+ if ((ctn.textContent || '').length === 0) {
1642
+ var espan = document.createElement('span');
1643
+ espan.className = 'code-cursor';
1644
+ espan.setAttribute('aria-hidden', 'true');
1645
+ espan.style.position = 'absolute';
1646
+ ctn.appendChild(espan);
1647
+ diffCaretSpan = espan;
1648
+ return;
1649
+ }
1548
1650
  var pos = diffCaretDomPosition(ctn, diffCursor.column);
1549
1651
  if (!pos) return;
1550
1652
  var span = document.createElement('span');
@@ -1568,6 +1670,7 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
1568
1670
  var ri = Math.max(0, Math.min(rowIndex, rows.length - 1));
1569
1671
  var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
1570
1672
  diffCursor = { path: path, side: side, rowIndex: ri, column: col };
1673
+ pendingFileBoundary = null; // any caret move re-arms the last-change announcement for the next F7 (see next)
1571
1674
  diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
1572
1675
  if (reveal) {
1573
1676
  // Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
@@ -1592,7 +1695,7 @@ function scheduleDiffReveal(wrapper, side, ri) {
1592
1695
  applyDiffSelection();
1593
1696
  if (!t) return;
1594
1697
  var row = diffRowAt(t.wrapper, t.side, t.ri);
1595
- revealAt(row, document.getElementById('diff2html-container'), 0.42);
1698
+ scrolloffReveal(row, document.getElementById('diff2html-container'), 0.15);
1596
1699
  });
1597
1700
  }
1598
1701
  function navEntryOf(kind) {
@@ -1739,11 +1842,51 @@ function moveDiffWord(dir, extend) {
1739
1842
  setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, ncol, true);
1740
1843
  if (anchor) { diffSelectionAnchor = anchor; applyDiffSelection(); }
1741
1844
  }
1845
+ // Comment boxes are injected on the right(new) side, right after the line's row (see injectThreadRow /
1846
+ // renderDiffComments). Split-view rows align 1:1 by index, so the caret's row index on the new side finds
1847
+ // the adjacent box regardless of which side the caret sits on. Mirrors commentRowSiblingOf for the source view.
1848
+ function diffCommentBoxSiblingOf(dir) {
1849
+ if (!diffCursor) return null;
1850
+ var wrapper = diffWrapperByPath(diffCursor.path);
1851
+ if (!wrapper) return null;
1852
+ var rows = diffRowsOf(diffSideTable(wrapper, 'new'));
1853
+ var row = rows[diffCursor.rowIndex];
1854
+ if (!row) return null;
1855
+ var sib = dir < 0 ? row.previousElementSibling : row.nextElementSibling;
1856
+ return (sib && sib.classList && sib.classList.contains('mc-comment-row')) ? sib : null;
1857
+ }
1742
1858
  function handleDiffCaretKey(event) {
1743
1859
  if (!isDiffViewVisible() || !diffCursor) return false;
1744
1860
  var ae = document.activeElement;
1745
1861
  if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return false;
1746
1862
  var extend = event.shiftKey;
1863
+ // A comment box is selected: Backspace/Delete removes it, `e` edits it, an arrow/Escape steps off it.
1864
+ // Same contract as the source view (handleSourceCaretKey), but caret moves go through setDiffCursor.
1865
+ if (selectedCommentRow) {
1866
+ if (event.key === 'Backspace' || event.key === 'Delete') { event.preventDefault(); deleteCommentsInRow(selectedCommentRow); return true; }
1867
+ if (event.key === 'e' || event.key === 'E') { event.preventDefault(); editCommentInRow(selectedCommentRow); return true; }
1868
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Escape') {
1869
+ var dir = event.key === 'ArrowUp' ? -1 : (event.key === 'ArrowDown' ? 1 : 0);
1870
+ var sib = dir < 0 ? selectedCommentRow.previousElementSibling : (dir > 0 ? selectedCommentRow.nextElementSibling : null);
1871
+ selectedCommentRow.classList.remove('mc-row-selected');
1872
+ selectedCommentRow = null;
1873
+ event.preventDefault();
1874
+ var wrapper = diffWrapperByPath(diffCursor.path);
1875
+ if (sib && wrapper && isDiffCodeRow(sib)) {
1876
+ var rows = diffRowsOf(diffSideTable(wrapper, 'new'));
1877
+ var idx = rows.indexOf(sib);
1878
+ if (idx >= 0) { setDiffCursor(diffCursor.path, 'new', idx, 0, true); return true; }
1879
+ }
1880
+ setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, diffCursor.column, false); // restore caret where it was
1881
+ return true;
1882
+ }
1883
+ return false;
1884
+ }
1885
+ // Plain Up/Down: a comment box attached to the caret line is a selectable stop (caret stays visible).
1886
+ if (!extend && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
1887
+ var box = diffCommentBoxSiblingOf(event.key === 'ArrowUp' ? -1 : 1);
1888
+ if (box) { event.preventDefault(); selectCommentRow(box); return true; }
1889
+ }
1747
1890
  if (event.key === 'ArrowDown') { event.preventDefault(); moveDiffCursor(1, 0, extend); return true; }
1748
1891
  if (event.key === 'ArrowUp') { event.preventDefault(); moveDiffCursor(-1, 0, extend); return true; }
1749
1892
  if (event.key === 'ArrowLeft') { event.preventDefault(); moveDiffCursor(0, -1, extend); return true; }
@@ -1768,35 +1911,30 @@ function showToast(message) {
1768
1911
  setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
1769
1912
  }, 4500);
1770
1913
  }
1771
- // When a file changes, follow each comment to its snapshot line (c.code) in the new content: same line if
1772
- // unchanged, else the nearest exact match of that line. If the line can't be found the change is too large
1773
- // to trustdrop the comment and toast. Files whose content isn't loaded yet (lazy) are skipped here and
1774
- // reconciled once loadSourceData brings the content in.
1914
+ // Follow each comment to its snapshot line (c.code) in the current content: same line if unchanged, else the
1915
+ // nearest exact match of that line. A comment is NEVER auto-deleted. If its line can't be found we leave it
1916
+ // where it is this happens routinely WITHOUT the file changing: a comment anchored to a deleted/old-side
1917
+ // diff line (comments carry no side, so old-side text never matches the new content) would otherwise vanish.
1918
+ // Silently dropping user-authored comments loses data; the reviewer can remove a stale one with the × button.
1919
+ // Files whose content isn't loaded yet (lazy) are skipped here and reconciled once loadSourceData arrives.
1775
1920
  function remapComments() {
1776
1921
  if (!reviewComments.length) return;
1777
- var dropped = [], moved = 0;
1778
- reviewComments = reviewComments.filter(function (c) {
1922
+ var moved = 0;
1923
+ reviewComments.forEach(function (c) {
1779
1924
  var file = sourceByPath.get(c.path);
1780
- if (!file || !file.embedded || typeof file.content !== 'string' || !file.content) return true;
1925
+ if (!file || !file.embedded || typeof file.content !== 'string' || !file.content) return;
1781
1926
  var code = c.code == null ? '' : String(c.code);
1782
- if (!code.trim()) return true;
1927
+ if (!code.trim()) return;
1783
1928
  var lines = file.content.split(/\r?\n/);
1784
- if (lines[c.line - 1] === code) return true;
1929
+ if (lines[c.line - 1] === code) return;
1785
1930
  var best = -1, bestDist = Infinity;
1786
1931
  for (var i = 0; i < lines.length; i++) {
1787
1932
  if (lines[i] === code) { var d = Math.abs(i - (c.line - 1)); if (d < bestDist) { bestDist = d; best = i; } }
1788
1933
  }
1789
- if (best >= 0) { if (c.line !== best + 1) moved++; c.line = best + 1; return true; }
1790
- dropped.push(c);
1791
- return false;
1934
+ if (best >= 0 && c.line !== best + 1) { c.line = best + 1; moved++; } // moved to follow the line; not found -> keep as-is
1792
1935
  });
1793
- if (!dropped.length && !moved) return; // nothing changed — skip the save/re-render
1936
+ if (!moved) return; // nothing moved — skip the save/re-render
1794
1937
  saveComments();
1795
- var byPath = {};
1796
- dropped.forEach(function (c) { byPath[c.path] = (byPath[c.path] || 0) + 1; });
1797
- Object.keys(byPath).forEach(function (p) {
1798
- showToast(t('toast.commentsDropped').replace('{n}', byPath[p]).replace('{file}', String(p).split('/').pop()));
1799
- });
1800
1938
  refreshComments();
1801
1939
  }
1802
1940
  function saveComments() {
@@ -1821,6 +1959,14 @@ function addComment(kind, path, line, code, text) {
1821
1959
  reviewComments.push({ seq: commentSeq, kind: kind, path: path, line: line, code: String(code || ''), text: trimmed });
1822
1960
  saveComments();
1823
1961
  }
1962
+ // Edit an existing comment in place (e on a selected box -> composer prefilled -> save). Empty text deletes it.
1963
+ function updateComment(seq, text) {
1964
+ var c = reviewComments.find(function (x) { return x.seq === seq; });
1965
+ if (!c) return;
1966
+ var trimmed = String(text || '').trim();
1967
+ if (trimmed) { c.text = trimmed; saveComments(); }
1968
+ else { deleteComment(seq); }
1969
+ }
1824
1970
  function deleteComment(seq) {
1825
1971
  reviewComments = reviewComments.filter(function (c) { return c.seq !== seq; });
1826
1972
  saveComments();
@@ -1901,6 +2047,7 @@ function composerTargetLabel(s) {
1901
2047
  function threadHtml(path, line) {
1902
2048
  var html = '';
1903
2049
  commentsAt(path, line).forEach(function (c) {
2050
+ if (composerState && composerState.editSeq === c.seq) return; // being edited -> rendered as the composer below
1904
2051
  html += '<div class="mc-card mc-' + c.kind + '">'
1905
2052
  + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(c.kind) + '</span>'
1906
2053
  + '<button type="button" class="mc-del" data-seq="' + c.seq + '" title="' + escapeHtml(t('composer.delete')) + '">×</button></div>'
@@ -1910,7 +2057,7 @@ function threadHtml(path, line) {
1910
2057
  var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
1911
2058
  html += '<div class="mc-card mc-' + composerState.kind + ' mc-composer">'
1912
2059
  + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(composerState.kind) + '</span><span class="mc-target" title="' + escapeHtml(composerState.path || '') + '">' + escapeHtml(composerTargetLabel(composerState)) + '</span></div>'
1913
- + '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '"></textarea>'
2060
+ + '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '">' + escapeHtml(composerState.editText || '') + '</textarea>'
1914
2061
  + '<div class="mc-actions"><button type="button" class="mc-btn mc-save">' + escapeHtml(t('composer.save')) + '</button>'
1915
2062
  + '<button type="button" class="mc-btn mc-ghost mc-cancel">' + escapeHtml(t('composer.cancel')) + '</button>'
1916
2063
  + '<span class="mc-hint">' + escapeHtml(t('composer.hint')) + '</span></div></div>';
@@ -2069,6 +2216,7 @@ function closeComposer() {
2069
2216
  if (!composerState) return;
2070
2217
  composerState = null;
2071
2218
  refreshComments();
2219
+ flushPendingDiffUpdate(); // apply any live watch refresh that was held while composing
2072
2220
  }
2073
2221
  // The composer is injected into BOTH the diff and source views (refreshComments renders comments in
2074
2222
  // each), but only one view is on screen at a time — the other lives inside a `.hidden` container with
@@ -2089,9 +2237,11 @@ function saveComposer(ta) {
2089
2237
  if (!composerState) return;
2090
2238
  var box = ta || activeComposerInput();
2091
2239
  if (!box) return;
2092
- addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
2240
+ if (composerState.editSeq != null) updateComment(composerState.editSeq, box.value);
2241
+ else addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
2093
2242
  composerState = null;
2094
2243
  refreshComments();
2244
+ flushPendingDiffUpdate(); // apply any live watch refresh that was held while composing
2095
2245
  }
2096
2246
 
2097
2247
  // Default merge-prompt headings, localized: a Korean user gets Korean defaults. Editable in
@@ -2233,36 +2383,145 @@ function buildMergedText(kind) {
2233
2383
  return lines.join(nl);
2234
2384
  }
2235
2385
 
2236
- function openMergedView(kind) {
2237
- var existing = document.getElementById('mc-modal');
2238
- if (existing) existing.remove();
2239
- var modal = document.createElement('div');
2240
- modal.id = 'mc-modal';
2241
- modal.className = 'mc-modal';
2242
- modal.dataset.kind = kind; // remembered so a live locale switch can re-render this same view
2386
+ // ===== Bottom dock: merged-prompt / memo / terminal share ONE docked slot below the editor =====
2387
+ // Only one is visible at a time — opening one closes the others (the terminal included). Cmd/Ctrl+Shift+'
2388
+ // maximizes the active dock over the editor area (the sidebar stays). A top resizer drags the height.
2389
+ var dockHeightKey = 'monacori-dock-height';
2390
+ var dockMaximized = false;
2391
+ function applyDockHeight(px) {
2392
+ var h = Math.max(140, Math.min(px, window.innerHeight - 120));
2393
+ document.documentElement.style.setProperty('--dock-height', h + 'px');
2394
+ }
2395
+ (function () { var s = parseInt(localStorage.getItem(dockHeightKey) || '', 10); if (s) applyDockHeight(s); })();
2396
+ // The dock panel currently filling the slot: a merged/memo panel, else the terminal when it's open.
2397
+ function activeDockPanel() {
2398
+ var mm = document.getElementById('mc-merged-panel') || document.getElementById('mc-memo-panel');
2399
+ if (mm) return mm;
2400
+ var term = document.getElementById('terminal-panel');
2401
+ return (term && !term.classList.contains('hidden')) ? term : null;
2402
+ }
2403
+ function applyDockMaximized() {
2404
+ if (!activeDockPanel()) dockMaximized = false; // nothing docked -> can't stay maximized
2405
+ document.body.classList.toggle('dock-maximized', dockMaximized);
2406
+ }
2407
+ function toggleDockMaximized() {
2408
+ if (!activeDockPanel()) return; // nothing docked -> nothing to maximize
2409
+ dockMaximized = !dockMaximized;
2410
+ applyDockMaximized();
2411
+ }
2412
+ function isDockFocused() {
2413
+ var ae = document.activeElement;
2414
+ return !!(ae && ae.closest && ae.closest('.dock-panel'));
2415
+ }
2416
+ // Close the merged/memo docks (the terminal's setOpen also calls this so the slot stays exclusive).
2417
+ function closeMergedMemoDocks() {
2418
+ var m = document.getElementById('mc-merged-panel'); if (m) m.remove();
2419
+ var n = document.getElementById('mc-memo-panel'); if (n) n.remove();
2420
+ document.querySelectorAll('.dock-backdrop').forEach(function (b) { b.remove(); });
2421
+ document.body.classList.toggle('dock-open', !!activeDockPanel());
2422
+ // floating-dock tracks merged/memo only (NOT the terminal) so the maximize CSS hides content for a
2423
+ // terminal dock but never for these floating panels.
2424
+ document.body.classList.toggle('floating-dock', !!(document.getElementById('mc-merged-panel') || document.getElementById('mc-memo-panel')));
2425
+ applyDockMaximized();
2426
+ }
2427
+ window.__monacoriCloseDocks = closeMergedMemoDocks;
2428
+ // Retry-focus a docked field (Electron async-restores focus to <body>, so a one-shot focus can lose the race).
2429
+ function focusDockField(field, panelSel) {
2430
+ var tries = 0;
2431
+ var tryF = function () {
2432
+ if (!document.querySelector(panelSel)) return true;
2433
+ if (document.activeElement === field) return true;
2434
+ try { field.focus(); } catch (e) {}
2435
+ return document.activeElement === field;
2436
+ };
2437
+ if (!tryF()) { var iv = setInterval(function () { if (tryF() || ++tries > 12) clearInterval(iv); }, 25); }
2438
+ }
2439
+ // Build a docked panel shell (resizer + bar with Maximize/Close + body) and mount it below the editor.
2440
+ // Opening it closes the terminal and any other merged/memo dock (the slot is exclusive). Returns
2441
+ // { panel, body, bar, close }.
2442
+ function mountDock(id, titleText) {
2443
+ if (window.__monacoriTerminal && typeof window.__monacoriTerminal.close === 'function') {
2444
+ try { window.__monacoriTerminal.close(); } catch (e) {}
2445
+ }
2446
+ var prior = document.getElementById(id);
2447
+ if (prior) prior.remove();
2448
+ closeMergedMemoDocks();
2243
2449
  var panel = document.createElement('div');
2244
- panel.className = 'mc-modal-panel';
2245
- var head = document.createElement('div');
2246
- head.className = 'mc-modal-head';
2450
+ panel.id = id;
2451
+ panel.className = 'dock-panel';
2452
+ panel.tabIndex = -1;
2453
+ // The panel floats over the editor; a dim backdrop sits behind it (click to dismiss).
2454
+ var backdrop = document.createElement('div');
2455
+ backdrop.className = 'dock-backdrop';
2456
+ var resizer = document.createElement('div');
2457
+ resizer.className = 'dock-resizer';
2458
+ resizer.setAttribute('aria-hidden', 'true');
2459
+ var bar = document.createElement('div');
2460
+ bar.className = 'dock-bar';
2247
2461
  var title = document.createElement('span');
2248
- title.textContent = kind === 'q' ? t('merged.qTitle') : t('merged.cTitle');
2462
+ title.className = 'dock-title';
2463
+ title.textContent = titleText;
2464
+ var maxBtn = document.createElement('button');
2465
+ maxBtn.type = 'button';
2466
+ maxBtn.className = 'dock-btn dock-max';
2467
+ maxBtn.setAttribute('data-i18n-title', 'dock.maximize');
2468
+ maxBtn.title = t('dock.maximize');
2469
+ maxBtn.textContent = '⤢'; // ⤢ maximize glyph
2249
2470
  var closeBtn = document.createElement('button');
2250
2471
  closeBtn.type = 'button';
2251
- closeBtn.className = 'mc-btn mc-ghost';
2472
+ closeBtn.className = 'dock-btn dock-close';
2473
+ closeBtn.setAttribute('data-i18n', 'merged.close');
2252
2474
  closeBtn.textContent = t('merged.close');
2475
+ var body = document.createElement('div');
2476
+ body.className = 'dock-body';
2477
+ bar.appendChild(title);
2478
+ bar.appendChild(maxBtn);
2479
+ bar.appendChild(closeBtn);
2480
+ panel.appendChild(resizer);
2481
+ panel.appendChild(bar);
2482
+ panel.appendChild(body);
2483
+ document.body.appendChild(backdrop);
2484
+ document.body.appendChild(panel);
2485
+ function close() { panel.remove(); backdrop.remove(); closeMergedMemoDocks(); }
2486
+ maxBtn.addEventListener('click', function () { toggleDockMaximized(); });
2487
+ closeBtn.addEventListener('click', close);
2488
+ backdrop.addEventListener('click', close); // click the dim behind the panel to dismiss
2489
+ // Esc closes the dock when focus is inside it; the editor keeps its own handlers otherwise.
2490
+ panel.addEventListener('keydown', function (e) {
2491
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
2492
+ });
2493
+ resizer.addEventListener('mousedown', function (e) {
2494
+ e.preventDefault();
2495
+ resizer.classList.add('resizing');
2496
+ function move(ev) { applyDockHeight(window.innerHeight - ev.clientY); }
2497
+ function up() {
2498
+ resizer.classList.remove('resizing');
2499
+ document.removeEventListener('mousemove', move);
2500
+ document.removeEventListener('mouseup', up);
2501
+ var cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--dock-height'), 10);
2502
+ if (cur) { try { localStorage.setItem(dockHeightKey, String(cur)); } catch (x) {} }
2503
+ }
2504
+ document.addEventListener('mousemove', move);
2505
+ document.addEventListener('mouseup', up);
2506
+ });
2507
+ document.body.classList.add('dock-open');
2508
+ document.body.classList.add('floating-dock'); // scopes the maximize CSS so it doesn't hide the diff
2509
+ applyDockMaximized();
2510
+ return { panel: panel, body: body, bar: bar, close: close };
2511
+ }
2512
+
2513
+ function openMergedView(kind) {
2514
+ var dock = mountDock('mc-merged-panel', kind === 'q' ? t('merged.qTitle') : t('merged.cTitle'));
2515
+ dock.panel.dataset.kind = kind; // remembered so a live locale switch can re-render this same view
2253
2516
  var area = document.createElement('textarea');
2254
2517
  area.className = 'mc-modal-text';
2255
2518
  // NOT readOnly: a readOnly textarea hides the caret in Chromium, yet we need it VISIBLE so the user sees
2256
- // which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead — read-only in
2257
- // effect while the caret and selection stay fully interactive.
2519
+ // which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead.
2258
2520
  area.value = buildMergedText(kind);
2259
2521
  area.addEventListener('beforeinput', function (e) { e.preventDefault(); });
2260
- // Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret "Go to comment"
2261
- // + "Remove" for a single caret; "Remove" only for a drag/select-all (can't navigate to many at once).
2262
- // Removing here calls deleteComment(), which re-syncs the on-screen comment boxes via refreshComments.
2522
+ // Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret. Opt/Alt+Arrow steps
2523
+ // the caret comment-to-comment so each can be acted on without hand-scrolling.
2263
2524
  area.addEventListener('keydown', function (e) {
2264
- // Opt/Alt + Arrow steps the caret to the next/previous comment block so you can move comment-to-comment
2265
- // and act on each with Opt+Enter, without hand-scrolling.
2266
2525
  if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
2267
2526
  e.preventDefault();
2268
2527
  e.stopPropagation();
@@ -2277,57 +2536,28 @@ function openMergedView(kind) {
2277
2536
  var cxy = mergedCaretXY(area);
2278
2537
  var x = cxy.x, y = cxy.below, flipTop = cxy.top;
2279
2538
  var rerender = function () {
2280
- if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) { modal.remove(); return; }
2539
+ if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) { dock.close(); return; }
2281
2540
  area.value = buildMergedText(kind);
2282
2541
  };
2283
2542
  if (area.selectionStart !== area.selectionEnd || seqs.length > 1) {
2284
- showCustomDropdown(x, y, [{ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } }], flipTop);
2543
+ // Select-all / multi-comment: offer send-to-terminal (the whole merged text) FIRST, then remove-all.
2544
+ var multi = [];
2545
+ if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
2546
+ multi.push({ label: t('merged.sendToTerminal'), onSelect: function () { var text = buildMergedText(kind); dock.close(); window.__monacoriTerminal.enterSendMode(text); } });
2547
+ }
2548
+ multi.push({ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } });
2549
+ showCustomDropdown(x, y, multi, flipTop);
2285
2550
  } else {
2286
2551
  var seq = seqs[0];
2287
2552
  showCustomDropdown(x, y, [
2288
- { label: t('dropdown.navigate'), onSelect: function () { modal.remove(); navigateToComment(seq); } },
2553
+ { label: t('dropdown.navigate'), onSelect: function () { dock.close(); navigateToComment(seq); } },
2289
2554
  { label: t('dropdown.remove'), onSelect: function () { deleteComment(seq); rerender(); } },
2290
2555
  ], flipTop);
2291
2556
  }
2292
2557
  });
2293
- closeBtn.addEventListener('click', function () { modal.remove(); });
2294
- // Terminal send (Electron, terminal open): close the modal and hand off to pane-pick mode ON the
2295
- // terminal — the chosen pane is highlighted, the rest dimmed, arrows change the choice, Enter sends.
2296
- // One button here; the actual pick happens visually over the live claude/codex sessions.
2297
- var sendBtn = null;
2298
- if (window.__monacoriTerminal && typeof window.__monacoriTerminal.isOpen === 'function' && window.__monacoriTerminal.isOpen()) {
2299
- sendBtn = document.createElement('button');
2300
- sendBtn.type = 'button';
2301
- sendBtn.className = 'mc-btn mc-send-term';
2302
- sendBtn.textContent = t('merged.sendToTerminal');
2303
- sendBtn.addEventListener('click', function () {
2304
- var text = buildMergedText(kind);
2305
- modal.remove();
2306
- window.__monacoriTerminal.enterSendMode(text);
2307
- });
2308
- }
2309
- head.appendChild(title);
2310
- if (sendBtn) head.appendChild(sendBtn);
2311
- head.appendChild(closeBtn);
2312
- panel.appendChild(head);
2313
- panel.appendChild(area);
2314
- modal.appendChild(panel);
2315
- modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
2316
- modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
2317
- document.body.appendChild(modal);
2318
- // Focus the send button (Enter starts pane-pick) when present, else the read-only text. Electron
2319
- // async-restores focus to <body>, so retry briefly (same as the composer).
2320
- var modalFocusTarget = area; // focus the text (not the send button) so the caret is visible and Opt+Arrow/Enter work; Send-to-terminal is a click
2321
- var modalFocusTries = 0;
2322
- var tryFocusModal = function () {
2323
- if (!document.getElementById('mc-modal')) return true;
2324
- if (document.activeElement === modalFocusTarget) return true;
2325
- try { modalFocusTarget.focus(); modalFocusTarget.selectionStart = modalFocusTarget.selectionEnd = 0; } catch (e) {}
2326
- return document.activeElement === modalFocusTarget;
2327
- };
2328
- if (!tryFocusModal()) {
2329
- var modalFocusIv = setInterval(function () { if (tryFocusModal() || ++modalFocusTries > 12) clearInterval(modalFocusIv); }, 25);
2330
- }
2558
+ dock.body.appendChild(area);
2559
+ // Focus the read-only text so the caret is visible and Opt+Arrow / Opt+Enter work; retry (Electron focus race).
2560
+ focusDockField(area, '#mc-merged-panel');
2331
2561
  }
2332
2562
 
2333
2563
  // Prompt memo (Cmd/Ctrl+Shift+N): one freeform Markdown scratchpad with a live split preview, persisted
@@ -2345,27 +2575,10 @@ function renderMemoMd(text) {
2345
2575
  return renderMarkdownBlocks(text).map(function (b) { return b.html; }).join('');
2346
2576
  }
2347
2577
  function openMemoView() {
2348
- var existing = document.getElementById('mc-memo');
2349
- if (existing) { existing.remove(); return; } // the shortcut toggles: a second press closes the memo
2350
- var modal = document.createElement('div');
2351
- modal.id = 'mc-memo';
2352
- modal.className = 'mc-modal';
2353
- var panel = document.createElement('div');
2354
- panel.className = 'mc-modal-panel mc-memo-panel';
2355
- var head = document.createElement('div');
2356
- head.className = 'mc-modal-head';
2357
- var title = document.createElement('span');
2358
- title.setAttribute('data-i18n', 'memo.title');
2359
- title.textContent = t('memo.title');
2360
- var closeBtn = document.createElement('button');
2361
- closeBtn.type = 'button';
2362
- closeBtn.className = 'mc-btn mc-ghost';
2363
- closeBtn.setAttribute('data-i18n', 'merged.close');
2364
- closeBtn.textContent = t('merged.close');
2365
- closeBtn.addEventListener('click', function () { modal.remove(); });
2366
-
2367
- var body = document.createElement('div');
2368
- body.className = 'mc-memo-body';
2578
+ if (document.getElementById('mc-memo-panel')) { closeMergedMemoDocks(); return; } // the shortcut toggles: 2nd press closes
2579
+ var dock = mountDock('mc-memo-panel', t('memo.title'));
2580
+ var memoBody = document.createElement('div');
2581
+ memoBody.className = 'mc-memo-body';
2369
2582
  var area = document.createElement('textarea');
2370
2583
  area.className = 'mc-modal-text mc-memo-edit';
2371
2584
  area.spellcheck = false;
@@ -2379,45 +2592,25 @@ function openMemoView() {
2379
2592
  saveMemo(area.value);
2380
2593
  preview.innerHTML = renderMemoMd(area.value);
2381
2594
  });
2382
-
2383
- // Terminal send: hand the current draft to pane-pick mode (arrows choose the session, Enter sends). Shown
2384
- // only once a terminal pane exists; enterSendMode reopens the panel if it was closed.
2385
- var sendBtn = null;
2595
+ // Terminal send: hand the current draft to pane-pick mode. Shown only once a terminal pane exists;
2596
+ // enterSendMode reopens the terminal (which closes this memo dock the slot is exclusive).
2386
2597
  if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
2387
- sendBtn = document.createElement('button');
2598
+ var sendBtn = document.createElement('button');
2388
2599
  sendBtn.type = 'button';
2389
- sendBtn.className = 'mc-btn mc-send-term';
2600
+ sendBtn.className = 'dock-btn mc-send-term';
2390
2601
  sendBtn.setAttribute('data-i18n', 'merged.sendToTerminal');
2391
2602
  sendBtn.textContent = t('merged.sendToTerminal');
2392
2603
  sendBtn.addEventListener('click', function () {
2393
2604
  var text = area.value;
2394
- modal.remove();
2605
+ dock.close();
2395
2606
  window.__monacoriTerminal.enterSendMode(text);
2396
2607
  });
2608
+ dock.bar.insertBefore(sendBtn, dock.bar.querySelector('.dock-max'));
2397
2609
  }
2398
-
2399
- head.appendChild(title);
2400
- if (sendBtn) head.appendChild(sendBtn);
2401
- head.appendChild(closeBtn);
2402
- body.appendChild(area);
2403
- body.appendChild(preview);
2404
- panel.appendChild(head);
2405
- panel.appendChild(body);
2406
- modal.appendChild(panel);
2407
- modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
2408
- modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
2409
- document.body.appendChild(modal);
2410
- // Focus the editor; Electron async-restores focus to <body>, so retry briefly (same as the composer/merged view).
2411
- var memoFocusTries = 0;
2412
- var tryFocusMemo = function () {
2413
- if (!document.getElementById('mc-memo')) return true;
2414
- if (document.activeElement === area) return true;
2415
- try { area.focus(); } catch (e) {}
2416
- return document.activeElement === area;
2417
- };
2418
- if (!tryFocusMemo()) {
2419
- var memoFocusIv = setInterval(function () { if (tryFocusMemo() || ++memoFocusTries > 12) clearInterval(memoFocusIv); }, 25);
2420
- }
2610
+ memoBody.appendChild(area);
2611
+ memoBody.appendChild(preview);
2612
+ dock.body.appendChild(memoBody);
2613
+ focusDockField(area, '#mc-memo-panel');
2421
2614
  }
2422
2615
 
2423
2616
  document.addEventListener('click', function (event) {
@@ -2623,10 +2816,13 @@ refreshComments();
2623
2816
 
2624
2817
  function isOpen() { return !panel.classList.contains('hidden'); }
2625
2818
  function setOpen(open) {
2819
+ // The terminal shares the bottom dock slot with merged/memo — opening it closes those (exclusive slot).
2820
+ if (open && typeof window.__monacoriCloseDocks === 'function') { try { window.__monacoriCloseDocks(); } catch (e) {} }
2626
2821
  panel.classList.toggle('hidden', !open);
2627
2822
  document.body.classList.toggle('terminal-open', open);
2628
2823
  if (toggleBtn) toggleBtn.classList.toggle('is-active', open);
2629
2824
  try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch (e) {}
2825
+ if (typeof applyDockMaximized === 'function') applyDockMaximized(); // keep Cmd+Shift+' maximize in sync
2630
2826
  if (open) {
2631
2827
  if (panes.length === 0) makePane();
2632
2828
  requestAnimationFrame(function () { fitAll(); if (active) try { active.term.focus(); } catch (e) {} });
@@ -2850,8 +3046,10 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
2850
3046
  // Capture so closing settings wins over other Escape handlers (lightbox / composer).
2851
3047
  document.addEventListener('keydown', function (e) {
2852
3048
  if (e.key === 'Escape' && !modal.classList.contains('hidden')) { e.stopPropagation(); e.preventDefault(); close(); return; }
2853
- // Cmd/Ctrl+, (the standard "Preferences" accelerator) toggles the settings panel from anywhere.
3049
+ // Cmd/Ctrl+, (the standard "Preferences" accelerator) toggles the settings panel from anywhere — but not
3050
+ // while another floating overlay (merged / memo) owns focus; that one must be Esc'd first.
2854
3051
  if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === ',' || e.code === 'Comma')) {
3052
+ if (modal.classList.contains('hidden') && (document.getElementById('mc-modal') || document.getElementById('mc-memo'))) return;
2855
3053
  e.preventDefault(); e.stopPropagation();
2856
3054
  if (modal.classList.contains('hidden')) open('general'); else close();
2857
3055
  }
@@ -3000,8 +3198,19 @@ function restoreUiState() {
3000
3198
  // regions (diff container, sidebar trees, status, data) and re-run the bootstrap steps. The window never
3001
3199
  // reloads, so the integrated terminal's pty sessions (claude/codex) survive a watch refresh. Electron's
3002
3200
  // main pushes the payload over IPC (monacori:diff-update); serve mode's poller fetches /__ai_flow_update.
3201
+ // Live watch refreshes are HELD while a comment composer is open. applyDiffUpdate rebuilds the diff DOM, so
3202
+ // applying it mid-compose would destroy the composer textarea every watch tick — input stalls and characters
3203
+ // arrive in bursts — and flicker the page. Keep only the latest pending payload; flush it on close/save.
3204
+ var pendingDiffUpdate = null;
3205
+ function flushPendingDiffUpdate() {
3206
+ if (!pendingDiffUpdate) return;
3207
+ var u = pendingDiffUpdate;
3208
+ pendingDiffUpdate = null;
3209
+ try { applyDiffUpdate(u); } catch (e) {}
3210
+ }
3003
3211
  function applyDiffUpdate(u) {
3004
3212
  if (!u || !u.signature || u.signature === currentSignature) return false; // unchanged — nothing to do
3213
+ if (composerState) { pendingDiffUpdate = u; return false; } // composing a comment — hold the refresh until close/save
3005
3214
 
3006
3215
  // Remember what to restore after the swap (comments/viewed persist on their own; these don't).
3007
3216
  var sv = document.getElementById('source-viewer');
@@ -3009,6 +3218,23 @@ function applyDiffUpdate(u) {
3009
3218
  var wasSource = isSourceViewerVisible();
3010
3219
  var container = document.getElementById('diff2html-container');
3011
3220
  var diffScrollTop = container ? container.scrollTop : 0;
3221
+ // Did the file the user is CURRENTLY viewing actually change in this build? If not, we must not re-render
3222
+ // the source view — an unrelated file's edit would otherwise flicker the pane they're reading. Capture the
3223
+ // open file's signature BEFORE fileSignatureByPath is rebuilt below.
3224
+ var prevOpenSig = openPath ? (fileSignatureByPath.get(openPath) || '') : '';
3225
+
3226
+ // Snapshot already-materialized file bodies (keyed by path + current signature) BEFORE the swap, so an
3227
+ // UNCHANGED file can be re-filled synchronously afterwards. Without this, the swap turns every wrapper into
3228
+ // an empty lazy shell that blanks until its body re-loads over IPC — the visible "flicker" on a watch tick.
3229
+ var prevBodies = {};
3230
+ if (REVIEW_LAZY && container) {
3231
+ container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
3232
+ var b = w.querySelector('.d2h-files-diff');
3233
+ if (!b || b.hasAttribute('data-lazy')) return; // only bodies that are actually materialized
3234
+ var p = diffWrapperPathKey(w);
3235
+ if (p) prevBodies[p] = { sig: fileSignatureByPath.get(p) || '', html: b.innerHTML };
3236
+ });
3237
+ }
3012
3238
 
3013
3239
  // 1) Replace the visible regions straight from the payload (no full-HTML parse).
3014
3240
  if (container) container.innerHTML = u.diffContainer || '';
@@ -3027,6 +3253,9 @@ function applyDiffUpdate(u) {
3027
3253
  // 2) Re-derive module-level state directly from the payload objects.
3028
3254
  fileStates = u.fileStates || [];
3029
3255
  fileSignatureByPath = new Map(fileStates.map(function (f) { return [f.path, f.signature]; }));
3256
+ // The open file changed iff its signature moved (or it vanished from the new build). Drives whether we
3257
+ // re-render the source view below.
3258
+ var openFileChanged = !openPath || prevOpenSig !== (fileSignatureByPath.get(openPath) || '');
3030
3259
  sourceFiles = u.sourceFilesMeta || [];
3031
3260
  sourceByPath = new Map(sourceFiles.map(function (f) { return [f.path, f]; }));
3032
3261
  httpEnvironments = u.httpEnvironments || {};
@@ -3036,16 +3265,40 @@ function applyDiffUpdate(u) {
3036
3265
  sourceLinks = Array.from(document.querySelectorAll('.source-link'));
3037
3266
 
3038
3267
  // 3) Reset lazy-materialize + index state so the new diff bodies / source / symbols rebuild on demand.
3268
+ // bodyCache is keyed by file INDEX, not content — after a watch rebuild the same index maps to the new
3269
+ // body, so it MUST be dropped too. Clearing only bodyPromise left loadBodyHtml() returning the cached
3270
+ // OLD body, so a watch change never showed up in the diff until a full reload.
3271
+ bodyCache = {};
3039
3272
  bodyPromise = {};
3040
3273
  diffBootDone = false;
3041
3274
  sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
3042
3275
  sourceLoading = false;
3043
- sourceBodyPath = null; // the new build may have changed the open file's content force a body re-render on next open
3276
+ // Force a source body re-render on next open ONLY if the open file actually changed; otherwise keep
3277
+ // sourceBodyPath so the already-painted (unchanged) source view is left exactly as-is — no flicker.
3278
+ if (openFileChanged) sourceBodyPath = null;
3044
3279
  symbolIndex = null;
3045
3280
  if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
3046
3281
  else { prepareDiff2HtmlHunks(); diffBootDone = true; }
3047
3282
  if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
3048
3283
 
3284
+ // 3b) Re-fill UNCHANGED files' bodies synchronously from the snapshot so they don't blank-then-reload (the
3285
+ // flicker). The fresh wrapper carries the correct data-first-hunk + file index, so materializeBody numbers
3286
+ // hunks exactly as a normal lazy load would — this only skips the IPC round-trip for files whose content is
3287
+ // identical. Changed/new files stay shells and lazy-load as usual, so a real edit still refreshes the diff.
3288
+ if (REVIEW_LAZY && container) {
3289
+ container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
3290
+ var p = diffWrapperPathKey(w);
3291
+ var prev = p ? prevBodies[p] : null;
3292
+ if (!prev || !prev.sig || prev.sig !== (fileSignatureByPath.get(p) || '')) return; // changed/new -> lazy-load
3293
+ var shell = w.querySelector('.d2h-files-diff[data-lazy]');
3294
+ if (!shell) return;
3295
+ var idx = (w.id || '').replace('file-', '');
3296
+ materializeBody(w, prev.html); // fills the body + markWrapperHunks (uses the new data-first-hunk)
3297
+ bodyCache[idx] = prev.html; // keep the index cache consistent so it never refetches
3298
+ bodyPromise[idx] = Promise.resolve(w);
3299
+ });
3300
+ }
3301
+
3049
3302
  // 4) Re-run the DOM-dependent bootstrap steps.
3050
3303
  applyI18n();
3051
3304
  populateHttpEnvSelect();
@@ -3053,9 +3306,10 @@ function applyDiffUpdate(u) {
3053
3306
  remapComments(); // follow/drop comments whose anchor line moved or vanished in the new build
3054
3307
  refreshComments();
3055
3308
 
3056
- // 5) Best-effort restore of what the user was looking at.
3309
+ // 5) Best-effort restore of what the user was looking at. Re-render the source view only when the open file
3310
+ // actually changed; an unchanged file stays painted as-is, so an unrelated edit doesn't flicker the pane.
3057
3311
  if (wasSource && openPath && sourceByPath.has(openPath)) {
3058
- openSourceFile(openPath, false);
3312
+ if (openFileChanged) openSourceFile(openPath, false);
3059
3313
  } else if (container) {
3060
3314
  showDiffView(false);
3061
3315
  container.scrollTop = diffScrollTop;
@@ -3313,6 +3567,17 @@ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLi
3313
3567
  recordNav(navEntryOf('source'));
3314
3568
  }
3315
3569
  var sourceRevealRaf = 0, sourceRevealPrev = null;
3570
+ // Source rows are a fixed monospace height, so the caret-follow scroll can be computed from
3571
+ // lineIndex*rowHeight instead of reading the caret's getBoundingClientRect — which forces a full reflow on
3572
+ // every move (~15ms on a 400-line file; the main caret-follow stutter). Cached; invalidated on resize.
3573
+ var _srcRowH = 0;
3574
+ function sourceRowHeight() {
3575
+ if (_srcRowH > 0) return _srcRowH;
3576
+ var r = document.querySelector('#source-body .source-row');
3577
+ if (r) { var h = r.offsetHeight; if (h > 0) _srcRowH = h; }
3578
+ return _srcRowH;
3579
+ }
3580
+ if (typeof window !== 'undefined') window.addEventListener('resize', function () { _srcRowH = 0; });
3316
3581
  function scheduleSourceReveal(prev) {
3317
3582
  // First prev of a coalesced burst wins: a fast ArrowDown updates viewerCursor many times before the frame
3318
3583
  // fires; render the caret once (first prev -> final viewerCursor) and scroll in the SAME frame so caret and
@@ -3326,8 +3591,23 @@ function scheduleSourceReveal(prev) {
3326
3591
  if (!f || !f.embedded) return;
3327
3592
  var lines = f.content.split(/\r?\n/);
3328
3593
  updateSourceCaret(p, lines, f.language || 'text');
3329
- var cl = document.querySelector('.source-row.cursor-line');
3330
- revealAt(cl, document.getElementById('source-body'), 0.42);
3594
+ var sb = document.getElementById('source-body');
3595
+ var rowH = sourceRowHeight();
3596
+ if (rowH > 0 && sb && !sb.classList.contains('rendered-body')) {
3597
+ // Scrolloff, not follow: scroll ONLY when the caret would otherwise leave the viewport, keeping it
3598
+ // within a 15% margin of the top/bottom edge. While the caret moves comfortably inside that band the
3599
+ // view stays put — continuous follow was dizzying (the file slid even when everything was visible) and
3600
+ // it forced a scroll/reflow on every move. lineIndex*rowH avoids getBoundingClientRect entirely, and
3601
+ // skipping the scroll when it's unnecessary removes the reflow on most moves too.
3602
+ var caretTop = viewerCursor.lineIndex * rowH;
3603
+ var ch = sb.clientHeight;
3604
+ var margin = Math.round(ch * 0.15);
3605
+ var vTop = sb.scrollTop;
3606
+ if (caretTop < vTop + margin) sb.scrollTop = Math.max(0, caretTop - margin);
3607
+ else if (caretTop + rowH > vTop + ch - margin) sb.scrollTop = caretTop + rowH - ch + margin;
3608
+ } else {
3609
+ revealAt(document.querySelector('.source-row.cursor-line'), sb, 0.85);
3610
+ }
3331
3611
  });
3332
3612
  }
3333
3613
 
@@ -3424,9 +3704,9 @@ function selectCommentRow(row) {
3424
3704
  selectedCommentRow = row || null;
3425
3705
  if (!selectedCommentRow) return;
3426
3706
  selectedCommentRow.classList.add('mc-row-selected');
3427
- // hide the text caret while the box is "selected" (no re-render happens during plain selection)
3428
- document.querySelectorAll('#source-body .source-row.cursor-line').forEach(function (r) { r.classList.remove('cursor-line'); });
3429
- document.querySelectorAll('#source-body .code-cursor').forEach(function (s) { var p = s.parentNode; if (p) { p.removeChild(s); if (p.normalize) p.normalize(); } });
3707
+ // Keep the caret visible: the box's active outline (.mc-row-selected) already shows the selection, and the
3708
+ // caret must never be hidden ("어떤 경우에도 커서는 가려지면 안 됨"). Previously this removed cursor-line +
3709
+ // code-cursor, so Go-to-comment ArrowDown (which selects the comment box on that line) made the caret vanish.
3430
3710
  }
3431
3711
  function deleteCommentsInRow(row) {
3432
3712
  if (!row) return;
@@ -3438,6 +3718,21 @@ function deleteCommentsInRow(row) {
3438
3718
  }
3439
3719
  refreshComments(); // remaining comment rows re-injected; the caret stays hidden until the next arrow press
3440
3720
  }
3721
+ // Open the composer in EDIT mode for the first comment in `row`, pre-filled with its text. threadHtml renders
3722
+ // the composer in place of that card (via composerState.editSeq), and saveComposer routes editSeq through
3723
+ // updateComment instead of addComment. Triggered by `e` while a comment box is selected.
3724
+ function editCommentInRow(row) {
3725
+ if (!row) return;
3726
+ var del = row.querySelector('.mc-del');
3727
+ if (!del) return;
3728
+ var seq = parseInt(del.dataset.seq, 10);
3729
+ var c = reviewComments.find(function (x) { return x.seq === seq; });
3730
+ if (!c) return;
3731
+ row.classList.remove('mc-row-selected');
3732
+ selectedCommentRow = null;
3733
+ composerState = { kind: c.kind, path: c.path, line: c.line, code: c.code, editSeq: seq, editText: c.text };
3734
+ refreshComments();
3735
+ }
3441
3736
  function handleSourceCaretKey(event) {
3442
3737
  if (!viewerCursor) return false;
3443
3738
  var ae = document.activeElement;
@@ -3446,6 +3741,7 @@ function handleSourceCaretKey(event) {
3446
3741
  // A comment box is selected (caret hidden): Backspace/Delete removes it; an arrow steps off it.
3447
3742
  if (selectedCommentRow) {
3448
3743
  if (event.key === 'Backspace' || event.key === 'Delete') { event.preventDefault(); deleteCommentsInRow(selectedCommentRow); return true; }
3744
+ if (event.key === 'e' || event.key === 'E') { event.preventDefault(); editCommentInRow(selectedCommentRow); return true; }
3449
3745
  if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Escape') {
3450
3746
  var dir = event.key === 'ArrowUp' ? -1 : (event.key === 'ArrowDown' ? 1 : 0);
3451
3747
  var sib = dir < 0 ? selectedCommentRow.previousElementSibling : (dir > 0 ? selectedCommentRow.nextElementSibling : null);
@@ -4036,6 +4332,7 @@ function toggleRenderMode() {
4036
4332
  var btn = document.getElementById('render-toggle');
4037
4333
  if (btn) btn.addEventListener('click', function () { toggleRenderMode(); });
4038
4334
  document.addEventListener('keydown', function (e) {
4335
+ if (isFloatingModalOpen()) return; // a floating overlay owns focus -> no render-toggle shortcut beneath it
4039
4336
  if ((e.metaKey || e.ctrlKey) && e.shiftKey && !e.altKey && (e.key === 'M' || e.key === 'm' || e.code === 'KeyM')) {
4040
4337
  var sv = document.getElementById('source-viewer');
4041
4338
  var open = sv && sv.dataset.openPath;