@happy-nut/monacori 0.1.21 → 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) {
@@ -256,6 +277,7 @@ const quickOpen = document.getElementById('quick-open');
256
277
  const quickInput = document.getElementById('quick-open-input');
257
278
  const quickResults = document.getElementById('quick-open-results');
258
279
  const quickModeLabel = document.getElementById('quick-open-mode');
280
+ const quickFilterEl = document.getElementById('quick-open-filter');
259
281
  let current = -1;
260
282
  let checkingForUpdates = false;
261
283
  let lastShiftAt = 0;
@@ -263,6 +285,7 @@ let lastShiftSide = 0;
263
285
  let quickMode = 'all';
264
286
  let quickItems = [];
265
287
  let quickActive = 0;
288
+ let recentFilter = ''; // IntelliJ-style speed-search: typed letters narrow the Recent list (no search box)
266
289
  let usageItems = []; // find-usages results for the Cmd+B-on-declaration popup
267
290
  let usageActive = 0;
268
291
  let viewerCursor = null;
@@ -660,14 +683,19 @@ function next(delta) {
660
683
  // File boundary: no more change blocks in this file. Forward F7 announces "last change — press F7 again
661
684
  // to go to the next file" on the FIRST press (a beat to mark-viewed) and only crosses on the SECOND
662
685
  // consecutive press. Already-viewed files (and backward nav) cross immediately — no announcement.
663
- 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) {
664
691
  if (pendingFileBoundary !== diffCursor.path) {
665
692
  pendingFileBoundary = diffCursor.path;
666
- showToast(t('diff.lastHunk'));
693
+ showCaretHint(t('diff.lastHunk'));
667
694
  return;
668
695
  }
669
696
  pendingFileBoundary = null; // second consecutive press on the same file → fall through and cross
670
697
  }
698
+ hideCaretHint(); // about to cross files — drop the hint NOW (before the async body load) so it can't cover the next file
671
699
  // hunk-level nav to the next/prev unviewed file.
672
700
  const caretHunk = hunkIndexAtCaret();
673
701
  const base = caretHunk >= 0 ? caretHunk : current;
@@ -716,9 +744,22 @@ function openQuickOpen(mode) {
716
744
  quickMode = mode;
717
745
  quickModeLabel.textContent = mode === 'recent' ? t('quickopen.recent') : mode === 'content' ? t('quickopen.findInFiles') : t('quickopen.searchFiles');
718
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 = '';
719
751
  quickInput.value = '';
752
+ updateRecentFilterDisplay();
720
753
  renderQuickOpenResults();
721
- 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'; }
722
763
  }
723
764
 
724
765
  function closeQuickOpen() {
@@ -728,6 +769,8 @@ function closeQuickOpen() {
728
769
  function handleQuickOpenKey(event) {
729
770
  if (event.key === 'Escape') {
730
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; }
731
774
  closeQuickOpen();
732
775
  return true;
733
776
  }
@@ -748,15 +791,31 @@ function handleQuickOpenKey(event) {
748
791
  openQuickItem(quickItems[quickActive]);
749
792
  return true;
750
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
+ }
751
809
  return false;
752
810
  }
753
811
 
754
812
  function renderQuickOpenResults() {
755
813
  if (!quickResults) return;
756
- const query = quickInput?.value.trim().toLowerCase() || '';
757
- 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();
758
818
  quickItems = candidates
759
- .filter((item) => quickMode !== 'recent' || query.length > 0 || item.recent)
760
819
  .filter((item) => {
761
820
  if (query.length === 0) return true;
762
821
  if (quickMode === 'content') {
@@ -1096,17 +1155,17 @@ document.addEventListener('keydown', (event) => {
1096
1155
  // and +. open the merged views; Cmd/Ctrl+Shift+N toggles the memo. (Match event.code so IME/layout never
1097
1156
  // swallows the combo.) Settings is a true overlay, so these stand down while it is up.
1098
1157
  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') {
1158
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.code === 'Quote') {
1100
1159
  event.preventDefault();
1101
1160
  toggleDockMaximized();
1102
1161
  return;
1103
1162
  }
1104
- if (!settingsUp && (event.metaKey || event.ctrlKey) && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
1163
+ if (!settingsUp && (event.metaKey || event.ctrlKey) && !event.altKey && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
1105
1164
  event.preventDefault();
1106
1165
  openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
1107
1166
  return;
1108
1167
  }
1109
- if (!settingsUp && (event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
1168
+ if (!settingsUp && (event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
1110
1169
  event.preventDefault();
1111
1170
  openMemoView();
1112
1171
  return;
@@ -1116,7 +1175,7 @@ document.addEventListener('keydown', (event) => {
1116
1175
  // shortcuts (Cmd+1, F7, Cmd+[/], Cmd+B, …). Each has its own Esc + editing handlers.
1117
1176
  if (isFloatingModalOpen()) return;
1118
1177
 
1119
- if ((event.metaKey || event.ctrlKey) && event.key === '1') {
1178
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key === '1') {
1120
1179
  event.preventDefault();
1121
1180
  // Coming from the diff: open the file you were viewing as source so Cmd+1 lands ON it (not a stale/blank
1122
1181
  // source pane), and the tree below points at the same file. Capture the path BEFORE openSourceFile flips
@@ -1131,7 +1190,7 @@ document.addEventListener('keydown', (event) => {
1131
1190
  focusOpenFileInTree();
1132
1191
  return;
1133
1192
  }
1134
- if ((event.metaKey || event.ctrlKey) && event.key === '0') {
1193
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key === '0') {
1135
1194
  event.preventDefault();
1136
1195
  setTab('changes');
1137
1196
  focusOpenFileInTree();
@@ -1217,7 +1276,7 @@ document.addEventListener('keydown', (event) => {
1217
1276
  // PageUp/Down scroll the diff/source view. There's no focusable scroller (the diff caret is a JS cursor),
1218
1277
  // and d2h-file-side-diff's horizontal scrollport even swallows vertical wheel, so handle paging explicitly.
1219
1278
  // Only when the tree isn't focused — the tree pages itself in handleTreeKey below.
1220
- 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) {
1221
1280
  var psc = isDiffViewVisible() ? document.getElementById('diff2html-container') : (isSourceViewerVisible() ? document.getElementById('source-body') : null);
1222
1281
  if (psc) { event.preventDefault(); psc.scrollTop += (event.key === 'PageDown' ? 0.9 : -0.9) * psc.clientHeight; return; }
1223
1282
  }
@@ -1248,12 +1307,12 @@ document.addEventListener('keydown', (event) => {
1248
1307
  lastShiftSide = side;
1249
1308
  }
1250
1309
 
1251
- 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') {
1252
1311
  event.preventDefault();
1253
1312
  openQuickOpen('content');
1254
1313
  return;
1255
1314
  }
1256
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'e') {
1315
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'e') {
1257
1316
  event.preventDefault();
1258
1317
  openQuickOpen('recent');
1259
1318
  return;
@@ -1268,14 +1327,14 @@ document.addEventListener('keydown', (event) => {
1268
1327
  }
1269
1328
  }
1270
1329
 
1271
- if ((event.metaKey || event.ctrlKey) && event.key === 'ArrowDown') {
1330
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key === 'ArrowDown') {
1272
1331
  event.preventDefault();
1273
1332
  if (isSourceViewerVisible()) goToSymbolUnderCursor();
1274
1333
  else openDiffFileAtCaret();
1275
1334
  return;
1276
1335
  }
1277
1336
 
1278
- 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')) {
1279
1338
  var aeB = document.activeElement;
1280
1339
  if (aeB && (aeB.tagName === 'INPUT' || aeB.tagName === 'TEXTAREA' || aeB.tagName === 'SELECT')) return;
1281
1340
  event.preventDefault();
@@ -1337,7 +1396,7 @@ document.addEventListener('keydown', (event) => {
1337
1396
  }
1338
1397
  }
1339
1398
 
1340
- if (event.key === 'F7') {
1399
+ if (event.key === 'F7' && !event.metaKey && !event.ctrlKey && !event.altKey) {
1341
1400
  event.preventDefault();
1342
1401
  const delta = event.shiftKey ? -1 : 1;
1343
1402
  const sourceViewer = document.getElementById('source-viewer');
@@ -1414,6 +1473,19 @@ document.querySelectorAll('.tab').forEach((button) => {
1414
1473
  button.addEventListener('click', () => setTab(button.dataset.tab || 'changes'));
1415
1474
  });
1416
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
+
1417
1489
  document.getElementById('back-to-diff')?.addEventListener('click', () => showDiffView(true));
1418
1490
  document.getElementById('source-tabs')?.addEventListener('click', function (event) {
1419
1491
  var closeBtn = event.target && event.target.closest && event.target.closest('.source-tab-close');
@@ -1450,6 +1522,7 @@ if (!restored) {
1450
1522
  else openDefaultSourceFile();
1451
1523
  }
1452
1524
  initSourceTreeFolds();
1525
+ syncRail(); // reflect the initial view on the activity rail
1453
1526
  // Electron receives live updates over IPC (monacoriMenu.onDiffUpdate); only serve/browser needs the HTTP
1454
1527
  // poller. Under file:// its fetch just fails every 1.5s for the app's whole life, so skip it in Electron.
1455
1528
  if (watchEnabled && !(window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function')) {
@@ -1485,7 +1558,10 @@ window.addEventListener('beforeunload', saveUiState);
1485
1558
  });
1486
1559
  document.addEventListener('mousemove', (event) => {
1487
1560
  if (!resizing) return;
1488
- 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));
1489
1565
  document.documentElement.style.setProperty('--sidebar-width', width + 'px');
1490
1566
  });
1491
1567
  document.addEventListener('mouseup', () => {
@@ -1671,6 +1747,7 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
1671
1747
  var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
1672
1748
  diffCursor = { path: path, side: side, rowIndex: ri, column: col };
1673
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
1674
1751
  diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
1675
1752
  if (reveal) {
1676
1753
  // Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
@@ -1911,6 +1988,28 @@ function showToast(message) {
1911
1988
  setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
1912
1989
  }, 4500);
1913
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
+ }
1914
2013
  // Follow each comment to its snapshot line (c.code) in the current content: same line if unchanged, else the
1915
2014
  // nearest exact match of that line. A comment is NEVER auto-deleted. If its line can't be found we leave it
1916
2015
  // where it is — this happens routinely WITHOUT the file changing: a comment anchored to a deleted/old-side
@@ -2405,6 +2504,12 @@ function applyDockMaximized() {
2405
2504
  document.body.classList.toggle('dock-maximized', dockMaximized);
2406
2505
  }
2407
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;
2408
2513
  if (!activeDockPanel()) return; // nothing docked -> nothing to maximize
2409
2514
  dockMaximized = !dockMaximized;
2410
2515
  applyDockMaximized();
@@ -2423,6 +2528,7 @@ function closeMergedMemoDocks() {
2423
2528
  // terminal dock but never for these floating panels.
2424
2529
  document.body.classList.toggle('floating-dock', !!(document.getElementById('mc-merged-panel') || document.getElementById('mc-memo-panel')));
2425
2530
  applyDockMaximized();
2531
+ if (typeof syncRail === 'function') syncRail(); // clear the rail icon for the closed dock(s)
2426
2532
  }
2427
2533
  window.__monacoriCloseDocks = closeMergedMemoDocks;
2428
2534
  // Retry-focus a docked field (Electron async-restores focus to <body>, so a one-shot focus can lose the race).
@@ -2507,6 +2613,7 @@ function mountDock(id, titleText) {
2507
2613
  document.body.classList.add('dock-open');
2508
2614
  document.body.classList.add('floating-dock'); // scopes the maximize CSS so it doesn't hide the diff
2509
2615
  applyDockMaximized();
2616
+ if (typeof syncRail === 'function') syncRail(); // light up the rail icon for the opened dock
2510
2617
  return { panel: panel, body: body, bar: bar, close: close };
2511
2618
  }
2512
2619
 
@@ -2681,6 +2788,7 @@ refreshComments();
2681
2788
 
2682
2789
  function setActive(p) {
2683
2790
  active = p;
2791
+ if (p && p.labelEl) p.labelEl.classList.remove('has-bell'); // viewing the pane clears its bell badge
2684
2792
  panes.forEach(function (q) {
2685
2793
  q.el.classList.toggle('is-active', q === p);
2686
2794
  // 2+ panes: dim every pane but the active one (no border, just a clean focus cue). A lone pane stays full.
@@ -2694,6 +2802,11 @@ refreshComments();
2694
2802
  });
2695
2803
  }
2696
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
+ }
2697
2810
  function makePane() {
2698
2811
  if (!ensureXterm()) return null; // xterm unavailable — leave the panel empty rather than throw
2699
2812
  var el = document.createElement('div');
@@ -2729,6 +2842,9 @@ refreshComments();
2729
2842
  // Match the PHYSICAL key (e.code), not e.key: under a non-Latin layout/IME (e.g. Korean 한글)
2730
2843
  // Cmd+V reports e.key as 'ㅍ', so a key-based check misses it — blurring the terminal and
2731
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; }
2732
2848
  if (e.code === 'KeyC' || e.code === 'KeyV' || e.code === 'KeyX' || e.code === 'KeyA') return true;
2733
2849
  try { term.blur(); } catch (x) {}
2734
2850
  return false;
@@ -2736,6 +2852,14 @@ refreshComments();
2736
2852
  return true;
2737
2853
  });
2738
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
+ });
2739
2863
  el.addEventListener('mousedown', function (e) { if (e.target !== labelEl) setActive(pane); });
2740
2864
  labelEl.addEventListener('dblclick', function () { renamePane(pane); });
2741
2865
  panes.push(pane);
@@ -2783,10 +2907,12 @@ refreshComments();
2783
2907
  }
2784
2908
 
2785
2909
  function removePane(id) {
2786
- var i = -1;
2787
- 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);
2788
2915
  if (i < 0) return;
2789
- var p = panes[i];
2790
2916
  try { p.term.dispose(); } catch (e) {}
2791
2917
  if (p.el.parentNode) p.el.parentNode.removeChild(p.el);
2792
2918
  panes.splice(i, 1);
@@ -2794,6 +2920,15 @@ refreshComments();
2794
2920
  if (panes.length === 0) setOpen(false);
2795
2921
  else fitAll();
2796
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
+ }
2797
2932
 
2798
2933
  function split() {
2799
2934
  if (panes.length >= MAX_PANES) return;
@@ -2927,8 +3062,12 @@ refreshComments();
2927
3062
  }, true);
2928
3063
  window.__monacoriTerminal = {
2929
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)); },
2930
3068
  open: function () { setOpen(true); },
2931
3069
  paneCount: function () { return panes.length; },
3070
+ closeActivePane: closeActivePane,
2932
3071
  enterSendMode: enterSendMode,
2933
3072
  send: function (text) { writeToPane(active || panes[0], text); },
2934
3073
  sendToPane: function (i, text) { writeToPane(panes[i] || active || panes[0], text); },
@@ -2956,10 +3095,11 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function
2956
3095
  window.monacoriMenu.onDiffUpdate(function (html) { try { applyDiffUpdate(html); } catch (e) {} });
2957
3096
  }
2958
3097
  if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function') {
2959
- // 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).
2960
3100
  window.monacoriMenu.onCloseTab(function () {
2961
- // Cmd/Ctrl+W closes the terminal panel first when it's open, otherwise the active Files-mode tab.
2962
- 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; }
2963
3103
  if (isSourceViewerVisible()) closeActiveSourceTab();
2964
3104
  });
2965
3105
  }
@@ -3070,6 +3210,12 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
3070
3210
  if (qta) qta.addEventListener('input', function () { saveMergePrompt('q', qta.value); flash(); });
3071
3211
  if (cta) cta.addEventListener('input', function () { saveMergePrompt('c', cta.value); flash(); });
3072
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
+ }
3073
3219
  // Language: live-switch the whole UI (no reload). Persist, re-apply the static chrome, then re-render
3074
3220
  // any currently-shown dynamic text (open composer / merged modal / index status) so it follows too.
3075
3221
  langSelectRef = setupCustomSelect('settings-language',
@@ -3103,6 +3249,29 @@ function setTab(name) {
3103
3249
  });
3104
3250
  document.getElementById('changes-panel')?.classList.toggle('hidden', name !== 'changes');
3105
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);
3106
3275
  }
3107
3276
  // Big repos ship the source tree as an inert island (see render.ts); build it the first time the Files
3108
3277
  // tab is opened so the (potentially huge) tree never blocks startup. No-op for inline (small) trees.
@@ -3218,6 +3387,10 @@ function applyDiffUpdate(u) {
3218
3387
  var wasSource = isSourceViewerVisible();
3219
3388
  var container = document.getElementById('diff2html-container');
3220
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) : '';
3221
3394
  // Did the file the user is CURRENTLY viewing actually change in this build? If not, we must not re-render
3222
3395
  // the source view — an unrelated file's edit would otherwise flicker the pane they're reading. Capture the
3223
3396
  // open file's signature BEFORE fileSignatureByPath is rebuilt below.
@@ -3248,6 +3421,13 @@ function applyDiffUpdate(u) {
3248
3421
  if (filesPanel && (!REVIEW_LAZY || filesPanel.innerHTML.trim())) filesPanel.innerHTML = u.filesTree || '';
3249
3422
  var statusEl = document.querySelector('.review-status');
3250
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
+ }
3251
3431
  if (reviewMeta) { reviewMeta.setAttribute('data-signature', u.signature); if (u.generatedAt) reviewMeta.setAttribute('data-generated-at', u.generatedAt); }
3252
3432
 
3253
3433
  // 2) Re-derive module-level state directly from the payload objects.
@@ -3264,6 +3444,16 @@ function applyDiffUpdate(u) {
3264
3444
  links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
3265
3445
  sourceLinks = Array.from(document.querySelectorAll('.source-link'));
3266
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
+
3267
3457
  // 3) Reset lazy-materialize + index state so the new diff bodies / source / symbols rebuild on demand.
3268
3458
  // bodyCache is keyed by file INDEX, not content — after a watch rebuild the same index maps to the new
3269
3459
  // body, so it MUST be dropped too. Clearing only bodyPromise left loadBodyHtml() returning the cached
@@ -3277,14 +3467,11 @@ function applyDiffUpdate(u) {
3277
3467
  // sourceBodyPath so the already-painted (unchanged) source view is left exactly as-is — no flicker.
3278
3468
  if (openFileChanged) sourceBodyPath = null;
3279
3469
  symbolIndex = null;
3280
- if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
3281
- else { prepareDiff2HtmlHunks(); diffBootDone = true; }
3282
- if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
3283
3470
 
3284
3471
  // 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.
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.
3288
3475
  if (REVIEW_LAZY && container) {
3289
3476
  container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
3290
3477
  var p = diffWrapperPathKey(w);
@@ -3298,6 +3485,10 @@ function applyDiffUpdate(u) {
3298
3485
  bodyPromise[idx] = Promise.resolve(w);
3299
3486
  });
3300
3487
  }
3488
+ refreshHunkIndex(); // rebuild hunks/hunkMeta from the swapped-in DOM so hunkTotal()/hunkPathAt() aren't stale
3489
+ if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
3490
+ else { diffBootDone = true; }
3491
+ if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
3301
3492
 
3302
3493
  // 4) Re-run the DOM-dependent bootstrap steps.
3303
3494
  applyI18n();
@@ -3312,7 +3503,10 @@ function applyDiffUpdate(u) {
3312
3503
  if (openFileChanged) openSourceFile(openPath, false);
3313
3504
  } else if (container) {
3314
3505
  showDiffView(false);
3315
- 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;
3316
3510
  }
3317
3511
  return true;
3318
3512
  }
@@ -3961,6 +4155,39 @@ function showUsages(name, count) {
3961
4155
  if (title) title.textContent = count + ' usage' + (count === 1 ? '' : 's') + ' of ' + name;
3962
4156
  renderUsages();
3963
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 = '';
3964
4191
  }
3965
4192
  function renderUsages() {
3966
4193
  var results = document.getElementById('usages-results');
@@ -3998,7 +4225,11 @@ function openUsageItem(item) {
3998
4225
  openSourceAt(item.path, item.lineIndex, item.column);
3999
4226
  }
4000
4227
  function closeUsages() {
4001
- 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
4002
4233
  }
4003
4234
 
4004
4235
  var symbolIndex = null; // Map<name, [{path,lineIndex,column}]>; built off-thread by a Web Worker, null until ready
@@ -4176,11 +4407,18 @@ function renderSourceTabs(activePath) {
4176
4407
  var active = p === activePath;
4177
4408
  return '<div class="source-tab' + (active ? ' active' : '') + '" data-tab-path="' + escapeHtml(p) + '" title="' + escapeHtml(p) + '">'
4178
4409
  + '<span class="source-tab-name">' + escapeHtml(sourceTabLabel(p)) + '</span>'
4179
- + '<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>'
4180
4411
  + '</div>';
4181
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.
4182
4416
  var act = bar.querySelector('.source-tab.active');
4183
- 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
+ }
4184
4422
  }
4185
4423
  function closeSourceTab(path) {
4186
4424
  var idx = sourceTabs.indexOf(path);
@@ -4653,7 +4891,7 @@ function renderHttpTable(file) {
4653
4891
  const reqIdx = hasRun ? runAtLine[index] : -1;
4654
4892
  const isCursorLine = Boolean(cursor && cursor.lineIndex === index);
4655
4893
  const gutter = hasRun
4656
- ? '<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>'
4657
4895
  : '';
4658
4896
  rows += '<tr class="source-row http-row' + (hasRun ? ' http-request-line' : '') + (isCursorLine ? ' cursor-line' : '') + '" data-line-index="' + index + '">'
4659
4897
  + '<td class="num http-gutter">' + gutter + '<span class="num-text">' + (index + 1) + '</span></td>'