@happy-nut/monacori 0.1.20 → 0.1.22

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.
@@ -19,6 +19,27 @@ if (REVIEW_LAZY) {
19
19
  });
20
20
  }
21
21
  var diffBootDone = false;
22
+ // Rebuild the hunk index from the CURRENT diff DOM. `hunks`/`hunkPeers`/`hunkMeta` are captured once at
23
+ // init; after an in-place watch swap (applyDiffUpdate) the DOM holds new wrappers/rows, so without this
24
+ // hunkTotal()/hunkPathAt() keep reporting the OLD build — F7 and showDiffView then target vanished indices
25
+ // and the diff pane goes blank. Mutates the const arrays in place so existing references stay valid.
26
+ function refreshHunkIndex() {
27
+ if (REVIEW_LAZY) {
28
+ hunkMeta.length = 0;
29
+ Array.prototype.forEach.call(document.querySelectorAll('#diff2html-container .d2h-file-wrapper'), function (w) {
30
+ var base = parseInt(w.dataset.firstHunk || '0', 10) || 0;
31
+ var cnt = parseInt(w.dataset.hunkCount || '0', 10) || 0;
32
+ var p = w.dataset.path || ((w.querySelector('.d2h-file-name') || {}).textContent || '').trim();
33
+ for (var k = 0; k < cnt; k++) hunkMeta[base + k] = { path: p };
34
+ });
35
+ } else {
36
+ prepareDiff2HtmlHunks(); // (re)tag .hunk/.hunk-peer rows + file ids on the new DOM
37
+ hunks.length = 0;
38
+ Array.prototype.push.apply(hunks, document.querySelectorAll('.hunk'));
39
+ hunkPeers.length = 0;
40
+ Array.prototype.push.apply(hunkPeers, document.querySelectorAll('.hunk-peer'));
41
+ }
42
+ }
22
43
  function hunkTotal() { return REVIEW_LAZY ? hunkMeta.length : hunks.length; }
23
44
  function hunkPathAt(i) { return REVIEW_LAZY ? (hunkMeta[i] ? hunkMeta[i].path : '') : (hunks[i] ? hunks[i].dataset.file : ''); }
24
45
  function hunkRowAt(i) {
@@ -98,14 +119,19 @@ function whenFileReady(wrapper, cb) {
98
119
  if (bodyPromise[idx]) { bodyPromise[idx].then(function () { cb(); }); return; }
99
120
  cb();
100
121
  }
122
+ var lazyIO = null; // remembered so each setupLazyDiff (re-run on every watch refresh) disconnects the prior
123
+ // observer instead of leaving a new one bound to detached wrappers — otherwise observers
124
+ // (and the old DOM they retain) pile up over a long-running session and slowly choke it.
101
125
  function setupLazyDiff() {
102
126
  var container = document.getElementById('diff2html-container');
103
127
  if (!container) return;
128
+ if (lazyIO) { try { lazyIO.disconnect(); } catch (e) {} lazyIO = null; }
104
129
  var wrappers = Array.prototype.slice.call(container.querySelectorAll('.d2h-file-wrapper'));
105
130
  if (typeof IntersectionObserver !== 'undefined') {
106
131
  var io = new IntersectionObserver(function (entries) {
107
132
  entries.forEach(function (e) { if (e.isIntersecting) { ensureFileReady(e.target); io.unobserve(e.target); } });
108
133
  }, { root: null, rootMargin: '600px 0px' });
134
+ lazyIO = io; // track this observer so the NEXT setupLazyDiff can disconnect it (callback keeps using local io)
109
135
  wrappers.forEach(function (w) { io.observe(w); });
110
136
  } else {
111
137
  wrappers.forEach(function (w) { ensureFileReady(w); }); // no IntersectionObserver -> materialize all
@@ -251,6 +277,7 @@ const quickOpen = document.getElementById('quick-open');
251
277
  const quickInput = document.getElementById('quick-open-input');
252
278
  const quickResults = document.getElementById('quick-open-results');
253
279
  const quickModeLabel = document.getElementById('quick-open-mode');
280
+ const quickFilterEl = document.getElementById('quick-open-filter');
254
281
  let current = -1;
255
282
  let checkingForUpdates = false;
256
283
  let lastShiftAt = 0;
@@ -258,6 +285,7 @@ let lastShiftSide = 0;
258
285
  let quickMode = 'all';
259
286
  let quickItems = [];
260
287
  let quickActive = 0;
288
+ let recentFilter = ''; // IntelliJ-style speed-search: typed letters narrow the Recent list (no search box)
261
289
  let usageItems = []; // find-usages results for the Cmd+B-on-declaration popup
262
290
  let usageActive = 0;
263
291
  let viewerCursor = null;
@@ -508,7 +536,7 @@ function revealAt(el, scroller, fraction) {
508
536
  }
509
537
  // Scrolloff variant: scroll ONLY when `el` would otherwise leave the viewport, keeping it within `marginFrac`
510
538
  // 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.
539
+ // centering scrolled the file even when everything was visible (dizzying). Used by the diff caret and the sidebar tree.
512
540
  function scrolloffReveal(el, scroller, marginFrac) {
513
541
  if (!el || !scroller || !scroller.clientHeight) return;
514
542
  var top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
@@ -655,14 +683,19 @@ function next(delta) {
655
683
  // File boundary: no more change blocks in this file. Forward F7 announces "last change — press F7 again
656
684
  // to go to the next file" on the FIRST press (a beat to mark-viewed) and only crosses on the SECOND
657
685
  // consecutive press. Already-viewed files (and backward nav) cross immediately — no announcement.
658
- if (delta > 0 && diffCursor && isDiffViewVisible() && !isFileViewed(diffCursor.path)) {
686
+ // The `hunkPathAt(current) === diffCursor.path` guard skips the announcement while a cross is still in
687
+ // flight: after setActive moves `current` to the next file but BEFORE its (async, lazy-loaded) caret lands,
688
+ // diffCursor still points at the OLD file — without the guard a quick second F7 re-announced that old
689
+ // boundary instead of letting the cross finish (the "press F7 twice more, no caret" bug).
690
+ if (delta > 0 && diffCursor && isDiffViewVisible() && !isFileViewed(diffCursor.path) && hunkPathAt(current) === diffCursor.path) {
659
691
  if (pendingFileBoundary !== diffCursor.path) {
660
692
  pendingFileBoundary = diffCursor.path;
661
- showToast(t('diff.lastHunk'));
693
+ showCaretHint(t('diff.lastHunk'));
662
694
  return;
663
695
  }
664
696
  pendingFileBoundary = null; // second consecutive press on the same file → fall through and cross
665
697
  }
698
+ hideCaretHint(); // about to cross files — drop the hint NOW (before the async body load) so it can't cover the next file
666
699
  // hunk-level nav to the next/prev unviewed file.
667
700
  const caretHunk = hunkIndexAtCaret();
668
701
  const base = caretHunk >= 0 ? caretHunk : current;
@@ -675,6 +708,22 @@ function next(delta) {
675
708
  // Every changed file is marked viewed — nothing left to review, so F7/[/] stay put.
676
709
  }
677
710
 
711
+ // Jump to the first change of the next unviewed file after `path` (wrapping). Used right after marking a
712
+ // file viewed: its diff body is now hidden, so staying would blank the content — we advance to the next
713
+ // change instead. Returns false when every changed file is viewed (nothing to advance to).
714
+ function gotoNextUnviewedFile(path) {
715
+ const total = hunkTotal();
716
+ if (total === 0) return false;
717
+ const start = firstHunkForPath(path);
718
+ let idx = (start >= 0 ? start : (current >= 0 ? current : 0)) + 1;
719
+ for (let step = 0; step < total; step++) {
720
+ const norm = ((idx % total) + total) % total;
721
+ if (!isFileViewed(hunkPathAt(norm) || '')) { setActive(norm); return true; }
722
+ idx += 1;
723
+ }
724
+ return false;
725
+ }
726
+
678
727
  function initialHunkForNavigation(delta) {
679
728
  const openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
680
729
  const sourceHunk = firstHunkForPath(openPath);
@@ -695,9 +744,22 @@ function openQuickOpen(mode) {
695
744
  quickMode = mode;
696
745
  quickModeLabel.textContent = mode === 'recent' ? t('quickopen.recent') : mode === 'content' ? t('quickopen.findInFiles') : t('quickopen.searchFiles');
697
746
  quickOpen.classList.remove('hidden');
747
+ // Recent files needs no search box — it's just the latest files. Hide the input and let typed letters
748
+ // narrow the list (IntelliJ-style speed search); the global keydown routes keys to handleQuickOpenKey.
749
+ quickOpen.classList.toggle('quick-recent', mode === 'recent');
750
+ recentFilter = '';
698
751
  quickInput.value = '';
752
+ updateRecentFilterDisplay();
699
753
  renderQuickOpenResults();
700
- setTimeout(() => quickInput.focus(), 0);
754
+ if (mode === 'recent') { if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); }
755
+ else setTimeout(() => quickInput.focus(), 0);
756
+ }
757
+ // Title-row indicator for the Recent speed-search: the typed letters, or a muted "type to filter" hint.
758
+ function updateRecentFilterDisplay() {
759
+ if (!quickFilterEl) return;
760
+ if (quickMode !== 'recent') { quickFilterEl.textContent = ''; quickFilterEl.className = 'quick-open-filter'; return; }
761
+ if (recentFilter) { quickFilterEl.textContent = recentFilter; quickFilterEl.className = 'quick-open-filter has-filter'; }
762
+ else { quickFilterEl.textContent = t('quickopen.typeToFilter'); quickFilterEl.className = 'quick-open-filter is-hint'; }
701
763
  }
702
764
 
703
765
  function closeQuickOpen() {
@@ -707,6 +769,8 @@ function closeQuickOpen() {
707
769
  function handleQuickOpenKey(event) {
708
770
  if (event.key === 'Escape') {
709
771
  event.preventDefault();
772
+ // Recent speed-search: first Esc clears the typed filter, a second Esc closes (IntelliJ behavior).
773
+ if (quickMode === 'recent' && recentFilter) { recentFilter = ''; updateRecentFilterDisplay(); renderQuickOpenResults(); return true; }
710
774
  closeQuickOpen();
711
775
  return true;
712
776
  }
@@ -727,15 +791,31 @@ function handleQuickOpenKey(event) {
727
791
  openQuickItem(quickItems[quickActive]);
728
792
  return true;
729
793
  }
794
+ // Recent files has no input box: type letters to filter the list, Backspace to delete (speed search).
795
+ if (quickMode === 'recent') {
796
+ if (event.key === 'Backspace') {
797
+ event.preventDefault();
798
+ if (recentFilter) { recentFilter = recentFilter.slice(0, -1); updateRecentFilterDisplay(); renderQuickOpenResults(); }
799
+ return true;
800
+ }
801
+ if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
802
+ event.preventDefault();
803
+ recentFilter += event.key;
804
+ updateRecentFilterDisplay();
805
+ renderQuickOpenResults();
806
+ return true;
807
+ }
808
+ }
730
809
  return false;
731
810
  }
732
811
 
733
812
  function renderQuickOpenResults() {
734
813
  if (!quickResults) return;
735
- const query = quickInput?.value.trim().toLowerCase() || '';
736
- const candidates = quickMode === 'recent' && query.length === 0 ? recentItems() : allQuickItems();
814
+ // Recent mode filters its own list by the typed speed-search string; other modes use the search box.
815
+ const isRecent = quickMode === 'recent';
816
+ const query = (isRecent ? recentFilter : (quickInput?.value || '')).trim().toLowerCase();
817
+ const candidates = isRecent ? recentItems() : allQuickItems();
737
818
  quickItems = candidates
738
- .filter((item) => quickMode !== 'recent' || query.length > 0 || item.recent)
739
819
  .filter((item) => {
740
820
  if (query.length === 0) return true;
741
821
  if (quickMode === 'content') {
@@ -916,8 +996,10 @@ function focusTree(index) {
916
996
  if (rows.length === 0) return;
917
997
  treeFocusIndex = Math.max(0, Math.min(rows.length - 1, index));
918
998
  // 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.
999
+ // frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump. Coalescing
1000
+ // both keeps focus + scroll in lockstep, and scrolloffReveal scrolls ONLY when the focused row nears the
1001
+ // top/bottom edge — a row moving inside the visible band must never drag the whole panel (revealAt did,
1002
+ // re-centering on every move so even a mid-list row scrolled the sidebar).
921
1003
  scheduleTreeFocus();
922
1004
  }
923
1005
  var treeFocusRaf = 0;
@@ -929,7 +1011,7 @@ function scheduleTreeFocus() {
929
1011
  if (treeFocusIndex < 0 || treeFocusIndex >= rows.length) return;
930
1012
  const el = rows[treeFocusIndex];
931
1013
  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); }
1014
+ if (el) { el.classList.add('tree-focus'); scrolloffReveal(el, document.querySelector('.sidebar-scroll'), 0.15); }
933
1015
  });
934
1016
  }
935
1017
 
@@ -1053,9 +1135,11 @@ function handleTreeKey(event) {
1053
1135
  // owns focus AND the only caret, so global shortcuts stand down until Esc/close — we must not navigate a
1054
1136
  // panel the user can't even see behind the overlay (nor leave a second blinking caret in it).
1055
1137
  function isFloatingModalOpen() {
1056
- if (document.getElementById('mc-modal') || document.getElementById('mc-memo')) return true;
1057
1138
  var sm = document.getElementById('settings-modal');
1058
- return !!(sm && !sm.classList.contains('hidden'));
1139
+ if (sm && !sm.classList.contains('hidden')) return true;
1140
+ // The merged/memo panels are now docked (inline), not overlays — but while one OWNS focus we still stand
1141
+ // down the global nav shortcuts so typing / ▲▼ inside it isn't hijacked. Focus elsewhere -> shortcuts run.
1142
+ return isDockFocused();
1059
1143
  }
1060
1144
  document.addEventListener('keydown', (event) => {
1061
1145
  if (!quickOpen?.classList.contains('hidden')) {
@@ -1066,12 +1150,32 @@ document.addEventListener('keydown', (event) => {
1066
1150
  if (handleUsagesKey(event)) return;
1067
1151
  }
1068
1152
 
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.
1153
+ // Dock controls fire regardless of focus (terminal / merged / memo) they sit ABOVE the focus guard so
1154
+ // they still work from inside a dock panel. Cmd/Ctrl+Shift+' maximizes the active dock; Cmd/Ctrl+Shift+/
1155
+ // and +. open the merged views; Cmd/Ctrl+Shift+N toggles the memo. (Match event.code so IME/layout never
1156
+ // swallows the combo.) Settings is a true overlay, so these stand down while it is up.
1157
+ var settingsUp = (function () { var s = document.getElementById('settings-modal'); return !!(s && !s.classList.contains('hidden')); })();
1158
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.code === 'Quote') {
1159
+ event.preventDefault();
1160
+ toggleDockMaximized();
1161
+ return;
1162
+ }
1163
+ if (!settingsUp && (event.metaKey || event.ctrlKey) && !event.altKey && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
1164
+ event.preventDefault();
1165
+ openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
1166
+ return;
1167
+ }
1168
+ if (!settingsUp && (event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
1169
+ event.preventDefault();
1170
+ openMemoView();
1171
+ return;
1172
+ }
1173
+
1174
+ // Settings overlay (or a focused merged/memo dock) captures keys: stand down the rest of the global
1175
+ // shortcuts (Cmd+1, F7, Cmd+[/], Cmd+B, …). Each has its own Esc + editing handlers.
1072
1176
  if (isFloatingModalOpen()) return;
1073
1177
 
1074
- if ((event.metaKey || event.ctrlKey) && event.key === '1') {
1178
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key === '1') {
1075
1179
  event.preventDefault();
1076
1180
  // Coming from the diff: open the file you were viewing as source so Cmd+1 lands ON it (not a stale/blank
1077
1181
  // source pane), and the tree below points at the same file. Capture the path BEFORE openSourceFile flips
@@ -1086,7 +1190,7 @@ document.addEventListener('keydown', (event) => {
1086
1190
  focusOpenFileInTree();
1087
1191
  return;
1088
1192
  }
1089
- if ((event.metaKey || event.ctrlKey) && event.key === '0') {
1193
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key === '0') {
1090
1194
  event.preventDefault();
1091
1195
  setTab('changes');
1092
1196
  focusOpenFileInTree();
@@ -1121,21 +1225,8 @@ document.addEventListener('keydown', (event) => {
1121
1225
  }
1122
1226
  }
1123
1227
 
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
- }
1228
+ // (Merged views Cmd/Ctrl+Shift+/ +. and the memo Cmd/Ctrl+Shift+N are handled above the focus guard so
1229
+ // they work from inside a dock too.)
1139
1230
  // "?" = question, ">" = change-request composer on the current line/selection (no modifier).
1140
1231
  if (!event.altKey && !event.metaKey && !event.ctrlKey && (event.key === '?' || event.key === '>')) {
1141
1232
  const ce = document.activeElement;
@@ -1160,7 +1251,12 @@ document.addEventListener('keydown', (event) => {
1160
1251
  }
1161
1252
  if (vp && currentFileSignature(vp)) {
1162
1253
  event.preventDefault();
1163
- setFileViewed(vp, !isFileViewed(vp));
1254
+ const willView = !isFileViewed(vp);
1255
+ setFileViewed(vp, willView);
1256
+ // Marking viewed hides this file's diff body — don't strand the caret on the now-blank file.
1257
+ // Auto-advance to the next unviewed change (the user's flow: mark viewed -> jump to next).
1258
+ // Unmarking stays put. If every file is viewed, gotoNextUnviewedFile is a no-op.
1259
+ if (willView) gotoNextUnviewedFile(vp);
1164
1260
  return;
1165
1261
  }
1166
1262
  }
@@ -1180,10 +1276,14 @@ document.addEventListener('keydown', (event) => {
1180
1276
  // PageUp/Down scroll the diff/source view. There's no focusable scroller (the diff caret is a JS cursor),
1181
1277
  // and d2h-file-side-diff's horizontal scrollport even swallows vertical wheel, so handle paging explicitly.
1182
1278
  // Only when the tree isn't focused — the tree pages itself in handleTreeKey below.
1183
- if (treeFocusIndex < 0 && (event.key === 'PageDown' || event.key === 'PageUp') && !event.metaKey && !event.ctrlKey && !event.altKey) {
1279
+ if (treeFocusIndex < 0 && (event.key === 'PageDown' || event.key === 'PageUp') && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) {
1184
1280
  var psc = isDiffViewVisible() ? document.getElementById('diff2html-container') : (isSourceViewerVisible() ? document.getElementById('source-body') : null);
1185
1281
  if (psc) { event.preventDefault(); psc.scrollTop += (event.key === 'PageDown' ? 0.9 : -0.9) * psc.clientHeight; return; }
1186
1282
  }
1283
+ // A non-Shift keystroke between the two Shifts cancels the pending double-Shift quick-open. Without this,
1284
+ // "Shift → type something → Shift" within 300ms still popped the search, so it fired on nearly every other
1285
+ // keystroke. Reset BEFORE the caret handlers below (they swallow arrows) so arrow keys break it too.
1286
+ if (event.key !== 'Shift') { lastShiftAt = 0; lastShiftSide = 0; }
1187
1287
  if (treeFocusIndex >= 0 && handleTreeKey(event)) return;
1188
1288
  if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isSourceViewerVisible() && handleSourceCaretKey(event)) return;
1189
1289
  if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isDiffViewVisible() && handleDiffCaretKey(event)) return;
@@ -1207,12 +1307,12 @@ document.addEventListener('keydown', (event) => {
1207
1307
  lastShiftSide = side;
1208
1308
  }
1209
1309
 
1210
- if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'f') {
1310
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === 'f') {
1211
1311
  event.preventDefault();
1212
1312
  openQuickOpen('content');
1213
1313
  return;
1214
1314
  }
1215
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'e') {
1315
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'e') {
1216
1316
  event.preventDefault();
1217
1317
  openQuickOpen('recent');
1218
1318
  return;
@@ -1227,14 +1327,14 @@ document.addEventListener('keydown', (event) => {
1227
1327
  }
1228
1328
  }
1229
1329
 
1230
- if ((event.metaKey || event.ctrlKey) && event.key === 'ArrowDown') {
1330
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key === 'ArrowDown') {
1231
1331
  event.preventDefault();
1232
1332
  if (isSourceViewerVisible()) goToSymbolUnderCursor();
1233
1333
  else openDiffFileAtCaret();
1234
1334
  return;
1235
1335
  }
1236
1336
 
1237
- if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
1337
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && (event.key === 'b' || event.key === 'B')) {
1238
1338
  var aeB = document.activeElement;
1239
1339
  if (aeB && (aeB.tagName === 'INPUT' || aeB.tagName === 'TEXTAREA' || aeB.tagName === 'SELECT')) return;
1240
1340
  event.preventDefault();
@@ -1296,7 +1396,7 @@ document.addEventListener('keydown', (event) => {
1296
1396
  }
1297
1397
  }
1298
1398
 
1299
- if (event.key === 'F7') {
1399
+ if (event.key === 'F7' && !event.metaKey && !event.ctrlKey && !event.altKey) {
1300
1400
  event.preventDefault();
1301
1401
  const delta = event.shiftKey ? -1 : 1;
1302
1402
  const sourceViewer = document.getElementById('source-viewer');
@@ -1304,8 +1404,12 @@ document.addEventListener('keydown', (event) => {
1304
1404
  // where they were reading. Shift+F7 — and any file with no hunk of its own — falls through to plain
1305
1405
  // prev/next-change navigation across the whole diff.
1306
1406
  if (delta > 0 && sourceViewer && !sourceViewer.classList.contains('hidden')) {
1307
- const sourceHunk = firstHunkForPath(sourceViewer.dataset.openPath || '');
1308
- if (sourceHunk >= 0) {
1407
+ const sp = sourceViewer.dataset.openPath || '';
1408
+ const sourceHunk = firstHunkForPath(sp);
1409
+ // Enter the diff at the open file's own hunk — UNLESS it's already viewed. A viewed file's diff body
1410
+ // is hidden (display:none), so landing on it blanks the content and F7 appears stuck; fall through to
1411
+ // next() instead so we skip to an unviewed change.
1412
+ if (sourceHunk >= 0 && !isFileViewed(sp)) {
1309
1413
  setActive(sourceHunk);
1310
1414
  return;
1311
1415
  }
@@ -1369,6 +1473,19 @@ document.querySelectorAll('.tab').forEach((button) => {
1369
1473
  button.addEventListener('click', () => setTab(button.dataset.tab || 'changes'));
1370
1474
  });
1371
1475
 
1476
+ // Activity rail (IntelliJ-style): click an icon to navigate/toggle its view. Terminal + settings buttons
1477
+ // carry no data-view — they keep their own id-based handlers (terminal toggle / settings gear).
1478
+ document.querySelector('.activity-rail')?.addEventListener('click', (event) => {
1479
+ const btn = event.target.closest && event.target.closest('.rail-btn[data-view]');
1480
+ if (!btn) return;
1481
+ const view = btn.dataset.view;
1482
+ if (view === 'changes') { setTab('changes'); if (!isDiffViewVisible()) showDiffView(false); }
1483
+ else if (view === 'files') { setTab('files'); }
1484
+ else if (view === 'q' || view === 'c') { toggleMergedRail(view); }
1485
+ else if (view === 'memo') { openMemoView(); } // openMemoView already toggles
1486
+ syncRail();
1487
+ });
1488
+
1372
1489
  document.getElementById('back-to-diff')?.addEventListener('click', () => showDiffView(true));
1373
1490
  document.getElementById('source-tabs')?.addEventListener('click', function (event) {
1374
1491
  var closeBtn = event.target && event.target.closest && event.target.closest('.source-tab-close');
@@ -1405,7 +1522,12 @@ if (!restored) {
1405
1522
  else openDefaultSourceFile();
1406
1523
  }
1407
1524
  initSourceTreeFolds();
1408
- if (watchEnabled) setInterval(checkForLiveUpdate, 1500);
1525
+ syncRail(); // reflect the initial view on the activity rail
1526
+ // Electron receives live updates over IPC (monacoriMenu.onDiffUpdate); only serve/browser needs the HTTP
1527
+ // poller. Under file:// its fetch just fails every 1.5s for the app's whole life, so skip it in Electron.
1528
+ if (watchEnabled && !(window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function')) {
1529
+ setInterval(checkForLiveUpdate, 1500);
1530
+ }
1409
1531
  window.addEventListener('beforeunload', saveUiState);
1410
1532
 
1411
1533
  // First render has painted — drop the boot overlay (it bridged the blank gap right after loadFile). Two
@@ -1436,7 +1558,10 @@ window.addEventListener('beforeunload', saveUiState);
1436
1558
  });
1437
1559
  document.addEventListener('mousemove', (event) => {
1438
1560
  if (!resizing) return;
1439
- const width = Math.min(640, Math.max(180, event.clientX));
1561
+ // Subtract the activity rail's width: the sidebar starts to its right, so its width is the cursor X
1562
+ // minus the rail offset (not clientX itself, which would over-size it by the rail width).
1563
+ const railW = parseFloat(getComputedStyle(document.body).getPropertyValue('--rail-width')) || 0;
1564
+ const width = Math.min(640, Math.max(180, event.clientX - railW));
1440
1565
  document.documentElement.style.setProperty('--sidebar-width', width + 'px');
1441
1566
  });
1442
1567
  document.addEventListener('mouseup', () => {
@@ -1622,6 +1747,7 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
1622
1747
  var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
1623
1748
  diffCursor = { path: path, side: side, rowIndex: ri, column: col };
1624
1749
  pendingFileBoundary = null; // any caret move re-arms the last-change announcement for the next F7 (see next)
1750
+ hideCaretHint(); // caret moved (incl. crossing to the next file) → drop the "last change" hint so it never covers the new file
1625
1751
  diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
1626
1752
  if (reveal) {
1627
1753
  // Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
@@ -1793,11 +1919,51 @@ function moveDiffWord(dir, extend) {
1793
1919
  setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, ncol, true);
1794
1920
  if (anchor) { diffSelectionAnchor = anchor; applyDiffSelection(); }
1795
1921
  }
1922
+ // Comment boxes are injected on the right(new) side, right after the line's row (see injectThreadRow /
1923
+ // renderDiffComments). Split-view rows align 1:1 by index, so the caret's row index on the new side finds
1924
+ // the adjacent box regardless of which side the caret sits on. Mirrors commentRowSiblingOf for the source view.
1925
+ function diffCommentBoxSiblingOf(dir) {
1926
+ if (!diffCursor) return null;
1927
+ var wrapper = diffWrapperByPath(diffCursor.path);
1928
+ if (!wrapper) return null;
1929
+ var rows = diffRowsOf(diffSideTable(wrapper, 'new'));
1930
+ var row = rows[diffCursor.rowIndex];
1931
+ if (!row) return null;
1932
+ var sib = dir < 0 ? row.previousElementSibling : row.nextElementSibling;
1933
+ return (sib && sib.classList && sib.classList.contains('mc-comment-row')) ? sib : null;
1934
+ }
1796
1935
  function handleDiffCaretKey(event) {
1797
1936
  if (!isDiffViewVisible() || !diffCursor) return false;
1798
1937
  var ae = document.activeElement;
1799
1938
  if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return false;
1800
1939
  var extend = event.shiftKey;
1940
+ // A comment box is selected: Backspace/Delete removes it, `e` edits it, an arrow/Escape steps off it.
1941
+ // Same contract as the source view (handleSourceCaretKey), but caret moves go through setDiffCursor.
1942
+ if (selectedCommentRow) {
1943
+ if (event.key === 'Backspace' || event.key === 'Delete') { event.preventDefault(); deleteCommentsInRow(selectedCommentRow); return true; }
1944
+ if (event.key === 'e' || event.key === 'E') { event.preventDefault(); editCommentInRow(selectedCommentRow); return true; }
1945
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Escape') {
1946
+ var dir = event.key === 'ArrowUp' ? -1 : (event.key === 'ArrowDown' ? 1 : 0);
1947
+ var sib = dir < 0 ? selectedCommentRow.previousElementSibling : (dir > 0 ? selectedCommentRow.nextElementSibling : null);
1948
+ selectedCommentRow.classList.remove('mc-row-selected');
1949
+ selectedCommentRow = null;
1950
+ event.preventDefault();
1951
+ var wrapper = diffWrapperByPath(diffCursor.path);
1952
+ if (sib && wrapper && isDiffCodeRow(sib)) {
1953
+ var rows = diffRowsOf(diffSideTable(wrapper, 'new'));
1954
+ var idx = rows.indexOf(sib);
1955
+ if (idx >= 0) { setDiffCursor(diffCursor.path, 'new', idx, 0, true); return true; }
1956
+ }
1957
+ setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, diffCursor.column, false); // restore caret where it was
1958
+ return true;
1959
+ }
1960
+ return false;
1961
+ }
1962
+ // Plain Up/Down: a comment box attached to the caret line is a selectable stop (caret stays visible).
1963
+ if (!extend && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
1964
+ var box = diffCommentBoxSiblingOf(event.key === 'ArrowUp' ? -1 : 1);
1965
+ if (box) { event.preventDefault(); selectCommentRow(box); return true; }
1966
+ }
1801
1967
  if (event.key === 'ArrowDown') { event.preventDefault(); moveDiffCursor(1, 0, extend); return true; }
1802
1968
  if (event.key === 'ArrowUp') { event.preventDefault(); moveDiffCursor(-1, 0, extend); return true; }
1803
1969
  if (event.key === 'ArrowLeft') { event.preventDefault(); moveDiffCursor(0, -1, extend); return true; }
@@ -1822,6 +1988,28 @@ function showToast(message) {
1822
1988
  setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
1823
1989
  }, 4500);
1824
1990
  }
1991
+ // Inline hint anchored just under the diff caret — used for the F7 "last change" boundary announcement so the
1992
+ // message appears where the user is looking and fades on its own (unlike the corner toast). Falls back to the
1993
+ // corner toast when there's no on-screen caret (e.g. source view).
1994
+ var caretHintEl = null, caretHintTimer = 0;
1995
+ function showCaretHint(message) {
1996
+ var row = activeDiffRow || document.querySelector('#diff2html-container .diff-active-row');
1997
+ if (!row || !row.getBoundingClientRect) { showToast(message); return; }
1998
+ if (!caretHintEl) { caretHintEl = document.createElement('div'); caretHintEl.className = 'mc-caret-hint'; document.body.appendChild(caretHintEl); }
1999
+ caretHintEl.textContent = message;
2000
+ var r = row.getBoundingClientRect();
2001
+ caretHintEl.style.left = Math.round(Math.max(8, r.left)) + 'px';
2002
+ caretHintEl.style.top = Math.round(r.bottom + 4) + 'px';
2003
+ caretHintEl.classList.remove('show');
2004
+ void caretHintEl.offsetWidth; // reflow so the fade-in re-triggers on rapid repeat presses
2005
+ caretHintEl.classList.add('show');
2006
+ if (caretHintTimer) clearTimeout(caretHintTimer);
2007
+ caretHintTimer = setTimeout(function () { if (caretHintEl) caretHintEl.classList.remove('show'); }, 2000);
2008
+ }
2009
+ function hideCaretHint() {
2010
+ if (caretHintTimer) { clearTimeout(caretHintTimer); caretHintTimer = 0; }
2011
+ if (caretHintEl) caretHintEl.classList.remove('show');
2012
+ }
1825
2013
  // Follow each comment to its snapshot line (c.code) in the current content: same line if unchanged, else the
1826
2014
  // nearest exact match of that line. A comment is NEVER auto-deleted. If its line can't be found we leave it
1827
2015
  // where it is — this happens routinely WITHOUT the file changing: a comment anchored to a deleted/old-side
@@ -2127,6 +2315,7 @@ function closeComposer() {
2127
2315
  if (!composerState) return;
2128
2316
  composerState = null;
2129
2317
  refreshComments();
2318
+ flushPendingDiffUpdate(); // apply any live watch refresh that was held while composing
2130
2319
  }
2131
2320
  // The composer is injected into BOTH the diff and source views (refreshComments renders comments in
2132
2321
  // each), but only one view is on screen at a time — the other lives inside a `.hidden` container with
@@ -2151,6 +2340,7 @@ function saveComposer(ta) {
2151
2340
  else addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
2152
2341
  composerState = null;
2153
2342
  refreshComments();
2343
+ flushPendingDiffUpdate(); // apply any live watch refresh that was held while composing
2154
2344
  }
2155
2345
 
2156
2346
  // Default merge-prompt headings, localized: a Korean user gets Korean defaults. Editable in
@@ -2292,36 +2482,153 @@ function buildMergedText(kind) {
2292
2482
  return lines.join(nl);
2293
2483
  }
2294
2484
 
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
2485
+ // ===== Bottom dock: merged-prompt / memo / terminal share ONE docked slot below the editor =====
2486
+ // Only one is visible at a time — opening one closes the others (the terminal included). Cmd/Ctrl+Shift+'
2487
+ // maximizes the active dock over the editor area (the sidebar stays). A top resizer drags the height.
2488
+ var dockHeightKey = 'monacori-dock-height';
2489
+ var dockMaximized = false;
2490
+ function applyDockHeight(px) {
2491
+ var h = Math.max(140, Math.min(px, window.innerHeight - 120));
2492
+ document.documentElement.style.setProperty('--dock-height', h + 'px');
2493
+ }
2494
+ (function () { var s = parseInt(localStorage.getItem(dockHeightKey) || '', 10); if (s) applyDockHeight(s); })();
2495
+ // The dock panel currently filling the slot: a merged/memo panel, else the terminal when it's open.
2496
+ function activeDockPanel() {
2497
+ var mm = document.getElementById('mc-merged-panel') || document.getElementById('mc-memo-panel');
2498
+ if (mm) return mm;
2499
+ var term = document.getElementById('terminal-panel');
2500
+ return (term && !term.classList.contains('hidden')) ? term : null;
2501
+ }
2502
+ function applyDockMaximized() {
2503
+ if (!activeDockPanel()) dockMaximized = false; // nothing docked -> can't stay maximized
2504
+ document.body.classList.toggle('dock-maximized', dockMaximized);
2505
+ }
2506
+ function toggleDockMaximized() {
2507
+ // Maximize only the panel you're FOCUSED in: the merged/memo dock (.dock-panel) or the terminal
2508
+ // (.terminal-panel). From the sidebar tree (treeFocusIndex >= 0) or the diff/source content this is a
2509
+ // no-op — pressing it there must NOT maximize a terminal you aren't actually in.
2510
+ if (treeFocusIndex >= 0) return;
2511
+ var ae = document.activeElement;
2512
+ if (!(ae && ae.closest && (ae.closest('.dock-panel') || ae.closest('.terminal-panel')))) return;
2513
+ if (!activeDockPanel()) return; // nothing docked -> nothing to maximize
2514
+ dockMaximized = !dockMaximized;
2515
+ applyDockMaximized();
2516
+ }
2517
+ function isDockFocused() {
2518
+ var ae = document.activeElement;
2519
+ return !!(ae && ae.closest && ae.closest('.dock-panel'));
2520
+ }
2521
+ // Close the merged/memo docks (the terminal's setOpen also calls this so the slot stays exclusive).
2522
+ function closeMergedMemoDocks() {
2523
+ var m = document.getElementById('mc-merged-panel'); if (m) m.remove();
2524
+ var n = document.getElementById('mc-memo-panel'); if (n) n.remove();
2525
+ document.querySelectorAll('.dock-backdrop').forEach(function (b) { b.remove(); });
2526
+ document.body.classList.toggle('dock-open', !!activeDockPanel());
2527
+ // floating-dock tracks merged/memo only (NOT the terminal) so the maximize CSS hides content for a
2528
+ // terminal dock but never for these floating panels.
2529
+ document.body.classList.toggle('floating-dock', !!(document.getElementById('mc-merged-panel') || document.getElementById('mc-memo-panel')));
2530
+ applyDockMaximized();
2531
+ if (typeof syncRail === 'function') syncRail(); // clear the rail icon for the closed dock(s)
2532
+ }
2533
+ window.__monacoriCloseDocks = closeMergedMemoDocks;
2534
+ // Retry-focus a docked field (Electron async-restores focus to <body>, so a one-shot focus can lose the race).
2535
+ function focusDockField(field, panelSel) {
2536
+ var tries = 0;
2537
+ var tryF = function () {
2538
+ if (!document.querySelector(panelSel)) return true;
2539
+ if (document.activeElement === field) return true;
2540
+ try { field.focus(); } catch (e) {}
2541
+ return document.activeElement === field;
2542
+ };
2543
+ if (!tryF()) { var iv = setInterval(function () { if (tryF() || ++tries > 12) clearInterval(iv); }, 25); }
2544
+ }
2545
+ // Build a docked panel shell (resizer + bar with Maximize/Close + body) and mount it below the editor.
2546
+ // Opening it closes the terminal and any other merged/memo dock (the slot is exclusive). Returns
2547
+ // { panel, body, bar, close }.
2548
+ function mountDock(id, titleText) {
2549
+ if (window.__monacoriTerminal && typeof window.__monacoriTerminal.close === 'function') {
2550
+ try { window.__monacoriTerminal.close(); } catch (e) {}
2551
+ }
2552
+ var prior = document.getElementById(id);
2553
+ if (prior) prior.remove();
2554
+ closeMergedMemoDocks();
2302
2555
  var panel = document.createElement('div');
2303
- panel.className = 'mc-modal-panel';
2304
- var head = document.createElement('div');
2305
- head.className = 'mc-modal-head';
2556
+ panel.id = id;
2557
+ panel.className = 'dock-panel';
2558
+ panel.tabIndex = -1;
2559
+ // The panel floats over the editor; a dim backdrop sits behind it (click to dismiss).
2560
+ var backdrop = document.createElement('div');
2561
+ backdrop.className = 'dock-backdrop';
2562
+ var resizer = document.createElement('div');
2563
+ resizer.className = 'dock-resizer';
2564
+ resizer.setAttribute('aria-hidden', 'true');
2565
+ var bar = document.createElement('div');
2566
+ bar.className = 'dock-bar';
2306
2567
  var title = document.createElement('span');
2307
- title.textContent = kind === 'q' ? t('merged.qTitle') : t('merged.cTitle');
2568
+ title.className = 'dock-title';
2569
+ title.textContent = titleText;
2570
+ var maxBtn = document.createElement('button');
2571
+ maxBtn.type = 'button';
2572
+ maxBtn.className = 'dock-btn dock-max';
2573
+ maxBtn.setAttribute('data-i18n-title', 'dock.maximize');
2574
+ maxBtn.title = t('dock.maximize');
2575
+ maxBtn.textContent = '⤢'; // ⤢ maximize glyph
2308
2576
  var closeBtn = document.createElement('button');
2309
2577
  closeBtn.type = 'button';
2310
- closeBtn.className = 'mc-btn mc-ghost';
2578
+ closeBtn.className = 'dock-btn dock-close';
2579
+ closeBtn.setAttribute('data-i18n', 'merged.close');
2311
2580
  closeBtn.textContent = t('merged.close');
2581
+ var body = document.createElement('div');
2582
+ body.className = 'dock-body';
2583
+ bar.appendChild(title);
2584
+ bar.appendChild(maxBtn);
2585
+ bar.appendChild(closeBtn);
2586
+ panel.appendChild(resizer);
2587
+ panel.appendChild(bar);
2588
+ panel.appendChild(body);
2589
+ document.body.appendChild(backdrop);
2590
+ document.body.appendChild(panel);
2591
+ function close() { panel.remove(); backdrop.remove(); closeMergedMemoDocks(); }
2592
+ maxBtn.addEventListener('click', function () { toggleDockMaximized(); });
2593
+ closeBtn.addEventListener('click', close);
2594
+ backdrop.addEventListener('click', close); // click the dim behind the panel to dismiss
2595
+ // Esc closes the dock when focus is inside it; the editor keeps its own handlers otherwise.
2596
+ panel.addEventListener('keydown', function (e) {
2597
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
2598
+ });
2599
+ resizer.addEventListener('mousedown', function (e) {
2600
+ e.preventDefault();
2601
+ resizer.classList.add('resizing');
2602
+ function move(ev) { applyDockHeight(window.innerHeight - ev.clientY); }
2603
+ function up() {
2604
+ resizer.classList.remove('resizing');
2605
+ document.removeEventListener('mousemove', move);
2606
+ document.removeEventListener('mouseup', up);
2607
+ var cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--dock-height'), 10);
2608
+ if (cur) { try { localStorage.setItem(dockHeightKey, String(cur)); } catch (x) {} }
2609
+ }
2610
+ document.addEventListener('mousemove', move);
2611
+ document.addEventListener('mouseup', up);
2612
+ });
2613
+ document.body.classList.add('dock-open');
2614
+ document.body.classList.add('floating-dock'); // scopes the maximize CSS so it doesn't hide the diff
2615
+ applyDockMaximized();
2616
+ if (typeof syncRail === 'function') syncRail(); // light up the rail icon for the opened dock
2617
+ return { panel: panel, body: body, bar: bar, close: close };
2618
+ }
2619
+
2620
+ function openMergedView(kind) {
2621
+ var dock = mountDock('mc-merged-panel', kind === 'q' ? t('merged.qTitle') : t('merged.cTitle'));
2622
+ dock.panel.dataset.kind = kind; // remembered so a live locale switch can re-render this same view
2312
2623
  var area = document.createElement('textarea');
2313
2624
  area.className = 'mc-modal-text';
2314
2625
  // 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.
2626
+ // which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead.
2317
2627
  area.value = buildMergedText(kind);
2318
2628
  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.
2629
+ // Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret. Opt/Alt+Arrow steps
2630
+ // the caret comment-to-comment so each can be acted on without hand-scrolling.
2322
2631
  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
2632
  if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
2326
2633
  e.preventDefault();
2327
2634
  e.stopPropagation();
@@ -2336,49 +2643,28 @@ function openMergedView(kind) {
2336
2643
  var cxy = mergedCaretXY(area);
2337
2644
  var x = cxy.x, y = cxy.below, flipTop = cxy.top;
2338
2645
  var rerender = function () {
2339
- if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) { modal.remove(); return; }
2646
+ if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) { dock.close(); return; }
2340
2647
  area.value = buildMergedText(kind);
2341
2648
  };
2342
2649
  if (area.selectionStart !== area.selectionEnd || seqs.length > 1) {
2343
2650
  // 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
2651
  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); } });
2652
+ if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
2653
+ multi.push({ label: t('merged.sendToTerminal'), onSelect: function () { var text = buildMergedText(kind); dock.close(); window.__monacoriTerminal.enterSendMode(text); } });
2348
2654
  }
2349
2655
  multi.push({ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } });
2350
2656
  showCustomDropdown(x, y, multi, flipTop);
2351
2657
  } else {
2352
2658
  var seq = seqs[0];
2353
2659
  showCustomDropdown(x, y, [
2354
- { label: t('dropdown.navigate'), onSelect: function () { modal.remove(); navigateToComment(seq); } },
2660
+ { label: t('dropdown.navigate'), onSelect: function () { dock.close(); navigateToComment(seq); } },
2355
2661
  { label: t('dropdown.remove'), onSelect: function () { deleteComment(seq); rerender(); } },
2356
2662
  ], flipTop);
2357
2663
  }
2358
2664
  });
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
- }
2665
+ dock.body.appendChild(area);
2666
+ // Focus the read-only text so the caret is visible and Opt+Arrow / Opt+Enter work; retry (Electron focus race).
2667
+ focusDockField(area, '#mc-merged-panel');
2382
2668
  }
2383
2669
 
2384
2670
  // Prompt memo (Cmd/Ctrl+Shift+N): one freeform Markdown scratchpad with a live split preview, persisted
@@ -2396,27 +2682,10 @@ function renderMemoMd(text) {
2396
2682
  return renderMarkdownBlocks(text).map(function (b) { return b.html; }).join('');
2397
2683
  }
2398
2684
  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';
2685
+ if (document.getElementById('mc-memo-panel')) { closeMergedMemoDocks(); return; } // the shortcut toggles: 2nd press closes
2686
+ var dock = mountDock('mc-memo-panel', t('memo.title'));
2687
+ var memoBody = document.createElement('div');
2688
+ memoBody.className = 'mc-memo-body';
2420
2689
  var area = document.createElement('textarea');
2421
2690
  area.className = 'mc-modal-text mc-memo-edit';
2422
2691
  area.spellcheck = false;
@@ -2430,45 +2699,25 @@ function openMemoView() {
2430
2699
  saveMemo(area.value);
2431
2700
  preview.innerHTML = renderMemoMd(area.value);
2432
2701
  });
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;
2702
+ // Terminal send: hand the current draft to pane-pick mode. Shown only once a terminal pane exists;
2703
+ // enterSendMode reopens the terminal (which closes this memo dock the slot is exclusive).
2437
2704
  if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
2438
- sendBtn = document.createElement('button');
2705
+ var sendBtn = document.createElement('button');
2439
2706
  sendBtn.type = 'button';
2440
- sendBtn.className = 'mc-btn mc-send-term';
2707
+ sendBtn.className = 'dock-btn mc-send-term';
2441
2708
  sendBtn.setAttribute('data-i18n', 'merged.sendToTerminal');
2442
2709
  sendBtn.textContent = t('merged.sendToTerminal');
2443
2710
  sendBtn.addEventListener('click', function () {
2444
2711
  var text = area.value;
2445
- modal.remove();
2712
+ dock.close();
2446
2713
  window.__monacoriTerminal.enterSendMode(text);
2447
2714
  });
2715
+ dock.bar.insertBefore(sendBtn, dock.bar.querySelector('.dock-max'));
2448
2716
  }
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
- }
2717
+ memoBody.appendChild(area);
2718
+ memoBody.appendChild(preview);
2719
+ dock.body.appendChild(memoBody);
2720
+ focusDockField(area, '#mc-memo-panel');
2472
2721
  }
2473
2722
 
2474
2723
  document.addEventListener('click', function (event) {
@@ -2539,6 +2788,7 @@ refreshComments();
2539
2788
 
2540
2789
  function setActive(p) {
2541
2790
  active = p;
2791
+ if (p && p.labelEl) p.labelEl.classList.remove('has-bell'); // viewing the pane clears its bell badge
2542
2792
  panes.forEach(function (q) {
2543
2793
  q.el.classList.toggle('is-active', q === p);
2544
2794
  // 2+ panes: dim every pane but the active one (no border, just a clean focus cue). A lone pane stays full.
@@ -2552,6 +2802,11 @@ refreshComments();
2552
2802
  });
2553
2803
  }
2554
2804
 
2805
+ function copyToClipboard(text) {
2806
+ if (!text) return;
2807
+ try { if (window.monacoriClipboard && window.monacoriClipboard.write) { window.monacoriClipboard.write(text); return; } } catch (e) {}
2808
+ try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text); } catch (e) {}
2809
+ }
2555
2810
  function makePane() {
2556
2811
  if (!ensureXterm()) return null; // xterm unavailable — leave the panel empty rather than throw
2557
2812
  var el = document.createElement('div');
@@ -2587,6 +2842,9 @@ refreshComments();
2587
2842
  // Match the PHYSICAL key (e.code), not e.key: under a non-Latin layout/IME (e.g. Korean 한글)
2588
2843
  // Cmd+V reports e.key as 'ㅍ', so a key-based check misses it — blurring the terminal and
2589
2844
  // breaking paste/copy/cut/select-all whenever the Korean input source is active.
2845
+ // Cmd+C with a terminal selection: copy it ourselves — xterm doesn't auto-copy and the menu/native
2846
+ // copy misses xterm's own selection, so Cmd+C silently did nothing. No selection -> fall through.
2847
+ if (e.code === 'KeyC' && term.hasSelection && term.hasSelection()) { copyToClipboard(term.getSelection()); return false; }
2590
2848
  if (e.code === 'KeyC' || e.code === 'KeyV' || e.code === 'KeyX' || e.code === 'KeyA') return true;
2591
2849
  try { term.blur(); } catch (x) {}
2592
2850
  return false;
@@ -2594,6 +2852,14 @@ refreshComments();
2594
2852
  return true;
2595
2853
  });
2596
2854
  term.onData(function (d) { if (pane.id != null) window.monacoriPty.write({ id: pane.id, data: d }); });
2855
+ // Bell from the pane's TUI (e.g. Claude Code finished a turn / needs input): badge the pane when it isn't
2856
+ // the one you're looking at, and ask the main process to raise a native notification when the whole window
2857
+ // isn't focused. Toggle in Settings ("Notify when a terminal task finishes").
2858
+ term.onBell(function () {
2859
+ if (pane !== active && pane.labelEl) pane.labelEl.classList.add('has-bell');
2860
+ if (persistRead('monacori-terminal-bell-notify') === false) return; // OS notifications disabled
2861
+ try { window.monacoriPty.bell({ title: 'monacori', body: pane.name + ' — ' + t('notify.bellBody') }); } catch (e) {}
2862
+ });
2597
2863
  el.addEventListener('mousedown', function (e) { if (e.target !== labelEl) setActive(pane); });
2598
2864
  labelEl.addEventListener('dblclick', function () { renamePane(pane); });
2599
2865
  panes.push(pane);
@@ -2641,10 +2907,12 @@ refreshComments();
2641
2907
  }
2642
2908
 
2643
2909
  function removePane(id) {
2644
- var i = -1;
2645
- for (var k = 0; k < panes.length; k++) { if (panes[k].id === id) { i = k; break; } }
2910
+ for (var k = 0; k < panes.length; k++) { if (panes[k].id === id) { removePaneRef(panes[k]); return; } }
2911
+ }
2912
+ // Remove a pane by object reference (handles panes whose pty id hasn't arrived yet — spawn is async).
2913
+ function removePaneRef(p) {
2914
+ var i = panes.indexOf(p);
2646
2915
  if (i < 0) return;
2647
- var p = panes[i];
2648
2916
  try { p.term.dispose(); } catch (e) {}
2649
2917
  if (p.el.parentNode) p.el.parentNode.removeChild(p.el);
2650
2918
  panes.splice(i, 1);
@@ -2652,6 +2920,15 @@ refreshComments();
2652
2920
  if (panes.length === 0) setOpen(false);
2653
2921
  else fitAll();
2654
2922
  }
2923
+ // Cmd/Ctrl+W inside the terminal: close just the FOCUSED pane (kill its pty), not the whole panel. The
2924
+ // last pane closing collapses the panel via removePaneRef -> setOpen(false). Remove the pane immediately
2925
+ // (don't wait for the pty's onExit) so the UI responds at once; the later onExit -> removePane no-ops.
2926
+ function closeActivePane() {
2927
+ var p = active || panes[panes.length - 1];
2928
+ if (!p) { setOpen(false); return; }
2929
+ if (p.id != null) { try { window.monacoriPty.kill({ id: p.id }); } catch (e) {} }
2930
+ removePaneRef(p);
2931
+ }
2655
2932
 
2656
2933
  function split() {
2657
2934
  if (panes.length >= MAX_PANES) return;
@@ -2674,10 +2951,13 @@ refreshComments();
2674
2951
 
2675
2952
  function isOpen() { return !panel.classList.contains('hidden'); }
2676
2953
  function setOpen(open) {
2954
+ // The terminal shares the bottom dock slot with merged/memo — opening it closes those (exclusive slot).
2955
+ if (open && typeof window.__monacoriCloseDocks === 'function') { try { window.__monacoriCloseDocks(); } catch (e) {} }
2677
2956
  panel.classList.toggle('hidden', !open);
2678
2957
  document.body.classList.toggle('terminal-open', open);
2679
2958
  if (toggleBtn) toggleBtn.classList.toggle('is-active', open);
2680
2959
  try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch (e) {}
2960
+ if (typeof applyDockMaximized === 'function') applyDockMaximized(); // keep Cmd+Shift+' maximize in sync
2681
2961
  if (open) {
2682
2962
  if (panes.length === 0) makePane();
2683
2963
  requestAnimationFrame(function () { fitAll(); if (active) try { active.term.focus(); } catch (e) {} });
@@ -2782,8 +3062,12 @@ refreshComments();
2782
3062
  }, true);
2783
3063
  window.__monacoriTerminal = {
2784
3064
  isOpen: isOpen,
3065
+ // True when keyboard focus is inside the terminal panel (a pane owns it) — Cmd/Ctrl+W uses this to
3066
+ // decide between closing a pane and closing a source tab.
3067
+ hasFocus: function () { var ae = document.activeElement; return !!(ae && panel.contains(ae)); },
2785
3068
  open: function () { setOpen(true); },
2786
3069
  paneCount: function () { return panes.length; },
3070
+ closeActivePane: closeActivePane,
2787
3071
  enterSendMode: enterSendMode,
2788
3072
  send: function (text) { writeToPane(active || panes[0], text); },
2789
3073
  sendToPane: function (i, text) { writeToPane(panes[i] || active || panes[0], text); },
@@ -2811,10 +3095,11 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function
2811
3095
  window.monacoriMenu.onDiffUpdate(function (html) { try { applyDiffUpdate(html); } catch (e) {} });
2812
3096
  }
2813
3097
  if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function') {
2814
- // Cmd/Ctrl+W: close the active Files-mode tab (no-op outside the source viewer).
3098
+ // Cmd/Ctrl+W: close whatever the focus is on. A focused terminal pane closes just that pane (the last
3099
+ // pane collapses the panel); otherwise close the active Files-mode tab (no-op outside the source viewer).
2815
3100
  window.monacoriMenu.onCloseTab(function () {
2816
- // Cmd/Ctrl+W closes the terminal panel first when it's open, otherwise the active Files-mode tab.
2817
- if (window.__monacoriTerminal && window.__monacoriTerminal.isOpen()) { window.__monacoriTerminal.close(); return; }
3101
+ var term = window.__monacoriTerminal;
3102
+ if (term && term.isOpen() && term.hasFocus()) { term.closeActivePane(); return; }
2818
3103
  if (isSourceViewerVisible()) closeActiveSourceTab();
2819
3104
  });
2820
3105
  }
@@ -2925,6 +3210,12 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
2925
3210
  if (qta) qta.addEventListener('input', function () { saveMergePrompt('q', qta.value); flash(); });
2926
3211
  if (cta) cta.addEventListener('input', function () { saveMergePrompt('c', cta.value); flash(); });
2927
3212
  if (resetBtn) resetBtn.addEventListener('click', function () { saveMergePrompt('q', ''); saveMergePrompt('c', ''); fill(); flash(); });
3213
+ // Terminal-bell notification toggle (default ON — persistRead returns undefined when never set).
3214
+ var bellCb = document.getElementById('set-bell-notify');
3215
+ if (bellCb) {
3216
+ bellCb.checked = persistRead('monacori-terminal-bell-notify') !== false;
3217
+ bellCb.addEventListener('change', function () { persistSave('monacori-terminal-bell-notify', bellCb.checked); });
3218
+ }
2928
3219
  // Language: live-switch the whole UI (no reload). Persist, re-apply the static chrome, then re-render
2929
3220
  // any currently-shown dynamic text (open composer / merged modal / index status) so it follows too.
2930
3221
  langSelectRef = setupCustomSelect('settings-language',
@@ -2958,6 +3249,29 @@ function setTab(name) {
2958
3249
  });
2959
3250
  document.getElementById('changes-panel')?.classList.toggle('hidden', name !== 'changes');
2960
3251
  document.getElementById('files-panel')?.classList.toggle('hidden', name !== 'files');
3252
+ syncRail();
3253
+ }
3254
+ // Reflect the current view/dock state on the activity rail icons (active highlight). Terminal active is
3255
+ // kept in sync separately by the dock-terminal setOpen (it toggles is-active on #terminal-toggle).
3256
+ function syncRail() {
3257
+ var rail = document.querySelector('.activity-rail');
3258
+ if (!rail) return;
3259
+ var setOn = function (view, on) {
3260
+ var btn = rail.querySelector('[data-view="' + view + '"]');
3261
+ if (btn) btn.classList.toggle('is-active', !!on);
3262
+ };
3263
+ setOn('changes', !document.getElementById('changes-panel')?.classList.contains('hidden'));
3264
+ setOn('files', !document.getElementById('files-panel')?.classList.contains('hidden'));
3265
+ var merged = document.getElementById('mc-merged-panel');
3266
+ setOn('q', !!(merged && merged.dataset.kind === 'q'));
3267
+ setOn('c', !!(merged && merged.dataset.kind === 'c'));
3268
+ setOn('memo', !!document.getElementById('mc-memo-panel'));
3269
+ }
3270
+ // Rail click for the merged views toggles: a 2nd click on the open kind closes it (memo already toggles).
3271
+ function toggleMergedRail(kind) {
3272
+ var m = document.getElementById('mc-merged-panel');
3273
+ if (m && m.dataset.kind === kind) { closeMergedMemoDocks(); return; }
3274
+ openMergedView(kind);
2961
3275
  }
2962
3276
  // Big repos ship the source tree as an inert island (see render.ts); build it the first time the Files
2963
3277
  // tab is opened so the (potentially huge) tree never blocks startup. No-op for inline (small) trees.
@@ -3053,8 +3367,19 @@ function restoreUiState() {
3053
3367
  // regions (diff container, sidebar trees, status, data) and re-run the bootstrap steps. The window never
3054
3368
  // reloads, so the integrated terminal's pty sessions (claude/codex) survive a watch refresh. Electron's
3055
3369
  // main pushes the payload over IPC (monacori:diff-update); serve mode's poller fetches /__ai_flow_update.
3370
+ // Live watch refreshes are HELD while a comment composer is open. applyDiffUpdate rebuilds the diff DOM, so
3371
+ // applying it mid-compose would destroy the composer textarea every watch tick — input stalls and characters
3372
+ // arrive in bursts — and flicker the page. Keep only the latest pending payload; flush it on close/save.
3373
+ var pendingDiffUpdate = null;
3374
+ function flushPendingDiffUpdate() {
3375
+ if (!pendingDiffUpdate) return;
3376
+ var u = pendingDiffUpdate;
3377
+ pendingDiffUpdate = null;
3378
+ try { applyDiffUpdate(u); } catch (e) {}
3379
+ }
3056
3380
  function applyDiffUpdate(u) {
3057
3381
  if (!u || !u.signature || u.signature === currentSignature) return false; // unchanged — nothing to do
3382
+ if (composerState) { pendingDiffUpdate = u; return false; } // composing a comment — hold the refresh until close/save
3058
3383
 
3059
3384
  // Remember what to restore after the swap (comments/viewed persist on their own; these don't).
3060
3385
  var sv = document.getElementById('source-viewer');
@@ -3062,11 +3387,28 @@ function applyDiffUpdate(u) {
3062
3387
  var wasSource = isSourceViewerVisible();
3063
3388
  var container = document.getElementById('diff2html-container');
3064
3389
  var diffScrollTop = container ? container.scrollTop : 0;
3390
+ // The active hunk's file path BEFORE the swap (hunkMeta/hunks still hold the old build here). After a commit
3391
+ // the old active file can vanish from the new diff, so we re-anchor `current` to it below — otherwise it
3392
+ // dangles at a stale index and showDiffView renders blank with a stale breadcrumb.
3393
+ var prevActivePath = current >= 0 ? hunkPathAt(current) : '';
3065
3394
  // Did the file the user is CURRENTLY viewing actually change in this build? If not, we must not re-render
3066
3395
  // the source view — an unrelated file's edit would otherwise flicker the pane they're reading. Capture the
3067
3396
  // open file's signature BEFORE fileSignatureByPath is rebuilt below.
3068
3397
  var prevOpenSig = openPath ? (fileSignatureByPath.get(openPath) || '') : '';
3069
3398
 
3399
+ // Snapshot already-materialized file bodies (keyed by path + current signature) BEFORE the swap, so an
3400
+ // UNCHANGED file can be re-filled synchronously afterwards. Without this, the swap turns every wrapper into
3401
+ // an empty lazy shell that blanks until its body re-loads over IPC — the visible "flicker" on a watch tick.
3402
+ var prevBodies = {};
3403
+ if (REVIEW_LAZY && container) {
3404
+ container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
3405
+ var b = w.querySelector('.d2h-files-diff');
3406
+ if (!b || b.hasAttribute('data-lazy')) return; // only bodies that are actually materialized
3407
+ var p = diffWrapperPathKey(w);
3408
+ if (p) prevBodies[p] = { sig: fileSignatureByPath.get(p) || '', html: b.innerHTML };
3409
+ });
3410
+ }
3411
+
3070
3412
  // 1) Replace the visible regions straight from the payload (no full-HTML parse).
3071
3413
  if (container) container.innerHTML = u.diffContainer || '';
3072
3414
  var changesPanel = document.getElementById('changes-panel');
@@ -3079,6 +3421,13 @@ function applyDiffUpdate(u) {
3079
3421
  if (filesPanel && (!REVIEW_LAZY || filesPanel.innerHTML.trim())) filesPanel.innerHTML = u.filesTree || '';
3080
3422
  var statusEl = document.querySelector('.review-status');
3081
3423
  if (statusEl) statusEl.innerHTML = u.reviewStatus || '';
3424
+ // Branch can change between watch ticks (checkout/commit) — keep the sidebar chip current.
3425
+ var branchName = document.getElementById('brand-branch-name');
3426
+ if (branchName) {
3427
+ branchName.textContent = u.branch || '';
3428
+ var branchChip = branchName.closest && branchName.closest('.brand-branch');
3429
+ if (branchChip) branchChip.classList.toggle('hidden', !u.branch);
3430
+ }
3082
3431
  if (reviewMeta) { reviewMeta.setAttribute('data-signature', u.signature); if (u.generatedAt) reviewMeta.setAttribute('data-generated-at', u.generatedAt); }
3083
3432
 
3084
3433
  // 2) Re-derive module-level state directly from the payload objects.
@@ -3095,7 +3444,21 @@ function applyDiffUpdate(u) {
3095
3444
  links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
3096
3445
  sourceLinks = Array.from(document.querySelectorAll('.source-link'));
3097
3446
 
3447
+ // Reconcile the active hunk against the new build (uses the just-rebuilt `links`). A committed/removed file
3448
+ // reshuffles or shrinks the diff: re-anchor `current` to the same file's new hunk when it survives, else
3449
+ // drop to -1 so the diff lands on the first change rather than a dangling index that paints nothing.
3450
+ var activeFilePreserved = false;
3451
+ if (prevActivePath) {
3452
+ var reHunk = firstHunkForPath(prevActivePath);
3453
+ if (reHunk >= 0) { current = reHunk; activeFilePreserved = true; }
3454
+ else current = -1;
3455
+ }
3456
+
3098
3457
  // 3) Reset lazy-materialize + index state so the new diff bodies / source / symbols rebuild on demand.
3458
+ // bodyCache is keyed by file INDEX, not content — after a watch rebuild the same index maps to the new
3459
+ // body, so it MUST be dropped too. Clearing only bodyPromise left loadBodyHtml() returning the cached
3460
+ // OLD body, so a watch change never showed up in the diff until a full reload.
3461
+ bodyCache = {};
3099
3462
  bodyPromise = {};
3100
3463
  diffBootDone = false;
3101
3464
  sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
@@ -3104,8 +3467,27 @@ function applyDiffUpdate(u) {
3104
3467
  // sourceBodyPath so the already-painted (unchanged) source view is left exactly as-is — no flicker.
3105
3468
  if (openFileChanged) sourceBodyPath = null;
3106
3469
  symbolIndex = null;
3470
+
3471
+ // 3b) Re-fill UNCHANGED files' bodies synchronously from the snapshot so they don't blank-then-reload (the
3472
+ // flicker). Runs BEFORE setupLazyDiff so the IntersectionObserver sees them already materialized and never
3473
+ // re-fetches them. The fresh wrapper carries the correct data-first-hunk + file index, so materializeBody
3474
+ // numbers hunks exactly as a normal lazy load would. Changed/new files stay shells and lazy-load as usual.
3475
+ if (REVIEW_LAZY && container) {
3476
+ container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
3477
+ var p = diffWrapperPathKey(w);
3478
+ var prev = p ? prevBodies[p] : null;
3479
+ if (!prev || !prev.sig || prev.sig !== (fileSignatureByPath.get(p) || '')) return; // changed/new -> lazy-load
3480
+ var shell = w.querySelector('.d2h-files-diff[data-lazy]');
3481
+ if (!shell) return;
3482
+ var idx = (w.id || '').replace('file-', '');
3483
+ materializeBody(w, prev.html); // fills the body + markWrapperHunks (uses the new data-first-hunk)
3484
+ bodyCache[idx] = prev.html; // keep the index cache consistent so it never refetches
3485
+ bodyPromise[idx] = Promise.resolve(w);
3486
+ });
3487
+ }
3488
+ refreshHunkIndex(); // rebuild hunks/hunkMeta from the swapped-in DOM so hunkTotal()/hunkPathAt() aren't stale
3107
3489
  if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
3108
- else { prepareDiff2HtmlHunks(); diffBootDone = true; }
3490
+ else { diffBootDone = true; }
3109
3491
  if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
3110
3492
 
3111
3493
  // 4) Re-run the DOM-dependent bootstrap steps.
@@ -3121,7 +3503,10 @@ function applyDiffUpdate(u) {
3121
3503
  if (openFileChanged) openSourceFile(openPath, false);
3122
3504
  } else if (container) {
3123
3505
  showDiffView(false);
3124
- container.scrollTop = diffScrollTop;
3506
+ // Same active file survived → keep the user's exact scroll. If it was committed away (current reset to
3507
+ // -1, showDiffView landed on the first change), restoring the old, now-out-of-range scrollTop would push
3508
+ // the shorter new diff off-screen and look blank — so reset to the top instead.
3509
+ container.scrollTop = activeFilePreserved ? diffScrollTop : 0;
3125
3510
  }
3126
3511
  return true;
3127
3512
  }
@@ -3770,6 +4155,39 @@ function showUsages(name, count) {
3770
4155
  if (title) title.textContent = count + ' usage' + (count === 1 ? '' : 's') + ' of ' + name;
3771
4156
  renderUsages();
3772
4157
  box.classList.remove('hidden');
4158
+ positionUsagesAtCaret();
4159
+ }
4160
+ // Anchor the usages popup just below (or above, if cramped) the live caret — source OR diff both render a
4161
+ // `.code-cursor` span. No caret on screen → leave the centered overlay fallback in place.
4162
+ function positionUsagesAtCaret() {
4163
+ var box = document.getElementById('usages');
4164
+ if (!box) return;
4165
+ var panel = box.querySelector('.quick-open-panel');
4166
+ if (!panel) return;
4167
+ resetUsagesAnchor(box, panel); // measure from a clean slate
4168
+ var caret = document.querySelector('#source-body .code-cursor') || document.querySelector('#diff2html-container .code-cursor');
4169
+ if (!caret) return;
4170
+ var rect = caret.getBoundingClientRect();
4171
+ if (!rect.height && !rect.width && !rect.top) return; // detached / off-layout
4172
+ var vw = window.innerWidth, vh = window.innerHeight, gap = 6, margin = 8;
4173
+ var pw = Math.min(560, vw - margin * 2);
4174
+ var left = Math.min(Math.max(margin, rect.left), vw - pw - margin);
4175
+ box.classList.add('anchored');
4176
+ panel.style.width = pw + 'px';
4177
+ panel.style.left = left + 'px';
4178
+ var spaceBelow = vh - rect.bottom - gap - margin;
4179
+ var spaceAbove = rect.top - gap - margin;
4180
+ if (spaceBelow >= 200 || spaceBelow >= spaceAbove) {
4181
+ panel.style.top = (rect.bottom + gap) + 'px';
4182
+ panel.style.maxHeight = Math.max(120, spaceBelow) + 'px';
4183
+ } else {
4184
+ panel.style.bottom = (vh - rect.top + gap) + 'px';
4185
+ panel.style.maxHeight = Math.max(120, spaceAbove) + 'px';
4186
+ }
4187
+ }
4188
+ function resetUsagesAnchor(box, panel) {
4189
+ box.classList.remove('anchored');
4190
+ panel.style.left = panel.style.top = panel.style.bottom = panel.style.width = panel.style.maxHeight = '';
3773
4191
  }
3774
4192
  function renderUsages() {
3775
4193
  var results = document.getElementById('usages-results');
@@ -3807,7 +4225,11 @@ function openUsageItem(item) {
3807
4225
  openSourceAt(item.path, item.lineIndex, item.column);
3808
4226
  }
3809
4227
  function closeUsages() {
3810
- document.getElementById('usages')?.classList.add('hidden');
4228
+ var box = document.getElementById('usages');
4229
+ if (!box) return;
4230
+ box.classList.add('hidden');
4231
+ var panel = box.querySelector('.quick-open-panel');
4232
+ if (panel) resetUsagesAnchor(box, panel); // clear inline anchoring so the next open re-measures cleanly
3811
4233
  }
3812
4234
 
3813
4235
  var symbolIndex = null; // Map<name, [{path,lineIndex,column}]>; built off-thread by a Web Worker, null until ready
@@ -3985,11 +4407,18 @@ function renderSourceTabs(activePath) {
3985
4407
  var active = p === activePath;
3986
4408
  return '<div class="source-tab' + (active ? ' active' : '') + '" data-tab-path="' + escapeHtml(p) + '" title="' + escapeHtml(p) + '">'
3987
4409
  + '<span class="source-tab-name">' + escapeHtml(sourceTabLabel(p)) + '</span>'
3988
- + '<button type="button" class="source-tab-close" data-close-path="' + escapeHtml(p) + '" aria-label="Close tab" title="Close (Cmd/Ctrl+W)">×</button>'
4410
+ + '<button type="button" class="source-tab-close" data-close-path="' + escapeHtml(p) + '" aria-label="Close tab" title="Close (W)">×</button>'
3989
4411
  + '</div>';
3990
4412
  }).join('');
4413
+ // Scroll the tab bar HORIZONTALLY only. scrollIntoView() walks every scrollable ancestor — on rapid
4414
+ // Cmd+Shift+[/] cycling it nudged a vertical ancestor and clipped the tab strip at the top. Adjusting
4415
+ // bar.scrollLeft directly keeps the active tab in view without ever touching vertical scroll.
3991
4416
  var act = bar.querySelector('.source-tab.active');
3992
- if (act && act.scrollIntoView) act.scrollIntoView({ block: 'nearest', inline: 'nearest' });
4417
+ if (act) {
4418
+ var bl = bar.getBoundingClientRect(), al = act.getBoundingClientRect();
4419
+ if (al.left < bl.left) bar.scrollLeft -= (bl.left - al.left) + 8;
4420
+ else if (al.right > bl.right) bar.scrollLeft += (al.right - bl.right) + 8;
4421
+ }
3993
4422
  }
3994
4423
  function closeSourceTab(path) {
3995
4424
  var idx = sourceTabs.indexOf(path);
@@ -4462,7 +4891,7 @@ function renderHttpTable(file) {
4462
4891
  const reqIdx = hasRun ? runAtLine[index] : -1;
4463
4892
  const isCursorLine = Boolean(cursor && cursor.lineIndex === index);
4464
4893
  const gutter = hasRun
4465
- ? '<button type="button" class="http-run" data-req="' + reqIdx + '" title="Run request (Cmd/Alt+Enter)" aria-label="Run request">&#9654;</button>'
4894
+ ? '<button type="button" class="http-run" data-req="' + reqIdx + '" title="Run request (⌘Enter /Enter)" aria-label="Run request">&#9654;</button>'
4466
4895
  : '';
4467
4896
  rows += '<tr class="source-row http-row' + (hasRun ? ' http-request-line' : '') + (isCursorLine ? ' cursor-line' : '') + '" data-line-index="' + index + '">'
4468
4897
  + '<td class="num http-gutter">' + gutter + '<span class="num-text">' + (index + 1) + '</span></td>'