@happy-nut/monacori 0.1.21 → 0.1.23

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') {
@@ -1041,6 +1100,11 @@ function handleTreeKey(event) {
1041
1100
  if (event.key === 'ArrowUp') { event.preventDefault(); focusTree(treeFocusIndex - 1); return true; }
1042
1101
  if (event.key === 'PageDown') { event.preventDefault(); focusTree(treeFocusIndex + treePageSize()); return true; }
1043
1102
  if (event.key === 'PageUp') { event.preventDefault(); focusTree(treeFocusIndex - treePageSize()); return true; }
1103
+ if (event.key === 'Enter' && event.altKey) {
1104
+ event.preventDefault();
1105
+ if (row && typeof openTreeRowMenu === 'function') openTreeRowMenu(row); // copy path / Finder / terminal
1106
+ return true;
1107
+ }
1044
1108
  if (event.key === 'Enter') {
1045
1109
  event.preventDefault();
1046
1110
  if (row && row.classList.contains('file-link')) { row.click(); clearTreeFocus(); }
@@ -1078,6 +1142,9 @@ function handleTreeKey(event) {
1078
1142
  function isFloatingModalOpen() {
1079
1143
  var sm = document.getElementById('settings-modal');
1080
1144
  if (sm && !sm.classList.contains('hidden')) return true;
1145
+ var hv = document.getElementById('history-view');
1146
+ if (hv && !hv.classList.contains('hidden')) return true; // history overlay owns the keys (Esc/filter/click)
1147
+ if (document.getElementById('goto-line')) return true; // go-to-line prompt owns the keys until Enter/Esc
1081
1148
  // The merged/memo panels are now docked (inline), not overlays — but while one OWNS focus we still stand
1082
1149
  // down the global nav shortcuts so typing / ▲▼ inside it isn't hijacked. Focus elsewhere -> shortcuts run.
1083
1150
  return isDockFocused();
@@ -1096,27 +1163,33 @@ document.addEventListener('keydown', (event) => {
1096
1163
  // and +. open the merged views; Cmd/Ctrl+Shift+N toggles the memo. (Match event.code so IME/layout never
1097
1164
  // swallows the combo.) Settings is a true overlay, so these stand down while it is up.
1098
1165
  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') {
1166
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.code === 'Quote') {
1100
1167
  event.preventDefault();
1101
1168
  toggleDockMaximized();
1102
1169
  return;
1103
1170
  }
1104
- if (!settingsUp && (event.metaKey || event.ctrlKey) && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
1171
+ if (!settingsUp && (event.metaKey || event.ctrlKey) && !event.altKey && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
1105
1172
  event.preventDefault();
1106
1173
  openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
1107
1174
  return;
1108
1175
  }
1109
- if (!settingsUp && (event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
1176
+ if (!settingsUp && (event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
1110
1177
  event.preventDefault();
1111
1178
  openMemoView();
1112
1179
  return;
1113
1180
  }
1181
+ // Cmd/Ctrl+9 toggles the git history view (above the focus guard so a 2nd press closes it from inside).
1182
+ if (!settingsUp && (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && (event.code === 'Digit9' || event.key === '9') && typeof toggleHistory === 'function') {
1183
+ event.preventDefault();
1184
+ toggleHistory();
1185
+ return;
1186
+ }
1114
1187
 
1115
1188
  // Settings overlay (or a focused merged/memo dock) captures keys: stand down the rest of the global
1116
1189
  // shortcuts (Cmd+1, F7, Cmd+[/], Cmd+B, …). Each has its own Esc + editing handlers.
1117
1190
  if (isFloatingModalOpen()) return;
1118
1191
 
1119
- if ((event.metaKey || event.ctrlKey) && event.key === '1') {
1192
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key === '1') {
1120
1193
  event.preventDefault();
1121
1194
  // Coming from the diff: open the file you were viewing as source so Cmd+1 lands ON it (not a stale/blank
1122
1195
  // source pane), and the tree below points at the same file. Capture the path BEFORE openSourceFile flips
@@ -1131,7 +1204,25 @@ document.addEventListener('keydown', (event) => {
1131
1204
  focusOpenFileInTree();
1132
1205
  return;
1133
1206
  }
1134
- if ((event.metaKey || event.ctrlKey) && event.key === '0') {
1207
+ // Cmd/Ctrl+L = go to line (numeric prompt); Cmd/Ctrl+K = copy the caret's file:line. Skip when an
1208
+ // editable field owns focus (a comment composer textarea) so we don't hijack the user's typing.
1209
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && (event.key === 'l' || event.key === 'L')) {
1210
+ var lkae = document.activeElement;
1211
+ if (!(lkae && (lkae.tagName === 'INPUT' || lkae.tagName === 'TEXTAREA' || lkae.tagName === 'SELECT'))) {
1212
+ event.preventDefault();
1213
+ openGotoLine();
1214
+ return;
1215
+ }
1216
+ }
1217
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && (event.key === 'k' || event.key === 'K')) {
1218
+ var kkae = document.activeElement;
1219
+ if (!(kkae && (kkae.tagName === 'INPUT' || kkae.tagName === 'TEXTAREA' || kkae.tagName === 'SELECT'))) {
1220
+ event.preventDefault();
1221
+ copyCaretLocation();
1222
+ return;
1223
+ }
1224
+ }
1225
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key === '0') {
1135
1226
  event.preventDefault();
1136
1227
  setTab('changes');
1137
1228
  focusOpenFileInTree();
@@ -1217,7 +1308,7 @@ document.addEventListener('keydown', (event) => {
1217
1308
  // PageUp/Down scroll the diff/source view. There's no focusable scroller (the diff caret is a JS cursor),
1218
1309
  // and d2h-file-side-diff's horizontal scrollport even swallows vertical wheel, so handle paging explicitly.
1219
1310
  // 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) {
1311
+ if (treeFocusIndex < 0 && (event.key === 'PageDown' || event.key === 'PageUp') && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) {
1221
1312
  var psc = isDiffViewVisible() ? document.getElementById('diff2html-container') : (isSourceViewerVisible() ? document.getElementById('source-body') : null);
1222
1313
  if (psc) { event.preventDefault(); psc.scrollTop += (event.key === 'PageDown' ? 0.9 : -0.9) * psc.clientHeight; return; }
1223
1314
  }
@@ -1248,12 +1339,12 @@ document.addEventListener('keydown', (event) => {
1248
1339
  lastShiftSide = side;
1249
1340
  }
1250
1341
 
1251
- if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'f') {
1342
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === 'f') {
1252
1343
  event.preventDefault();
1253
1344
  openQuickOpen('content');
1254
1345
  return;
1255
1346
  }
1256
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'e') {
1347
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'e') {
1257
1348
  event.preventDefault();
1258
1349
  openQuickOpen('recent');
1259
1350
  return;
@@ -1268,14 +1359,14 @@ document.addEventListener('keydown', (event) => {
1268
1359
  }
1269
1360
  }
1270
1361
 
1271
- if ((event.metaKey || event.ctrlKey) && event.key === 'ArrowDown') {
1362
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key === 'ArrowDown') {
1272
1363
  event.preventDefault();
1273
1364
  if (isSourceViewerVisible()) goToSymbolUnderCursor();
1274
1365
  else openDiffFileAtCaret();
1275
1366
  return;
1276
1367
  }
1277
1368
 
1278
- if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
1369
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && (event.key === 'b' || event.key === 'B')) {
1279
1370
  var aeB = document.activeElement;
1280
1371
  if (aeB && (aeB.tagName === 'INPUT' || aeB.tagName === 'TEXTAREA' || aeB.tagName === 'SELECT')) return;
1281
1372
  event.preventDefault();
@@ -1337,7 +1428,7 @@ document.addEventListener('keydown', (event) => {
1337
1428
  }
1338
1429
  }
1339
1430
 
1340
- if (event.key === 'F7') {
1431
+ if (event.key === 'F7' && !event.metaKey && !event.ctrlKey && !event.altKey) {
1341
1432
  event.preventDefault();
1342
1433
  const delta = event.shiftKey ? -1 : 1;
1343
1434
  const sourceViewer = document.getElementById('source-viewer');
@@ -1414,6 +1505,20 @@ document.querySelectorAll('.tab').forEach((button) => {
1414
1505
  button.addEventListener('click', () => setTab(button.dataset.tab || 'changes'));
1415
1506
  });
1416
1507
 
1508
+ // Activity rail (IntelliJ-style): click an icon to navigate/toggle its view. Terminal + settings buttons
1509
+ // carry no data-view — they keep their own id-based handlers (terminal toggle / settings gear).
1510
+ document.querySelector('.activity-rail')?.addEventListener('click', (event) => {
1511
+ const btn = event.target.closest && event.target.closest('.rail-btn[data-view]');
1512
+ if (!btn) return;
1513
+ const view = btn.dataset.view;
1514
+ if (view === 'changes') { setTab('changes'); if (!isDiffViewVisible()) showDiffView(false); }
1515
+ else if (view === 'files') { setTab('files'); }
1516
+ else if (view === 'q' || view === 'c') { toggleMergedRail(view); }
1517
+ else if (view === 'memo') { openMemoView(); } // openMemoView already toggles
1518
+ else if (view === 'history') { toggleHistory(); }
1519
+ syncRail();
1520
+ });
1521
+
1417
1522
  document.getElementById('back-to-diff')?.addEventListener('click', () => showDiffView(true));
1418
1523
  document.getElementById('source-tabs')?.addEventListener('click', function (event) {
1419
1524
  var closeBtn = event.target && event.target.closest && event.target.closest('.source-tab-close');
@@ -1450,6 +1555,7 @@ if (!restored) {
1450
1555
  else openDefaultSourceFile();
1451
1556
  }
1452
1557
  initSourceTreeFolds();
1558
+ syncRail(); // reflect the initial view on the activity rail
1453
1559
  // Electron receives live updates over IPC (monacoriMenu.onDiffUpdate); only serve/browser needs the HTTP
1454
1560
  // poller. Under file:// its fetch just fails every 1.5s for the app's whole life, so skip it in Electron.
1455
1561
  if (watchEnabled && !(window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function')) {
@@ -1485,7 +1591,10 @@ window.addEventListener('beforeunload', saveUiState);
1485
1591
  });
1486
1592
  document.addEventListener('mousemove', (event) => {
1487
1593
  if (!resizing) return;
1488
- const width = Math.min(640, Math.max(180, event.clientX));
1594
+ // Subtract the activity rail's width: the sidebar starts to its right, so its width is the cursor X
1595
+ // minus the rail offset (not clientX itself, which would over-size it by the rail width).
1596
+ const railW = parseFloat(getComputedStyle(document.body).getPropertyValue('--rail-width')) || 0;
1597
+ const width = Math.min(640, Math.max(180, event.clientX - railW));
1489
1598
  document.documentElement.style.setProperty('--sidebar-width', width + 'px');
1490
1599
  });
1491
1600
  document.addEventListener('mouseup', () => {
@@ -1671,6 +1780,7 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
1671
1780
  var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
1672
1781
  diffCursor = { path: path, side: side, rowIndex: ri, column: col };
1673
1782
  pendingFileBoundary = null; // any caret move re-arms the last-change announcement for the next F7 (see next)
1783
+ hideCaretHint(); // caret moved (incl. crossing to the next file) → drop the "last change" hint so it never covers the new file
1674
1784
  diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
1675
1785
  if (reveal) {
1676
1786
  // Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
@@ -1911,6 +2021,28 @@ function showToast(message) {
1911
2021
  setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
1912
2022
  }, 4500);
1913
2023
  }
2024
+ // Inline hint anchored just under the diff caret — used for the F7 "last change" boundary announcement so the
2025
+ // message appears where the user is looking and fades on its own (unlike the corner toast). Falls back to the
2026
+ // corner toast when there's no on-screen caret (e.g. source view).
2027
+ var caretHintEl = null, caretHintTimer = 0;
2028
+ function showCaretHint(message) {
2029
+ var row = activeDiffRow || document.querySelector('#diff2html-container .diff-active-row');
2030
+ if (!row || !row.getBoundingClientRect) { showToast(message); return; }
2031
+ if (!caretHintEl) { caretHintEl = document.createElement('div'); caretHintEl.className = 'mc-caret-hint'; document.body.appendChild(caretHintEl); }
2032
+ caretHintEl.textContent = message;
2033
+ var r = row.getBoundingClientRect();
2034
+ caretHintEl.style.left = Math.round(Math.max(8, r.left)) + 'px';
2035
+ caretHintEl.style.top = Math.round(r.bottom + 4) + 'px';
2036
+ caretHintEl.classList.remove('show');
2037
+ void caretHintEl.offsetWidth; // reflow so the fade-in re-triggers on rapid repeat presses
2038
+ caretHintEl.classList.add('show');
2039
+ if (caretHintTimer) clearTimeout(caretHintTimer);
2040
+ caretHintTimer = setTimeout(function () { if (caretHintEl) caretHintEl.classList.remove('show'); }, 2000);
2041
+ }
2042
+ function hideCaretHint() {
2043
+ if (caretHintTimer) { clearTimeout(caretHintTimer); caretHintTimer = 0; }
2044
+ if (caretHintEl) caretHintEl.classList.remove('show');
2045
+ }
1914
2046
  // Follow each comment to its snapshot line (c.code) in the current content: same line if unchanged, else the
1915
2047
  // nearest exact match of that line. A comment is NEVER auto-deleted. If its line can't be found we leave it
1916
2048
  // where it is — this happens routinely WITHOUT the file changing: a comment anchored to a deleted/old-side
@@ -2405,6 +2537,12 @@ function applyDockMaximized() {
2405
2537
  document.body.classList.toggle('dock-maximized', dockMaximized);
2406
2538
  }
2407
2539
  function toggleDockMaximized() {
2540
+ // Maximize only the panel you're FOCUSED in: the merged/memo dock (.dock-panel) or the terminal
2541
+ // (.terminal-panel). From the sidebar tree (treeFocusIndex >= 0) or the diff/source content this is a
2542
+ // no-op — pressing it there must NOT maximize a terminal you aren't actually in.
2543
+ if (treeFocusIndex >= 0) return;
2544
+ var ae = document.activeElement;
2545
+ if (!(ae && ae.closest && (ae.closest('.dock-panel') || ae.closest('.terminal-panel')))) return;
2408
2546
  if (!activeDockPanel()) return; // nothing docked -> nothing to maximize
2409
2547
  dockMaximized = !dockMaximized;
2410
2548
  applyDockMaximized();
@@ -2423,6 +2561,7 @@ function closeMergedMemoDocks() {
2423
2561
  // terminal dock but never for these floating panels.
2424
2562
  document.body.classList.toggle('floating-dock', !!(document.getElementById('mc-merged-panel') || document.getElementById('mc-memo-panel')));
2425
2563
  applyDockMaximized();
2564
+ if (typeof syncRail === 'function') syncRail(); // clear the rail icon for the closed dock(s)
2426
2565
  }
2427
2566
  window.__monacoriCloseDocks = closeMergedMemoDocks;
2428
2567
  // Retry-focus a docked field (Electron async-restores focus to <body>, so a one-shot focus can lose the race).
@@ -2507,6 +2646,7 @@ function mountDock(id, titleText) {
2507
2646
  document.body.classList.add('dock-open');
2508
2647
  document.body.classList.add('floating-dock'); // scopes the maximize CSS so it doesn't hide the diff
2509
2648
  applyDockMaximized();
2649
+ if (typeof syncRail === 'function') syncRail(); // light up the rail icon for the opened dock
2510
2650
  return { panel: panel, body: body, bar: bar, close: close };
2511
2651
  }
2512
2652
 
@@ -2681,6 +2821,7 @@ refreshComments();
2681
2821
 
2682
2822
  function setActive(p) {
2683
2823
  active = p;
2824
+ if (p && p.labelEl) p.labelEl.classList.remove('has-bell'); // viewing the pane clears its bell badge
2684
2825
  panes.forEach(function (q) {
2685
2826
  q.el.classList.toggle('is-active', q === p);
2686
2827
  // 2+ panes: dim every pane but the active one (no border, just a clean focus cue). A lone pane stays full.
@@ -2694,6 +2835,11 @@ refreshComments();
2694
2835
  });
2695
2836
  }
2696
2837
 
2838
+ function copyToClipboard(text) {
2839
+ if (!text) return;
2840
+ try { if (window.monacoriClipboard && window.monacoriClipboard.write) { window.monacoriClipboard.write(text); return; } } catch (e) {}
2841
+ try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text); } catch (e) {}
2842
+ }
2697
2843
  function makePane() {
2698
2844
  if (!ensureXterm()) return null; // xterm unavailable — leave the panel empty rather than throw
2699
2845
  var el = document.createElement('div');
@@ -2729,6 +2875,9 @@ refreshComments();
2729
2875
  // Match the PHYSICAL key (e.code), not e.key: under a non-Latin layout/IME (e.g. Korean 한글)
2730
2876
  // Cmd+V reports e.key as 'ㅍ', so a key-based check misses it — blurring the terminal and
2731
2877
  // breaking paste/copy/cut/select-all whenever the Korean input source is active.
2878
+ // Cmd+C with a terminal selection: copy it ourselves — xterm doesn't auto-copy and the menu/native
2879
+ // copy misses xterm's own selection, so Cmd+C silently did nothing. No selection -> fall through.
2880
+ if (e.code === 'KeyC' && term.hasSelection && term.hasSelection()) { copyToClipboard(term.getSelection()); return false; }
2732
2881
  if (e.code === 'KeyC' || e.code === 'KeyV' || e.code === 'KeyX' || e.code === 'KeyA') return true;
2733
2882
  try { term.blur(); } catch (x) {}
2734
2883
  return false;
@@ -2736,6 +2885,14 @@ refreshComments();
2736
2885
  return true;
2737
2886
  });
2738
2887
  term.onData(function (d) { if (pane.id != null) window.monacoriPty.write({ id: pane.id, data: d }); });
2888
+ // Bell from the pane's TUI (e.g. Claude Code finished a turn / needs input): badge the pane when it isn't
2889
+ // the one you're looking at, and ask the main process to raise a native notification when the whole window
2890
+ // isn't focused. Toggle in Settings ("Notify when a terminal task finishes").
2891
+ term.onBell(function () {
2892
+ if (pane !== active && pane.labelEl) pane.labelEl.classList.add('has-bell');
2893
+ if (persistRead('monacori-terminal-bell-notify') === false) return; // OS notifications disabled
2894
+ try { window.monacoriPty.bell({ title: 'monacori', body: pane.name + ' — ' + t('notify.bellBody') }); } catch (e) {}
2895
+ });
2739
2896
  el.addEventListener('mousedown', function (e) { if (e.target !== labelEl) setActive(pane); });
2740
2897
  labelEl.addEventListener('dblclick', function () { renamePane(pane); });
2741
2898
  panes.push(pane);
@@ -2783,10 +2940,12 @@ refreshComments();
2783
2940
  }
2784
2941
 
2785
2942
  function removePane(id) {
2786
- var i = -1;
2787
- for (var k = 0; k < panes.length; k++) { if (panes[k].id === id) { i = k; break; } }
2943
+ for (var k = 0; k < panes.length; k++) { if (panes[k].id === id) { removePaneRef(panes[k]); return; } }
2944
+ }
2945
+ // Remove a pane by object reference (handles panes whose pty id hasn't arrived yet — spawn is async).
2946
+ function removePaneRef(p) {
2947
+ var i = panes.indexOf(p);
2788
2948
  if (i < 0) return;
2789
- var p = panes[i];
2790
2949
  try { p.term.dispose(); } catch (e) {}
2791
2950
  if (p.el.parentNode) p.el.parentNode.removeChild(p.el);
2792
2951
  panes.splice(i, 1);
@@ -2794,6 +2953,15 @@ refreshComments();
2794
2953
  if (panes.length === 0) setOpen(false);
2795
2954
  else fitAll();
2796
2955
  }
2956
+ // Cmd/Ctrl+W inside the terminal: close just the FOCUSED pane (kill its pty), not the whole panel. The
2957
+ // last pane closing collapses the panel via removePaneRef -> setOpen(false). Remove the pane immediately
2958
+ // (don't wait for the pty's onExit) so the UI responds at once; the later onExit -> removePane no-ops.
2959
+ function closeActivePane() {
2960
+ var p = active || panes[panes.length - 1];
2961
+ if (!p) { setOpen(false); return; }
2962
+ if (p.id != null) { try { window.monacoriPty.kill({ id: p.id }); } catch (e) {} }
2963
+ removePaneRef(p);
2964
+ }
2797
2965
 
2798
2966
  function split() {
2799
2967
  if (panes.length >= MAX_PANES) return;
@@ -2927,8 +3095,12 @@ refreshComments();
2927
3095
  }, true);
2928
3096
  window.__monacoriTerminal = {
2929
3097
  isOpen: isOpen,
3098
+ // True when keyboard focus is inside the terminal panel (a pane owns it) — Cmd/Ctrl+W uses this to
3099
+ // decide between closing a pane and closing a source tab.
3100
+ hasFocus: function () { var ae = document.activeElement; return !!(ae && panel.contains(ae)); },
2930
3101
  open: function () { setOpen(true); },
2931
3102
  paneCount: function () { return panes.length; },
3103
+ closeActivePane: closeActivePane,
2932
3104
  enterSendMode: enterSendMode,
2933
3105
  send: function (text) { writeToPane(active || panes[0], text); },
2934
3106
  sendToPane: function (i, text) { writeToPane(panes[i] || active || panes[0], text); },
@@ -2956,10 +3128,11 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function
2956
3128
  window.monacoriMenu.onDiffUpdate(function (html) { try { applyDiffUpdate(html); } catch (e) {} });
2957
3129
  }
2958
3130
  if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function') {
2959
- // Cmd/Ctrl+W: close the active Files-mode tab (no-op outside the source viewer).
3131
+ // Cmd/Ctrl+W: close whatever the focus is on. A focused terminal pane closes just that pane (the last
3132
+ // pane collapses the panel); otherwise close the active Files-mode tab (no-op outside the source viewer).
2960
3133
  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; }
3134
+ var term = window.__monacoriTerminal;
3135
+ if (term && term.isOpen() && term.hasFocus()) { term.closeActivePane(); return; }
2963
3136
  if (isSourceViewerVisible()) closeActiveSourceTab();
2964
3137
  });
2965
3138
  }
@@ -3070,6 +3243,12 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
3070
3243
  if (qta) qta.addEventListener('input', function () { saveMergePrompt('q', qta.value); flash(); });
3071
3244
  if (cta) cta.addEventListener('input', function () { saveMergePrompt('c', cta.value); flash(); });
3072
3245
  if (resetBtn) resetBtn.addEventListener('click', function () { saveMergePrompt('q', ''); saveMergePrompt('c', ''); fill(); flash(); });
3246
+ // Terminal-bell notification toggle (default ON — persistRead returns undefined when never set).
3247
+ var bellCb = document.getElementById('set-bell-notify');
3248
+ if (bellCb) {
3249
+ bellCb.checked = persistRead('monacori-terminal-bell-notify') !== false;
3250
+ bellCb.addEventListener('change', function () { persistSave('monacori-terminal-bell-notify', bellCb.checked); });
3251
+ }
3073
3252
  // Language: live-switch the whole UI (no reload). Persist, re-apply the static chrome, then re-render
3074
3253
  // any currently-shown dynamic text (open composer / merged modal / index status) so it follows too.
3075
3254
  langSelectRef = setupCustomSelect('settings-language',
@@ -3103,6 +3282,31 @@ function setTab(name) {
3103
3282
  });
3104
3283
  document.getElementById('changes-panel')?.classList.toggle('hidden', name !== 'changes');
3105
3284
  document.getElementById('files-panel')?.classList.toggle('hidden', name !== 'files');
3285
+ syncRail();
3286
+ }
3287
+ // Reflect the current view/dock state on the activity rail icons (active highlight). Terminal active is
3288
+ // kept in sync separately by the dock-terminal setOpen (it toggles is-active on #terminal-toggle).
3289
+ function syncRail() {
3290
+ var rail = document.querySelector('.activity-rail');
3291
+ if (!rail) return;
3292
+ var setOn = function (view, on) {
3293
+ var btn = rail.querySelector('[data-view="' + view + '"]');
3294
+ if (btn) btn.classList.toggle('is-active', !!on);
3295
+ };
3296
+ setOn('changes', !document.getElementById('changes-panel')?.classList.contains('hidden'));
3297
+ setOn('files', !document.getElementById('files-panel')?.classList.contains('hidden'));
3298
+ var merged = document.getElementById('mc-merged-panel');
3299
+ setOn('q', !!(merged && merged.dataset.kind === 'q'));
3300
+ setOn('c', !!(merged && merged.dataset.kind === 'c'));
3301
+ setOn('memo', !!document.getElementById('mc-memo-panel'));
3302
+ var hv = document.getElementById('history-view');
3303
+ setOn('history', !!(hv && !hv.classList.contains('hidden')));
3304
+ }
3305
+ // Rail click for the merged views toggles: a 2nd click on the open kind closes it (memo already toggles).
3306
+ function toggleMergedRail(kind) {
3307
+ var m = document.getElementById('mc-merged-panel');
3308
+ if (m && m.dataset.kind === kind) { closeMergedMemoDocks(); return; }
3309
+ openMergedView(kind);
3106
3310
  }
3107
3311
  // Big repos ship the source tree as an inert island (see render.ts); build it the first time the Files
3108
3312
  // tab is opened so the (potentially huge) tree never blocks startup. No-op for inline (small) trees.
@@ -3218,6 +3422,10 @@ function applyDiffUpdate(u) {
3218
3422
  var wasSource = isSourceViewerVisible();
3219
3423
  var container = document.getElementById('diff2html-container');
3220
3424
  var diffScrollTop = container ? container.scrollTop : 0;
3425
+ // The active hunk's file path BEFORE the swap (hunkMeta/hunks still hold the old build here). After a commit
3426
+ // the old active file can vanish from the new diff, so we re-anchor `current` to it below — otherwise it
3427
+ // dangles at a stale index and showDiffView renders blank with a stale breadcrumb.
3428
+ var prevActivePath = current >= 0 ? hunkPathAt(current) : '';
3221
3429
  // Did the file the user is CURRENTLY viewing actually change in this build? If not, we must not re-render
3222
3430
  // the source view — an unrelated file's edit would otherwise flicker the pane they're reading. Capture the
3223
3431
  // open file's signature BEFORE fileSignatureByPath is rebuilt below.
@@ -3248,6 +3456,13 @@ function applyDiffUpdate(u) {
3248
3456
  if (filesPanel && (!REVIEW_LAZY || filesPanel.innerHTML.trim())) filesPanel.innerHTML = u.filesTree || '';
3249
3457
  var statusEl = document.querySelector('.review-status');
3250
3458
  if (statusEl) statusEl.innerHTML = u.reviewStatus || '';
3459
+ // Branch can change between watch ticks (checkout/commit) — keep the sidebar chip current.
3460
+ var branchName = document.getElementById('brand-branch-name');
3461
+ if (branchName) {
3462
+ branchName.textContent = u.branch || '';
3463
+ var branchChip = branchName.closest && branchName.closest('.brand-branch');
3464
+ if (branchChip) branchChip.classList.toggle('hidden', !u.branch);
3465
+ }
3251
3466
  if (reviewMeta) { reviewMeta.setAttribute('data-signature', u.signature); if (u.generatedAt) reviewMeta.setAttribute('data-generated-at', u.generatedAt); }
3252
3467
 
3253
3468
  // 2) Re-derive module-level state directly from the payload objects.
@@ -3264,6 +3479,16 @@ function applyDiffUpdate(u) {
3264
3479
  links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
3265
3480
  sourceLinks = Array.from(document.querySelectorAll('.source-link'));
3266
3481
 
3482
+ // Reconcile the active hunk against the new build (uses the just-rebuilt `links`). A committed/removed file
3483
+ // reshuffles or shrinks the diff: re-anchor `current` to the same file's new hunk when it survives, else
3484
+ // drop to -1 so the diff lands on the first change rather than a dangling index that paints nothing.
3485
+ var activeFilePreserved = false;
3486
+ if (prevActivePath) {
3487
+ var reHunk = firstHunkForPath(prevActivePath);
3488
+ if (reHunk >= 0) { current = reHunk; activeFilePreserved = true; }
3489
+ else current = -1;
3490
+ }
3491
+
3267
3492
  // 3) Reset lazy-materialize + index state so the new diff bodies / source / symbols rebuild on demand.
3268
3493
  // bodyCache is keyed by file INDEX, not content — after a watch rebuild the same index maps to the new
3269
3494
  // body, so it MUST be dropped too. Clearing only bodyPromise left loadBodyHtml() returning the cached
@@ -3277,14 +3502,11 @@ function applyDiffUpdate(u) {
3277
3502
  // sourceBodyPath so the already-painted (unchanged) source view is left exactly as-is — no flicker.
3278
3503
  if (openFileChanged) sourceBodyPath = null;
3279
3504
  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
3505
 
3284
3506
  // 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.
3507
+ // flicker). Runs BEFORE setupLazyDiff so the IntersectionObserver sees them already materialized and never
3508
+ // re-fetches them. The fresh wrapper carries the correct data-first-hunk + file index, so materializeBody
3509
+ // numbers hunks exactly as a normal lazy load would. Changed/new files stay shells and lazy-load as usual.
3288
3510
  if (REVIEW_LAZY && container) {
3289
3511
  container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
3290
3512
  var p = diffWrapperPathKey(w);
@@ -3298,6 +3520,10 @@ function applyDiffUpdate(u) {
3298
3520
  bodyPromise[idx] = Promise.resolve(w);
3299
3521
  });
3300
3522
  }
3523
+ refreshHunkIndex(); // rebuild hunks/hunkMeta from the swapped-in DOM so hunkTotal()/hunkPathAt() aren't stale
3524
+ if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
3525
+ else { diffBootDone = true; }
3526
+ if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
3301
3527
 
3302
3528
  // 4) Re-run the DOM-dependent bootstrap steps.
3303
3529
  applyI18n();
@@ -3312,7 +3538,10 @@ function applyDiffUpdate(u) {
3312
3538
  if (openFileChanged) openSourceFile(openPath, false);
3313
3539
  } else if (container) {
3314
3540
  showDiffView(false);
3315
- container.scrollTop = diffScrollTop;
3541
+ // Same active file survived → keep the user's exact scroll. If it was committed away (current reset to
3542
+ // -1, showDiffView landed on the first change), restoring the old, now-out-of-range scrollTop would push
3543
+ // the shorter new diff off-screen and look blank — so reset to the top instead.
3544
+ container.scrollTop = activeFilePreserved ? diffScrollTop : 0;
3316
3545
  }
3317
3546
  return true;
3318
3547
  }
@@ -3961,6 +4190,39 @@ function showUsages(name, count) {
3961
4190
  if (title) title.textContent = count + ' usage' + (count === 1 ? '' : 's') + ' of ' + name;
3962
4191
  renderUsages();
3963
4192
  box.classList.remove('hidden');
4193
+ positionUsagesAtCaret();
4194
+ }
4195
+ // Anchor the usages popup just below (or above, if cramped) the live caret — source OR diff both render a
4196
+ // `.code-cursor` span. No caret on screen → leave the centered overlay fallback in place.
4197
+ function positionUsagesAtCaret() {
4198
+ var box = document.getElementById('usages');
4199
+ if (!box) return;
4200
+ var panel = box.querySelector('.quick-open-panel');
4201
+ if (!panel) return;
4202
+ resetUsagesAnchor(box, panel); // measure from a clean slate
4203
+ var caret = document.querySelector('#source-body .code-cursor') || document.querySelector('#diff2html-container .code-cursor');
4204
+ if (!caret) return;
4205
+ var rect = caret.getBoundingClientRect();
4206
+ if (!rect.height && !rect.width && !rect.top) return; // detached / off-layout
4207
+ var vw = window.innerWidth, vh = window.innerHeight, gap = 6, margin = 8;
4208
+ var pw = Math.min(560, vw - margin * 2);
4209
+ var left = Math.min(Math.max(margin, rect.left), vw - pw - margin);
4210
+ box.classList.add('anchored');
4211
+ panel.style.width = pw + 'px';
4212
+ panel.style.left = left + 'px';
4213
+ var spaceBelow = vh - rect.bottom - gap - margin;
4214
+ var spaceAbove = rect.top - gap - margin;
4215
+ if (spaceBelow >= 200 || spaceBelow >= spaceAbove) {
4216
+ panel.style.top = (rect.bottom + gap) + 'px';
4217
+ panel.style.maxHeight = Math.max(120, spaceBelow) + 'px';
4218
+ } else {
4219
+ panel.style.bottom = (vh - rect.top + gap) + 'px';
4220
+ panel.style.maxHeight = Math.max(120, spaceAbove) + 'px';
4221
+ }
4222
+ }
4223
+ function resetUsagesAnchor(box, panel) {
4224
+ box.classList.remove('anchored');
4225
+ panel.style.left = panel.style.top = panel.style.bottom = panel.style.width = panel.style.maxHeight = '';
3964
4226
  }
3965
4227
  function renderUsages() {
3966
4228
  var results = document.getElementById('usages-results');
@@ -3998,7 +4260,11 @@ function openUsageItem(item) {
3998
4260
  openSourceAt(item.path, item.lineIndex, item.column);
3999
4261
  }
4000
4262
  function closeUsages() {
4001
- document.getElementById('usages')?.classList.add('hidden');
4263
+ var box = document.getElementById('usages');
4264
+ if (!box) return;
4265
+ box.classList.add('hidden');
4266
+ var panel = box.querySelector('.quick-open-panel');
4267
+ if (panel) resetUsagesAnchor(box, panel); // clear inline anchoring so the next open re-measures cleanly
4002
4268
  }
4003
4269
 
4004
4270
  var symbolIndex = null; // Map<name, [{path,lineIndex,column}]>; built off-thread by a Web Worker, null until ready
@@ -4176,11 +4442,18 @@ function renderSourceTabs(activePath) {
4176
4442
  var active = p === activePath;
4177
4443
  return '<div class="source-tab' + (active ? ' active' : '') + '" data-tab-path="' + escapeHtml(p) + '" title="' + escapeHtml(p) + '">'
4178
4444
  + '<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>'
4445
+ + '<button type="button" class="source-tab-close" data-close-path="' + escapeHtml(p) + '" aria-label="Close tab" title="Close (W)">×</button>'
4180
4446
  + '</div>';
4181
4447
  }).join('');
4448
+ // Scroll the tab bar HORIZONTALLY only. scrollIntoView() walks every scrollable ancestor — on rapid
4449
+ // Cmd+Shift+[/] cycling it nudged a vertical ancestor and clipped the tab strip at the top. Adjusting
4450
+ // bar.scrollLeft directly keeps the active tab in view without ever touching vertical scroll.
4182
4451
  var act = bar.querySelector('.source-tab.active');
4183
- if (act && act.scrollIntoView) act.scrollIntoView({ block: 'nearest', inline: 'nearest' });
4452
+ if (act) {
4453
+ var bl = bar.getBoundingClientRect(), al = act.getBoundingClientRect();
4454
+ if (al.left < bl.left) bar.scrollLeft -= (bl.left - al.left) + 8;
4455
+ else if (al.right > bl.right) bar.scrollLeft += (al.right - bl.right) + 8;
4456
+ }
4184
4457
  }
4185
4458
  function closeSourceTab(path) {
4186
4459
  var idx = sourceTabs.indexOf(path);
@@ -4653,7 +4926,7 @@ function renderHttpTable(file) {
4653
4926
  const reqIdx = hasRun ? runAtLine[index] : -1;
4654
4927
  const isCursorLine = Boolean(cursor && cursor.lineIndex === index);
4655
4928
  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>'
4929
+ ? '<button type="button" class="http-run" data-req="' + reqIdx + '" title="Run request (⌘Enter /Enter)" aria-label="Run request">&#9654;</button>'
4657
4930
  : '';
4658
4931
  rows += '<tr class="source-row http-row' + (hasRun ? ' http-request-line' : '') + (isCursorLine ? ' cursor-line' : '') + '" data-line-index="' + index + '">'
4659
4932
  + '<td class="num http-gutter">' + gutter + '<span class="num-text">' + (index + 1) + '</span></td>'
@@ -4937,3 +5210,328 @@ function formatBytes(bytes) {
4937
5210
  if (kib < 1024) return kib.toFixed(1) + ' KiB';
4938
5211
  return (kib / 1024).toFixed(1) + ' MiB';
4939
5212
  }
5213
+ // ===== Git history view (Cmd+9): commit list with graph lanes + per-commit diff. =====
5214
+ // Data comes from the main process (window.monacoriGit.log / .commitDiff); the lane layout is computed
5215
+ // here from each commit's parents. Read-only — the per-commit diff is static diff2html HTML.
5216
+
5217
+ var HISTORY_LANE_W = 14, HISTORY_DOT_R = 3.5, HISTORY_ROW_H = 24;
5218
+ var HISTORY_COLORS = ['#6c9fd4', '#7faf6b', '#d4a857', '#c77dd4', '#d36c6c', '#5bb6b6', '#b0884f', '#8d8df0'];
5219
+ var historyCommits = [];
5220
+ var historyGraph = [];
5221
+ var historyMaxLane = 0;
5222
+ var historyActiveSha = '';
5223
+ var historyLoading = false;
5224
+
5225
+ // Lane layout. Walks commits newest-first, tracking open edges (lanes) by the hash each expects next.
5226
+ // Returns per-row { hash, myLane, color, topEdges, bottomEdges } using LANE INDICES + COLOR INDICES (px-free,
5227
+ // so it's unit-testable). First parent inherits the commit's color so a branch keeps one hue down its line.
5228
+ function computeHistoryGraph(commits) {
5229
+ var lanes = []; // lane index -> hash the lane is waiting to reach (open edge from above)
5230
+ var colorOf = {}; // hash -> color index
5231
+ var next = 0;
5232
+ function colorFor(h) { if (colorOf[h] == null) colorOf[h] = next++; return colorOf[h]; }
5233
+ function freeLane() { for (var i = 0; i < lanes.length; i++) if (lanes[i] == null) return i; lanes.push(null); return lanes.length - 1; }
5234
+ var rows = [];
5235
+ var maxLane = 0;
5236
+ for (var ci = 0; ci < commits.length; ci++) {
5237
+ var c = commits[ci];
5238
+ var incoming = lanes.slice();
5239
+ var myLane = lanes.indexOf(c.hash);
5240
+ if (myLane === -1) myLane = freeLane();
5241
+ var myColor = colorFor(c.hash);
5242
+ lanes[myLane] = c.hash;
5243
+ for (var i = 0; i < lanes.length; i++) if (i !== myLane && lanes[i] === c.hash) lanes[i] = null; // merge other edges in
5244
+ var parents = c.parents || [];
5245
+ var parentLanes = {};
5246
+ if (parents.length === 0) {
5247
+ lanes[myLane] = null; // root commit — the lane ends here
5248
+ } else {
5249
+ lanes[myLane] = parents[0];
5250
+ if (colorOf[parents[0]] == null) colorOf[parents[0]] = myColor; // first parent keeps the hue
5251
+ parentLanes[myLane] = true;
5252
+ for (var p = 1; p < parents.length; p++) {
5253
+ var ex = lanes.indexOf(parents[p]);
5254
+ var l = ex !== -1 ? ex : freeLane();
5255
+ lanes[l] = parents[p];
5256
+ colorFor(parents[p]);
5257
+ parentLanes[l] = true;
5258
+ }
5259
+ }
5260
+ var outgoing = lanes.slice();
5261
+ var topEdges = [];
5262
+ for (var a = 0; a < incoming.length; a++) {
5263
+ if (incoming[a] == null) continue;
5264
+ topEdges.push({ from: a, to: incoming[a] === c.hash ? myLane : a, color: colorOf[incoming[a]] });
5265
+ }
5266
+ var bottomEdges = [];
5267
+ for (var b = 0; b < outgoing.length; b++) {
5268
+ if (outgoing[b] == null) continue;
5269
+ bottomEdges.push({ from: parentLanes[b] ? myLane : b, to: b, color: colorOf[outgoing[b]] });
5270
+ }
5271
+ for (var m = 0; m < Math.max(incoming.length, outgoing.length); m++) {
5272
+ if (incoming[m] != null || outgoing[m] != null) maxLane = Math.max(maxLane, m);
5273
+ }
5274
+ maxLane = Math.max(maxLane, myLane);
5275
+ rows.push({ hash: c.hash, myLane: myLane, color: myColor, topEdges: topEdges, bottomEdges: bottomEdges });
5276
+ }
5277
+ rows.maxLane = maxLane;
5278
+ return rows;
5279
+ }
5280
+ if (typeof window !== 'undefined') window.computeHistoryGraph = computeHistoryGraph; // exposed for tests
5281
+
5282
+ function historyLaneX(l) { return 9 + l * HISTORY_LANE_W; }
5283
+ function historyColor(i) { return HISTORY_COLORS[i % HISTORY_COLORS.length]; }
5284
+ function historyRowSvg(row) {
5285
+ var w = historyLaneX(historyMaxLane) + 9, h = HISTORY_ROW_H, mid = h / 2;
5286
+ var s = '<svg class="hgraph" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" aria-hidden="true">';
5287
+ var edge = function (e, y1, y2) {
5288
+ var x1 = historyLaneX(e.from), x2 = historyLaneX(e.to);
5289
+ var c1 = (y1 + y2) / 2;
5290
+ return '<path d="M' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + c1 + ', ' + x2 + ' ' + c1 + ', ' + x2 + ' ' + y2 + '" stroke="' + historyColor(e.color) + '" fill="none" stroke-width="1.6"/>';
5291
+ };
5292
+ row.topEdges.forEach(function (e) { s += edge(e, 0, mid); });
5293
+ row.bottomEdges.forEach(function (e) { s += edge(e, mid, h); });
5294
+ s += '<circle cx="' + historyLaneX(row.myLane) + '" cy="' + mid + '" r="' + HISTORY_DOT_R + '" fill="' + historyColor(row.color) + '"/></svg>';
5295
+ return s;
5296
+ }
5297
+
5298
+ // "HEAD -> main, origin/main, tag: v1" -> small badges (HEAD/branch/tag styled distinctly).
5299
+ function historyRefBadges(refs) {
5300
+ if (!refs || !refs.trim()) return '';
5301
+ return refs.split(',').map(function (r) {
5302
+ r = r.trim();
5303
+ if (!r) return '';
5304
+ var cls = 'href-branch', label = r;
5305
+ if (r.indexOf('tag:') === 0) { cls = 'href-tag'; label = r.replace('tag:', '').trim(); }
5306
+ else if (r.indexOf('HEAD') === 0) { cls = 'href-head'; }
5307
+ else if (r.indexOf('origin/') === 0 || r.indexOf('/') !== -1) { cls = 'href-remote'; }
5308
+ return '<span class="href ' + cls + '">' + escapeHtml(label) + '</span>';
5309
+ }).join('');
5310
+ }
5311
+
5312
+ function historyShortDate(iso) {
5313
+ if (!iso) return '';
5314
+ // 2026-06-20T21:03:11+09:00 -> "2026-06-20 21:03"
5315
+ var m = String(iso).match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/);
5316
+ return m ? m[1] + ' ' + m[2] : String(iso).slice(0, 16);
5317
+ }
5318
+
5319
+ function renderHistoryList() {
5320
+ var list = document.getElementById('history-list');
5321
+ if (!list) return;
5322
+ if (!historyCommits.length) {
5323
+ list.innerHTML = '<div class="quick-open-empty">' + escapeHtml(t(historyLoading ? 'history.loading' : 'history.empty')) + '</div>';
5324
+ return;
5325
+ }
5326
+ list.style.setProperty('--hgraph-w', (historyLaneX(historyMaxLane) + 9) + 'px');
5327
+ list.innerHTML = historyCommits.map(function (c, i) {
5328
+ return '<button type="button" class="hrow' + (c.hash === historyActiveSha ? ' active' : '') + '" data-sha="' + escapeHtml(c.hash) + '">'
5329
+ + '<span class="hgraph-cell">' + historyRowSvg(historyGraph[i]) + '</span>'
5330
+ + '<span class="hmsg">' + historyRefBadges(c.refs) + escapeHtml(c.subject) + '</span>'
5331
+ + '<span class="hauthor">' + escapeHtml(c.author) + '</span>'
5332
+ + '<span class="hdate">' + escapeHtml(historyShortDate(c.date)) + '</span>'
5333
+ + '</button>';
5334
+ }).join('');
5335
+ }
5336
+
5337
+ // Text filter (subject / author). The graph only reads right on the full contiguous history, so filtering
5338
+ // hides the graph column (IntelliJ does the same) and just shows matching rows.
5339
+ function applyHistoryFilter() {
5340
+ var input = document.getElementById('history-search');
5341
+ var list = document.getElementById('history-list');
5342
+ if (!list) return;
5343
+ var q = (input && input.value || '').trim().toLowerCase();
5344
+ list.classList.toggle('filtering', q.length > 0);
5345
+ var rows = list.querySelectorAll('.hrow');
5346
+ for (var i = 0; i < rows.length; i++) {
5347
+ var c = historyCommits[i];
5348
+ var hit = !q || (c.subject + '\n' + c.author + '\n' + c.hash).toLowerCase().indexOf(q) !== -1;
5349
+ rows[i].classList.toggle('hidden', !hit);
5350
+ }
5351
+ }
5352
+
5353
+ function openHistoryCommit(sha) {
5354
+ if (!sha || !window.monacoriGit) return;
5355
+ historyActiveSha = sha;
5356
+ var list = document.getElementById('history-list');
5357
+ if (list) list.querySelectorAll('.hrow').forEach(function (r) { r.classList.toggle('active', r.dataset.sha === sha); });
5358
+ var detail = document.getElementById('history-detail');
5359
+ if (detail) detail.innerHTML = '<div class="quick-open-empty">' + escapeHtml(t('history.loading')) + '</div>';
5360
+ Promise.resolve(window.monacoriGit.commitDiff(sha)).then(function (d) {
5361
+ if (!d || historyActiveSha !== sha) return; // selection moved on while loading
5362
+ renderHistoryDetail(d);
5363
+ }, function () {});
5364
+ }
5365
+
5366
+ function renderHistoryDetail(d) {
5367
+ var detail = document.getElementById('history-detail');
5368
+ if (!detail) return;
5369
+ var head = '<div class="history-detail-head">'
5370
+ + '<div class="hd-msg">' + escapeHtml(d.message || '').replace(/\n/g, '<br>') + '</div>'
5371
+ + '<div class="hd-meta"><span class="hd-hash">' + escapeHtml((d.hash || '').slice(0, 10)) + '</span>'
5372
+ + '<span class="hd-author">' + escapeHtml(d.author) + (d.email ? ' &lt;' + escapeHtml(d.email) + '&gt;' : '') + '</span>'
5373
+ + '<span class="hd-date">' + escapeHtml(historyShortDate(d.date)) + '</span>'
5374
+ + historyRefBadges(d.refs) + '</div></div>';
5375
+ var body = (d.diffHtml && d.diffHtml.trim())
5376
+ ? '<div class="history-diff diff2html-container">' + d.diffHtml + '</div>'
5377
+ : '<div class="quick-open-empty">' + escapeHtml(t(d.isMerge ? 'history.merge' : 'history.noDiff')) + '</div>';
5378
+ detail.innerHTML = head + body;
5379
+ }
5380
+
5381
+ function isHistoryOpen() {
5382
+ var v = document.getElementById('history-view');
5383
+ return !!(v && !v.classList.contains('hidden'));
5384
+ }
5385
+ function closeHistory() {
5386
+ var v = document.getElementById('history-view');
5387
+ if (v) v.classList.add('hidden');
5388
+ if (typeof syncRail === 'function') syncRail();
5389
+ }
5390
+ function openHistory() {
5391
+ var v = document.getElementById('history-view');
5392
+ if (!v) return;
5393
+ if (!window.monacoriGit) return; // browser/serve mode: no git bridge
5394
+ v.classList.remove('hidden');
5395
+ if (typeof syncRail === 'function') syncRail();
5396
+ var search = document.getElementById('history-search');
5397
+ if (search) { search.value = ''; }
5398
+ applyHistoryFilter();
5399
+ historyLoading = true;
5400
+ renderHistoryList();
5401
+ Promise.resolve(window.monacoriGit.log({ limit: 300 })).then(function (commits) {
5402
+ historyLoading = false;
5403
+ historyCommits = Array.isArray(commits) ? commits : [];
5404
+ historyGraph = computeHistoryGraph(historyCommits);
5405
+ historyMaxLane = historyGraph.maxLane || 0;
5406
+ renderHistoryList();
5407
+ var detail = document.getElementById('history-detail');
5408
+ if (detail) detail.innerHTML = '<div class="quick-open-empty">' + escapeHtml(t('history.selectCommit')) + '</div>';
5409
+ if (historyCommits[0]) openHistoryCommit(historyCommits[0].hash); // preview the newest commit
5410
+ if (search) setTimeout(function () { try { search.focus(); } catch (e) {} }, 0);
5411
+ }, function () { historyLoading = false; renderHistoryList(); });
5412
+ }
5413
+ function toggleHistory() { if (isHistoryOpen()) closeHistory(); else openHistory(); }
5414
+ if (typeof window !== 'undefined') window.__monacoriHistory = { open: openHistory, close: closeHistory, toggle: toggleHistory, isOpen: isHistoryOpen };
5415
+
5416
+ (function wireHistory() {
5417
+ var list = document.getElementById('history-list');
5418
+ if (list) list.addEventListener('click', function (e) {
5419
+ var row = e.target.closest && e.target.closest('.hrow[data-sha]');
5420
+ if (row) openHistoryCommit(row.dataset.sha);
5421
+ });
5422
+ var search = document.getElementById('history-search');
5423
+ if (search) search.addEventListener('input', applyHistoryFilter);
5424
+ var closeBtn = document.getElementById('history-close');
5425
+ if (closeBtn) closeBtn.addEventListener('click', closeHistory);
5426
+ var view = document.getElementById('history-view');
5427
+ if (view) view.addEventListener('keydown', function (e) {
5428
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeHistory(); }
5429
+ });
5430
+ })();
5431
+ // ===== Go-to-line (Cmd/Ctrl+L), copy caret location (Cmd/Ctrl+K), and the sidebar row action menu. =====
5432
+
5433
+ // Programmatic clipboard write. Electron's bridge is reliable on file://; navigator.clipboard is the fallback.
5434
+ function copyTextToClipboard(text) {
5435
+ try { if (window.monacoriClipboard && typeof window.monacoriClipboard.write === 'function') { window.monacoriClipboard.write(text); return true; } } catch (e) {}
5436
+ try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text); return true; } } catch (e) {}
5437
+ return false;
5438
+ }
5439
+
5440
+ // "path:line" for the current caret — source view (the painted file) or the diff caret. '' if neither.
5441
+ function caretLocation() {
5442
+ if (typeof isSourceViewerVisible === 'function' && isSourceViewerVisible()) {
5443
+ var sv = document.getElementById('source-viewer');
5444
+ var p = (sv && sv.dataset.openPath) || '';
5445
+ if (p && typeof viewerCursor !== 'undefined' && viewerCursor && viewerCursor.path === p) return p + ':' + (viewerCursor.lineIndex + 1);
5446
+ if (p) return p;
5447
+ }
5448
+ if (typeof isDiffViewVisible === 'function' && isDiffViewVisible() && typeof diffCursor !== 'undefined' && diffCursor) {
5449
+ var wrap = diffWrapperByPath(diffCursor.path);
5450
+ var row = wrap ? diffRowAt(wrap, diffCursor.side, diffCursor.rowIndex) : null;
5451
+ var ln = row ? diffLineNumber(row) : null;
5452
+ return diffCursor.path + (ln ? ':' + ln : '');
5453
+ }
5454
+ return '';
5455
+ }
5456
+
5457
+ // Cmd/Ctrl+K — copy the caret's file:line to the clipboard.
5458
+ function copyCaretLocation() {
5459
+ var loc = caretLocation();
5460
+ if (!loc) return;
5461
+ if (copyTextToClipboard(loc) && typeof showToast === 'function') showToast(t('goto.copied') + ' ' + loc);
5462
+ }
5463
+
5464
+ // Diff view: place the caret on the row whose (new, then old) line number matches n, in the active file.
5465
+ function gotoDiffLine(n) {
5466
+ var path = (typeof diffCursor !== 'undefined' && diffCursor && diffCursor.path) || '';
5467
+ if (!path && typeof diffActiveWrapper === 'function') {
5468
+ var w = diffActiveWrapper();
5469
+ var nm = w && w.querySelector('.d2h-file-name');
5470
+ if (nm && nm.textContent) path = nm.textContent.trim();
5471
+ }
5472
+ var wrap = path && diffWrapperByPath(path);
5473
+ if (!wrap) return;
5474
+ var sides = [(diffCursor && diffCursor.side) || 'new', 'new', 'old'];
5475
+ for (var s = 0; s < sides.length; s++) {
5476
+ var rows = diffRowsOf(diffSideTable(wrap, sides[s]));
5477
+ for (var i = 0; i < rows.length; i++) {
5478
+ if (diffLineNumber(rows[i]) === n) { setDiffCursor(path, sides[s], i, 0, true); return; }
5479
+ }
5480
+ }
5481
+ }
5482
+
5483
+ function gotoLineJump(n) {
5484
+ if (!(n >= 1)) return;
5485
+ if (typeof isSourceViewerVisible === 'function' && isSourceViewerVisible()) {
5486
+ var sv = document.getElementById('source-viewer');
5487
+ var p = (sv && sv.dataset.openPath) || '';
5488
+ var f = p && sourceByPath.get(p);
5489
+ if (f && f.embedded && typeof f.content === 'string') {
5490
+ var max = f.content.split(/\r?\n/).length;
5491
+ setSourceCursor(p, Math.max(0, Math.min(max - 1, n - 1)), 0, true, -1);
5492
+ return;
5493
+ }
5494
+ }
5495
+ if (typeof isDiffViewVisible === 'function' && isDiffViewVisible()) gotoDiffLine(n);
5496
+ }
5497
+
5498
+ // Cmd/Ctrl+L — a small numeric prompt; Enter jumps, Esc closes.
5499
+ function openGotoLine() {
5500
+ if (!((typeof isSourceViewerVisible === 'function' && isSourceViewerVisible()) || (typeof isDiffViewVisible === 'function' && isDiffViewVisible()))) return;
5501
+ var prior = document.getElementById('goto-line');
5502
+ if (prior) prior.remove();
5503
+ var box = document.createElement('div');
5504
+ box.id = 'goto-line';
5505
+ box.className = 'goto-line';
5506
+ var input = document.createElement('input');
5507
+ input.type = 'text';
5508
+ input.inputMode = 'numeric';
5509
+ input.className = 'goto-line-input';
5510
+ input.placeholder = t('goto.placeholder');
5511
+ box.appendChild(input);
5512
+ document.body.appendChild(box);
5513
+ function close() { box.remove(); document.removeEventListener('keydown', onKey, true); }
5514
+ function onKey(e) {
5515
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
5516
+ else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); var n = parseInt(input.value, 10); close(); if (n >= 1) gotoLineJump(n); }
5517
+ }
5518
+ // Capture phase so Enter/Esc are handled here before the global keymap (which is on bubble).
5519
+ document.addEventListener('keydown', onKey, true);
5520
+ setTimeout(function () { try { input.focus(); } catch (e) {} }, 0);
5521
+ }
5522
+
5523
+ // Sidebar Opt+Enter: actions for a focused file row (copy path / reveal in Finder / open terminal here).
5524
+ function openTreeRowMenu(row) {
5525
+ if (!row) return;
5526
+ var path = row.dataset.sourceFile || row.dataset.file || '';
5527
+ if (!path) return;
5528
+ var r = row.getBoundingClientRect();
5529
+ var items = [
5530
+ { label: t('menu.copyPath'), onSelect: function () { if (copyTextToClipboard(path) && typeof showToast === 'function') showToast(t('goto.copied') + ' ' + path); } },
5531
+ ];
5532
+ if (window.monacoriApp && typeof window.monacoriApp.revealInFinder === 'function') {
5533
+ items.push({ label: t('menu.revealFinder'), onSelect: function () { try { window.monacoriApp.revealInFinder(path); } catch (e) {} } });
5534
+ items.push({ label: t('menu.openTerminal'), onSelect: function () { try { window.monacoriApp.openTerminalAt(path); } catch (e) {} } });
5535
+ }
5536
+ showCustomDropdown(Math.round(r.left + 14), Math.round(r.bottom + 2), items, Math.round(r.top));
5537
+ }