@happy-nut/monacori 0.1.20 → 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
@@ -508,7 +513,7 @@ function revealAt(el, scroller, fraction) {
508
513
  }
509
514
  // Scrolloff variant: scroll ONLY when `el` would otherwise leave the viewport, keeping it within `marginFrac`
510
515
  // of the top/bottom edge. While the row moves comfortably inside that band the view stays put — continuous
511
- // centering scrolled the file even when everything was visible (dizzying). Used by the diff caret.
516
+ // centering scrolled the file even when everything was visible (dizzying). Used by the diff caret and the sidebar tree.
512
517
  function scrolloffReveal(el, scroller, marginFrac) {
513
518
  if (!el || !scroller || !scroller.clientHeight) return;
514
519
  var top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
@@ -675,6 +680,22 @@ function next(delta) {
675
680
  // Every changed file is marked viewed — nothing left to review, so F7/[/] stay put.
676
681
  }
677
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
+
678
699
  function initialHunkForNavigation(delta) {
679
700
  const openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
680
701
  const sourceHunk = firstHunkForPath(openPath);
@@ -916,8 +937,10 @@ function focusTree(index) {
916
937
  if (rows.length === 0) return;
917
938
  treeFocusIndex = Math.max(0, Math.min(rows.length - 1, index));
918
939
  // Render the focus class AND scroll in the SAME frame. A fast key-repeat queues many ArrowDowns before a
919
- // frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump ~one
920
- // 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).
921
944
  scheduleTreeFocus();
922
945
  }
923
946
  var treeFocusRaf = 0;
@@ -929,7 +952,7 @@ function scheduleTreeFocus() {
929
952
  if (treeFocusIndex < 0 || treeFocusIndex >= rows.length) return;
930
953
  const el = rows[treeFocusIndex];
931
954
  document.querySelectorAll('.tree-focus').forEach((e) => { if (e !== el) e.classList.remove('tree-focus'); });
932
- 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); }
933
956
  });
934
957
  }
935
958
 
@@ -1053,9 +1076,11 @@ function handleTreeKey(event) {
1053
1076
  // owns focus AND the only caret, so global shortcuts stand down until Esc/close — we must not navigate a
1054
1077
  // panel the user can't even see behind the overlay (nor leave a second blinking caret in it).
1055
1078
  function isFloatingModalOpen() {
1056
- if (document.getElementById('mc-modal') || document.getElementById('mc-memo')) return true;
1057
1079
  var sm = document.getElementById('settings-modal');
1058
- return !!(sm && !sm.classList.contains('hidden'));
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();
1059
1084
  }
1060
1085
  document.addEventListener('keydown', (event) => {
1061
1086
  if (!quickOpen?.classList.contains('hidden')) {
@@ -1066,9 +1091,29 @@ document.addEventListener('keydown', (event) => {
1066
1091
  if (handleUsagesKey(event)) return;
1067
1092
  }
1068
1093
 
1069
- // Floating overlay open (merged / memo / settings): it captures keys until Esc. Don't run ANY global
1070
- // shortcut (Cmd+1, F7, Cmd+[/], Cmd+B, open-merged/memo, …) underneath focus and the only caret belong
1071
- // to the overlay. Each overlay has its own Esc + editing handlers, so we simply stand down here.
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.
1072
1117
  if (isFloatingModalOpen()) return;
1073
1118
 
1074
1119
  if ((event.metaKey || event.ctrlKey) && event.key === '1') {
@@ -1121,21 +1166,8 @@ document.addEventListener('keydown', (event) => {
1121
1166
  }
1122
1167
  }
1123
1168
 
1124
- // Merged comment views see every saved comment of one kind at once + copy-all to paste into a prompt:
1125
- // Cmd/Ctrl+Shift+/ ("?") = all questions, Cmd/Ctrl+Shift+. (">") = all change-requests.
1126
- // Match the PHYSICAL key (event.code) so macOS/IME/layout never swallows the combo; fires in any focus.
1127
- if ((event.metaKey || event.ctrlKey) && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
1128
- event.preventDefault();
1129
- openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
1130
- return;
1131
- }
1132
- // Cmd/Ctrl+Shift+N opens/closes the prompt memo. Electron also routes this via the Review menu; in the
1133
- // browser/serve build (no menu) this keydown is the only path. Match the physical key so layout/IME never swallows it.
1134
- if ((event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
1135
- event.preventDefault();
1136
- openMemoView();
1137
- return;
1138
- }
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.)
1139
1171
  // "?" = question, ">" = change-request composer on the current line/selection (no modifier).
1140
1172
  if (!event.altKey && !event.metaKey && !event.ctrlKey && (event.key === '?' || event.key === '>')) {
1141
1173
  const ce = document.activeElement;
@@ -1160,7 +1192,12 @@ document.addEventListener('keydown', (event) => {
1160
1192
  }
1161
1193
  if (vp && currentFileSignature(vp)) {
1162
1194
  event.preventDefault();
1163
- 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);
1164
1201
  return;
1165
1202
  }
1166
1203
  }
@@ -1184,6 +1221,10 @@ document.addEventListener('keydown', (event) => {
1184
1221
  var psc = isDiffViewVisible() ? document.getElementById('diff2html-container') : (isSourceViewerVisible() ? document.getElementById('source-body') : null);
1185
1222
  if (psc) { event.preventDefault(); psc.scrollTop += (event.key === 'PageDown' ? 0.9 : -0.9) * psc.clientHeight; return; }
1186
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; }
1187
1228
  if (treeFocusIndex >= 0 && handleTreeKey(event)) return;
1188
1229
  if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isSourceViewerVisible() && handleSourceCaretKey(event)) return;
1189
1230
  if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isDiffViewVisible() && handleDiffCaretKey(event)) return;
@@ -1304,8 +1345,12 @@ document.addEventListener('keydown', (event) => {
1304
1345
  // where they were reading. Shift+F7 — and any file with no hunk of its own — falls through to plain
1305
1346
  // prev/next-change navigation across the whole diff.
1306
1347
  if (delta > 0 && sourceViewer && !sourceViewer.classList.contains('hidden')) {
1307
- const sourceHunk = firstHunkForPath(sourceViewer.dataset.openPath || '');
1308
- 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)) {
1309
1354
  setActive(sourceHunk);
1310
1355
  return;
1311
1356
  }
@@ -1405,7 +1450,11 @@ if (!restored) {
1405
1450
  else openDefaultSourceFile();
1406
1451
  }
1407
1452
  initSourceTreeFolds();
1408
- 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
+ }
1409
1458
  window.addEventListener('beforeunload', saveUiState);
1410
1459
 
1411
1460
  // First render has painted — drop the boot overlay (it bridged the blank gap right after loadFile). Two
@@ -1793,11 +1842,51 @@ function moveDiffWord(dir, extend) {
1793
1842
  setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, ncol, true);
1794
1843
  if (anchor) { diffSelectionAnchor = anchor; applyDiffSelection(); }
1795
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
+ }
1796
1858
  function handleDiffCaretKey(event) {
1797
1859
  if (!isDiffViewVisible() || !diffCursor) return false;
1798
1860
  var ae = document.activeElement;
1799
1861
  if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return false;
1800
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
+ }
1801
1890
  if (event.key === 'ArrowDown') { event.preventDefault(); moveDiffCursor(1, 0, extend); return true; }
1802
1891
  if (event.key === 'ArrowUp') { event.preventDefault(); moveDiffCursor(-1, 0, extend); return true; }
1803
1892
  if (event.key === 'ArrowLeft') { event.preventDefault(); moveDiffCursor(0, -1, extend); return true; }
@@ -2127,6 +2216,7 @@ function closeComposer() {
2127
2216
  if (!composerState) return;
2128
2217
  composerState = null;
2129
2218
  refreshComments();
2219
+ flushPendingDiffUpdate(); // apply any live watch refresh that was held while composing
2130
2220
  }
2131
2221
  // The composer is injected into BOTH the diff and source views (refreshComments renders comments in
2132
2222
  // each), but only one view is on screen at a time — the other lives inside a `.hidden` container with
@@ -2151,6 +2241,7 @@ function saveComposer(ta) {
2151
2241
  else addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
2152
2242
  composerState = null;
2153
2243
  refreshComments();
2244
+ flushPendingDiffUpdate(); // apply any live watch refresh that was held while composing
2154
2245
  }
2155
2246
 
2156
2247
  // Default merge-prompt headings, localized: a Korean user gets Korean defaults. Editable in
@@ -2292,36 +2383,145 @@ function buildMergedText(kind) {
2292
2383
  return lines.join(nl);
2293
2384
  }
2294
2385
 
2295
- function openMergedView(kind) {
2296
- var existing = document.getElementById('mc-modal');
2297
- if (existing) existing.remove();
2298
- var modal = document.createElement('div');
2299
- modal.id = 'mc-modal';
2300
- modal.className = 'mc-modal';
2301
- 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();
2302
2449
  var panel = document.createElement('div');
2303
- panel.className = 'mc-modal-panel';
2304
- var head = document.createElement('div');
2305
- 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';
2306
2461
  var title = document.createElement('span');
2307
- 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
2308
2470
  var closeBtn = document.createElement('button');
2309
2471
  closeBtn.type = 'button';
2310
- closeBtn.className = 'mc-btn mc-ghost';
2472
+ closeBtn.className = 'dock-btn dock-close';
2473
+ closeBtn.setAttribute('data-i18n', 'merged.close');
2311
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
2312
2516
  var area = document.createElement('textarea');
2313
2517
  area.className = 'mc-modal-text';
2314
2518
  // NOT readOnly: a readOnly textarea hides the caret in Chromium, yet we need it VISIBLE so the user sees
2315
- // which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead — read-only in
2316
- // effect while the caret and selection stay fully interactive.
2519
+ // which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead.
2317
2520
  area.value = buildMergedText(kind);
2318
2521
  area.addEventListener('beforeinput', function (e) { e.preventDefault(); });
2319
- // Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret "Go to comment"
2320
- // + "Remove" for a single caret; "Remove" only for a drag/select-all (can't navigate to many at once).
2321
- // 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.
2322
2524
  area.addEventListener('keydown', function (e) {
2323
- // Opt/Alt + Arrow steps the caret to the next/previous comment block so you can move comment-to-comment
2324
- // and act on each with Opt+Enter, without hand-scrolling.
2325
2525
  if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
2326
2526
  e.preventDefault();
2327
2527
  e.stopPropagation();
@@ -2336,49 +2536,28 @@ function openMergedView(kind) {
2336
2536
  var cxy = mergedCaretXY(area);
2337
2537
  var x = cxy.x, y = cxy.below, flipTop = cxy.top;
2338
2538
  var rerender = function () {
2339
- 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; }
2340
2540
  area.value = buildMergedText(kind);
2341
2541
  };
2342
2542
  if (area.selectionStart !== area.selectionEnd || seqs.length > 1) {
2343
2543
  // Select-all / multi-comment: offer send-to-terminal (the whole merged text) FIRST, then remove-all.
2344
- // Can't "Go to comment" across many at once, so navigate is omitted here.
2345
2544
  var multi = [];
2346
- if (window.__monacoriTerminal && typeof window.__monacoriTerminal.isOpen === 'function' && window.__monacoriTerminal.isOpen()) {
2347
- multi.push({ label: t('merged.sendToTerminal'), onSelect: function () { var text = buildMergedText(kind); modal.remove(); window.__monacoriTerminal.enterSendMode(text); } });
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); } });
2348
2547
  }
2349
2548
  multi.push({ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } });
2350
2549
  showCustomDropdown(x, y, multi, flipTop);
2351
2550
  } else {
2352
2551
  var seq = seqs[0];
2353
2552
  showCustomDropdown(x, y, [
2354
- { label: t('dropdown.navigate'), onSelect: function () { modal.remove(); navigateToComment(seq); } },
2553
+ { label: t('dropdown.navigate'), onSelect: function () { dock.close(); navigateToComment(seq); } },
2355
2554
  { label: t('dropdown.remove'), onSelect: function () { deleteComment(seq); rerender(); } },
2356
2555
  ], flipTop);
2357
2556
  }
2358
2557
  });
2359
- closeBtn.addEventListener('click', function () { modal.remove(); });
2360
- // Send-to-terminal now lives in the Opt+Enter dropdown (select-all -> first item), not as a header button.
2361
- head.appendChild(title);
2362
- head.appendChild(closeBtn);
2363
- panel.appendChild(head);
2364
- panel.appendChild(area);
2365
- modal.appendChild(panel);
2366
- modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
2367
- modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
2368
- document.body.appendChild(modal);
2369
- // Focus the read-only text so the caret is visible and Opt+Arrow / Opt+Enter (incl. the send-to-terminal
2370
- // dropdown item) work. Electron async-restores focus to <body>, so retry briefly (same as the composer).
2371
- var modalFocusTarget = area;
2372
- var modalFocusTries = 0;
2373
- var tryFocusModal = function () {
2374
- if (!document.getElementById('mc-modal')) return true;
2375
- if (document.activeElement === modalFocusTarget) return true;
2376
- try { modalFocusTarget.focus(); modalFocusTarget.selectionStart = modalFocusTarget.selectionEnd = 0; } catch (e) {}
2377
- return document.activeElement === modalFocusTarget;
2378
- };
2379
- if (!tryFocusModal()) {
2380
- var modalFocusIv = setInterval(function () { if (tryFocusModal() || ++modalFocusTries > 12) clearInterval(modalFocusIv); }, 25);
2381
- }
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');
2382
2561
  }
2383
2562
 
2384
2563
  // Prompt memo (Cmd/Ctrl+Shift+N): one freeform Markdown scratchpad with a live split preview, persisted
@@ -2396,27 +2575,10 @@ function renderMemoMd(text) {
2396
2575
  return renderMarkdownBlocks(text).map(function (b) { return b.html; }).join('');
2397
2576
  }
2398
2577
  function openMemoView() {
2399
- var existing = document.getElementById('mc-memo');
2400
- if (existing) { existing.remove(); return; } // the shortcut toggles: a second press closes the memo
2401
- var modal = document.createElement('div');
2402
- modal.id = 'mc-memo';
2403
- modal.className = 'mc-modal';
2404
- var panel = document.createElement('div');
2405
- panel.className = 'mc-modal-panel mc-memo-panel';
2406
- var head = document.createElement('div');
2407
- head.className = 'mc-modal-head';
2408
- var title = document.createElement('span');
2409
- title.setAttribute('data-i18n', 'memo.title');
2410
- title.textContent = t('memo.title');
2411
- var closeBtn = document.createElement('button');
2412
- closeBtn.type = 'button';
2413
- closeBtn.className = 'mc-btn mc-ghost';
2414
- closeBtn.setAttribute('data-i18n', 'merged.close');
2415
- closeBtn.textContent = t('merged.close');
2416
- closeBtn.addEventListener('click', function () { modal.remove(); });
2417
-
2418
- var body = document.createElement('div');
2419
- 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';
2420
2582
  var area = document.createElement('textarea');
2421
2583
  area.className = 'mc-modal-text mc-memo-edit';
2422
2584
  area.spellcheck = false;
@@ -2430,45 +2592,25 @@ function openMemoView() {
2430
2592
  saveMemo(area.value);
2431
2593
  preview.innerHTML = renderMemoMd(area.value);
2432
2594
  });
2433
-
2434
- // Terminal send: hand the current draft to pane-pick mode (arrows choose the session, Enter sends). Shown
2435
- // only once a terminal pane exists; enterSendMode reopens the panel if it was closed.
2436
- 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).
2437
2597
  if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
2438
- sendBtn = document.createElement('button');
2598
+ var sendBtn = document.createElement('button');
2439
2599
  sendBtn.type = 'button';
2440
- sendBtn.className = 'mc-btn mc-send-term';
2600
+ sendBtn.className = 'dock-btn mc-send-term';
2441
2601
  sendBtn.setAttribute('data-i18n', 'merged.sendToTerminal');
2442
2602
  sendBtn.textContent = t('merged.sendToTerminal');
2443
2603
  sendBtn.addEventListener('click', function () {
2444
2604
  var text = area.value;
2445
- modal.remove();
2605
+ dock.close();
2446
2606
  window.__monacoriTerminal.enterSendMode(text);
2447
2607
  });
2608
+ dock.bar.insertBefore(sendBtn, dock.bar.querySelector('.dock-max'));
2448
2609
  }
2449
-
2450
- head.appendChild(title);
2451
- if (sendBtn) head.appendChild(sendBtn);
2452
- head.appendChild(closeBtn);
2453
- body.appendChild(area);
2454
- body.appendChild(preview);
2455
- panel.appendChild(head);
2456
- panel.appendChild(body);
2457
- modal.appendChild(panel);
2458
- modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
2459
- modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
2460
- document.body.appendChild(modal);
2461
- // Focus the editor; Electron async-restores focus to <body>, so retry briefly (same as the composer/merged view).
2462
- var memoFocusTries = 0;
2463
- var tryFocusMemo = function () {
2464
- if (!document.getElementById('mc-memo')) return true;
2465
- if (document.activeElement === area) return true;
2466
- try { area.focus(); } catch (e) {}
2467
- return document.activeElement === area;
2468
- };
2469
- if (!tryFocusMemo()) {
2470
- var memoFocusIv = setInterval(function () { if (tryFocusMemo() || ++memoFocusTries > 12) clearInterval(memoFocusIv); }, 25);
2471
- }
2610
+ memoBody.appendChild(area);
2611
+ memoBody.appendChild(preview);
2612
+ dock.body.appendChild(memoBody);
2613
+ focusDockField(area, '#mc-memo-panel');
2472
2614
  }
2473
2615
 
2474
2616
  document.addEventListener('click', function (event) {
@@ -2674,10 +2816,13 @@ refreshComments();
2674
2816
 
2675
2817
  function isOpen() { return !panel.classList.contains('hidden'); }
2676
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) {} }
2677
2821
  panel.classList.toggle('hidden', !open);
2678
2822
  document.body.classList.toggle('terminal-open', open);
2679
2823
  if (toggleBtn) toggleBtn.classList.toggle('is-active', open);
2680
2824
  try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch (e) {}
2825
+ if (typeof applyDockMaximized === 'function') applyDockMaximized(); // keep Cmd+Shift+' maximize in sync
2681
2826
  if (open) {
2682
2827
  if (panes.length === 0) makePane();
2683
2828
  requestAnimationFrame(function () { fitAll(); if (active) try { active.term.focus(); } catch (e) {} });
@@ -3053,8 +3198,19 @@ function restoreUiState() {
3053
3198
  // regions (diff container, sidebar trees, status, data) and re-run the bootstrap steps. The window never
3054
3199
  // reloads, so the integrated terminal's pty sessions (claude/codex) survive a watch refresh. Electron's
3055
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
+ }
3056
3211
  function applyDiffUpdate(u) {
3057
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
3058
3214
 
3059
3215
  // Remember what to restore after the swap (comments/viewed persist on their own; these don't).
3060
3216
  var sv = document.getElementById('source-viewer');
@@ -3067,6 +3223,19 @@ function applyDiffUpdate(u) {
3067
3223
  // open file's signature BEFORE fileSignatureByPath is rebuilt below.
3068
3224
  var prevOpenSig = openPath ? (fileSignatureByPath.get(openPath) || '') : '';
3069
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
+ }
3238
+
3070
3239
  // 1) Replace the visible regions straight from the payload (no full-HTML parse).
3071
3240
  if (container) container.innerHTML = u.diffContainer || '';
3072
3241
  var changesPanel = document.getElementById('changes-panel');
@@ -3096,6 +3265,10 @@ function applyDiffUpdate(u) {
3096
3265
  sourceLinks = Array.from(document.querySelectorAll('.source-link'));
3097
3266
 
3098
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 = {};
3099
3272
  bodyPromise = {};
3100
3273
  diffBootDone = false;
3101
3274
  sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
@@ -3108,6 +3281,24 @@ function applyDiffUpdate(u) {
3108
3281
  else { prepareDiff2HtmlHunks(); diffBootDone = true; }
3109
3282
  if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
3110
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
+
3111
3302
  // 4) Re-run the DOM-dependent bootstrap steps.
3112
3303
  applyI18n();
3113
3304
  populateHttpEnvSelect();