@happy-nut/monacori 0.1.19 → 0.1.20

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.
package/dist/i18n.js CHANGED
@@ -35,6 +35,7 @@ export const MESSAGES = {
35
35
  "btn.viewed": "Viewed",
36
36
  "btn.viewed.title": "Toggle viewed (<)",
37
37
  "diff.noDiff": "No diff to review.",
38
+ "diff.lastHunk": "Last change in this file — press F7 again to go to the next file.",
38
39
  // Source toolbar
39
40
  "source.title": "Source",
40
41
  "source.selectFile": "Select a file from the Files tab.",
@@ -131,7 +132,6 @@ export const MESSAGES = {
131
132
  "merged.close": "Close",
132
133
  "dropdown.navigate": "Go to comment",
133
134
  "dropdown.remove": "Remove",
134
- "toast.commentsDropped": "Removed {n} comment(s) on {file} — the file changed too much to track them",
135
135
  "merged.qHeading": "# Questions",
136
136
  "merged.cHeading": "# Change requests",
137
137
  // Prompt memo (Cmd/Ctrl+Shift+N) — a single freeform Markdown scratchpad with a live split preview.
@@ -167,6 +167,7 @@ export const MESSAGES = {
167
167
  "btn.viewed": "확인함",
168
168
  "btn.viewed.title": "확인 표시 토글 (<)",
169
169
  "diff.noDiff": "검토할 변경사항이 없습니다.",
170
+ "diff.lastHunk": "이 파일의 마지막 변경입니다 — F7을 한 번 더 누르면 다음 파일로 이동합니다.",
170
171
  // Source toolbar
171
172
  "source.title": "소스",
172
173
  "source.selectFile": "파일 탭에서 파일을 선택하세요.",
@@ -263,7 +264,6 @@ export const MESSAGES = {
263
264
  "merged.close": "닫기",
264
265
  "dropdown.navigate": "코멘트로 이동",
265
266
  "dropdown.remove": "지우기",
266
- "toast.commentsDropped": "{file}이(가) 변경되어 추적할 수 없는 코멘트 {n}개를 제거했습니다",
267
267
  // Structural markers stay English in both locales (the preamble prose below follows the locale).
268
268
  "merged.qHeading": "# Questions",
269
269
  "merged.cHeading": "# Change requests",
@@ -319,7 +319,6 @@ function prepareDiff2HtmlHunks() {
319
319
  prepareViewedControls();
320
320
 
321
321
  function prepareViewedControls() {
322
- pruneViewedState();
323
322
  document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
324
323
  const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
325
324
  const toggle = wrapper.querySelector('.d2h-file-collapse');
@@ -356,34 +355,23 @@ function currentFileSignature(path) {
356
355
 
357
356
  function isFileViewed(path) {
358
357
  const viewed = loadViewedState();
359
- const signature = currentFileSignature(path);
360
- return Boolean(signature && viewed[path] === signature);
358
+ return Boolean(viewed[path]); // boolean now; legacy signature strings are also truthy, so old marks still read as viewed
361
359
  }
362
360
 
363
361
  function setFileViewed(path, viewed) {
364
362
  const state = loadViewedState();
365
- if (viewed) {
366
- const signature = currentFileSignature(path);
367
- if (signature) state[path] = signature;
368
- } else {
369
- delete state[path];
370
- }
363
+ // Persist a plain boolean (not the file signature) so a viewed mark survives a restart/refresh the way
364
+ // comments do. Tying it to the signature meant any re-generation that changed the signature silently
365
+ // cleared every viewed mark — exactly the "viewed didn't persist" the user hit.
366
+ if (viewed) state[path] = true;
367
+ else delete state[path];
371
368
  saveViewedState(state);
372
369
  applyViewedState();
373
370
  }
374
371
 
375
- function pruneViewedState() {
376
- const state = loadViewedState();
377
- let changed = false;
378
- Object.keys(state).forEach((path) => {
379
- if (state[path] !== currentFileSignature(path)) {
380
- delete state[path];
381
- changed = true;
382
- }
383
- });
384
- if (changed) saveViewedState(state);
385
- }
386
-
372
+ // Viewed marks persist by path (a plain boolean), like comments — we deliberately DON'T prune on signature
373
+ // change or restart. Tying persistence to the file signature is what made viewed marks vanish on every
374
+ // re-generation; the user wants them to survive restarts the way comments do.
387
375
  function applyViewedState() {
388
376
  document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
389
377
  const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
@@ -518,6 +506,18 @@ function revealAt(el, scroller, fraction) {
518
506
  var off = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
519
507
  scroller.scrollTop += off - scroller.clientHeight * fraction;
520
508
  }
509
+ // Scrolloff variant: scroll ONLY when `el` would otherwise leave the viewport, keeping it within `marginFrac`
510
+ // of the top/bottom edge. While the row moves comfortably inside that band the view stays put — continuous
511
+ // centering scrolled the file even when everything was visible (dizzying). Used by the diff caret.
512
+ function scrolloffReveal(el, scroller, marginFrac) {
513
+ if (!el || !scroller || !scroller.clientHeight) return;
514
+ var top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
515
+ var rowH = el.offsetHeight || 18;
516
+ var ch = scroller.clientHeight;
517
+ var margin = Math.round(ch * marginFrac);
518
+ if (top < margin) scroller.scrollTop += top - margin;
519
+ else if (top + rowH > ch - margin) scroller.scrollTop += (top + rowH) - (ch - margin);
520
+ }
521
521
  function scheduleScrollIntoView(el) {
522
522
  pendingScrollEl = el || null;
523
523
  if (scrollElRaf) return;
@@ -557,7 +557,7 @@ function applySetActive(idx, shouldScroll) {
557
557
  history.replaceState(null, '', '#hunk-' + idx);
558
558
  // Row-dependent work waits for the file body (sync for eager/Phase 1, async for cold lazy-LOAD).
559
559
  whenFileReady(diffWrapperByPath(file), function () {
560
- showOnlyFile(file);
560
+ showOnlyFile(file, true); // materialize + isolate the file, but leave the caret to focusDiffRow (skip ensureDiffCursor)
561
561
  const active = document.getElementById('hunk-' + idx);
562
562
  if (!active) return;
563
563
  if (REVIEW_LAZY) {
@@ -571,16 +571,24 @@ function applySetActive(idx, shouldScroll) {
571
571
  // F7/change navigation moves the caret but must NOT pollute the Cmd+[/] cursor history.
572
572
  navSuppress = true;
573
573
  try { focusDiffRow(targetRow); } finally { navSuppress = false; }
574
- if (shouldScroll && targetRow) scheduleDiffScroll(targetRow);
574
+ // Scroll inline in THIS frame, NOT via scheduleDiffScroll's extra rAF. showOnlyFile just display:none'd
575
+ // the previous file, but the scroll container keeps its old (larger) scrollTop — so for one frame the new
576
+ // file renders at that stale offset (≈ line 146) before a deferred scroll snaps to the change (≈ line 21):
577
+ // the visible 146→21 double jump on F7 across a file boundary. Scrolling synchronously here lands the
578
+ // view on the change before this frame paints, so the new file appears already at its first change.
579
+ if (shouldScroll && targetRow && targetRow.scrollIntoView) targetRow.scrollIntoView({ block: 'center' });
575
580
  });
576
581
  }
577
582
 
578
- function showOnlyFile(fileName) {
583
+ function showOnlyFile(fileName, skipCursor) {
579
584
  if (REVIEW_LAZY) ensureFileReady(diffWrapperByPath(fileName));
580
585
  document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
581
586
  wrapper.classList.toggle('df-inactive', diffWrapperPathKey(wrapper) !== fileName);
582
587
  });
583
- ensureDiffCursor();
588
+ // applySetActive passes skipCursor: it sets the caret itself via focusDiffRow(targetRow). Letting
589
+ // ensureDiffCursor run here would first place the caret on the file's FIRST code row, then focusDiffRow
590
+ // overrides it to the change — a visible double jump (the F7 "first line → change" flash).
591
+ if (!skipCursor) ensureDiffCursor();
584
592
  }
585
593
 
586
594
  // The hunk the diff caret currently sits in. Arrow keys move the caret without touching the active
@@ -622,6 +630,10 @@ function changeBlockAnchors(wrapper) {
622
630
  return anchors;
623
631
  }
624
632
 
633
+ // Forward F7 at a file's last change announces "last change — press F7 again" once before crossing to the
634
+ // next file, giving a beat to mark-viewed. Holds the path we've already announced; any caret move clears it
635
+ // (see setDiffCursor), so leaving and returning to the last change re-arms the announcement.
636
+ var pendingFileBoundary = null;
625
637
  function next(delta) {
626
638
  if (hunkTotal() === 0) return;
627
639
  // Within the caret's (unviewed) file, step change-block by change-block so a context-merged hunk
@@ -640,7 +652,18 @@ function next(delta) {
640
652
  }
641
653
  }
642
654
  }
643
- // File boundary (no more change blocks this file) hunk-level nav to the next/prev unviewed file.
655
+ // File boundary: no more change blocks in this file. Forward F7 announces "last change press F7 again
656
+ // to go to the next file" on the FIRST press (a beat to mark-viewed) and only crosses on the SECOND
657
+ // consecutive press. Already-viewed files (and backward nav) cross immediately — no announcement.
658
+ if (delta > 0 && diffCursor && isDiffViewVisible() && !isFileViewed(diffCursor.path)) {
659
+ if (pendingFileBoundary !== diffCursor.path) {
660
+ pendingFileBoundary = diffCursor.path;
661
+ showToast(t('diff.lastHunk'));
662
+ return;
663
+ }
664
+ pendingFileBoundary = null; // second consecutive press on the same file → fall through and cross
665
+ }
666
+ // hunk-level nav to the next/prev unviewed file.
644
667
  const caretHunk = hunkIndexAtCaret();
645
668
  const base = caretHunk >= 0 ? caretHunk : current;
646
669
  let idx = base < 0 ? initialHunkForNavigation(delta) : base + delta;
@@ -1026,6 +1049,14 @@ function handleTreeKey(event) {
1026
1049
  if (Math.abs(e.deltaY) >= Math.abs(e.deltaX) && e.deltaY !== 0) { dsc.scrollTop += e.deltaY; e.preventDefault(); }
1027
1050
  }, { passive: false });
1028
1051
  })();
1052
+ // A floating, focus-grabbing overlay (merged-comments, prompt memo, settings) is open. While one is up it
1053
+ // owns focus AND the only caret, so global shortcuts stand down until Esc/close — we must not navigate a
1054
+ // panel the user can't even see behind the overlay (nor leave a second blinking caret in it).
1055
+ function isFloatingModalOpen() {
1056
+ if (document.getElementById('mc-modal') || document.getElementById('mc-memo')) return true;
1057
+ var sm = document.getElementById('settings-modal');
1058
+ return !!(sm && !sm.classList.contains('hidden'));
1059
+ }
1029
1060
  document.addEventListener('keydown', (event) => {
1030
1061
  if (!quickOpen?.classList.contains('hidden')) {
1031
1062
  if (handleQuickOpenKey(event)) return;
@@ -1035,8 +1066,22 @@ document.addEventListener('keydown', (event) => {
1035
1066
  if (handleUsagesKey(event)) return;
1036
1067
  }
1037
1068
 
1069
+ // Floating overlay open (merged / memo / settings): it captures keys until Esc. Don't run ANY global
1070
+ // shortcut (Cmd+1, F7, Cmd+[/], Cmd+B, open-merged/memo, …) underneath — focus and the only caret belong
1071
+ // to the overlay. Each overlay has its own Esc + editing handlers, so we simply stand down here.
1072
+ if (isFloatingModalOpen()) return;
1073
+
1038
1074
  if ((event.metaKey || event.ctrlKey) && event.key === '1') {
1039
1075
  event.preventDefault();
1076
+ // Coming from the diff: open the file you were viewing as source so Cmd+1 lands ON it (not a stale/blank
1077
+ // source pane), and the tree below points at the same file. Capture the path BEFORE openSourceFile flips
1078
+ // the view (isDiffViewVisible would then be false).
1079
+ if (isDiffViewVisible()) {
1080
+ var dw1 = diffActiveWrapper();
1081
+ var dn1 = dw1 && dw1.querySelector('.d2h-file-name');
1082
+ var dpath1 = (diffCursor && diffCursor.path) || (dn1 ? (dn1.textContent || '').trim() : '');
1083
+ if (dpath1 && sourceByPath.has(dpath1)) openSourceFile(dpath1);
1084
+ }
1040
1085
  setTab('files');
1041
1086
  focusOpenFileInTree();
1042
1087
  return;
@@ -1542,9 +1587,17 @@ function renderDiffCaret() {
1542
1587
  row.classList.add('mc-diff-cursor-row');
1543
1588
  var ctn = diffCellCtn(row);
1544
1589
  if (!ctn) return;
1545
- // Empty line (ctn is just a <br>): the row highlight marks the caret. Inserting a caret span
1546
- // next to the <br> would push it onto a second visual line and break the row's height.
1547
- if ((ctn.textContent || '').length === 0) return;
1590
+ // Empty line (ctn is just a <br>): an inline caret span would wrap onto a 2nd visual line and break the
1591
+ // row height, so position the caret absolutely it shows without affecting the layout.
1592
+ if ((ctn.textContent || '').length === 0) {
1593
+ var espan = document.createElement('span');
1594
+ espan.className = 'code-cursor';
1595
+ espan.setAttribute('aria-hidden', 'true');
1596
+ espan.style.position = 'absolute';
1597
+ ctn.appendChild(espan);
1598
+ diffCaretSpan = espan;
1599
+ return;
1600
+ }
1548
1601
  var pos = diffCaretDomPosition(ctn, diffCursor.column);
1549
1602
  if (!pos) return;
1550
1603
  var span = document.createElement('span');
@@ -1568,6 +1621,7 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
1568
1621
  var ri = Math.max(0, Math.min(rowIndex, rows.length - 1));
1569
1622
  var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
1570
1623
  diffCursor = { path: path, side: side, rowIndex: ri, column: col };
1624
+ pendingFileBoundary = null; // any caret move re-arms the last-change announcement for the next F7 (see next)
1571
1625
  diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
1572
1626
  if (reveal) {
1573
1627
  // Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
@@ -1592,7 +1646,7 @@ function scheduleDiffReveal(wrapper, side, ri) {
1592
1646
  applyDiffSelection();
1593
1647
  if (!t) return;
1594
1648
  var row = diffRowAt(t.wrapper, t.side, t.ri);
1595
- revealAt(row, document.getElementById('diff2html-container'), 0.42);
1649
+ scrolloffReveal(row, document.getElementById('diff2html-container'), 0.15);
1596
1650
  });
1597
1651
  }
1598
1652
  function navEntryOf(kind) {
@@ -1768,35 +1822,30 @@ function showToast(message) {
1768
1822
  setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
1769
1823
  }, 4500);
1770
1824
  }
1771
- // When a file changes, follow each comment to its snapshot line (c.code) in the new content: same line if
1772
- // unchanged, else the nearest exact match of that line. If the line can't be found the change is too large
1773
- // to trustdrop the comment and toast. Files whose content isn't loaded yet (lazy) are skipped here and
1774
- // reconciled once loadSourceData brings the content in.
1825
+ // Follow each comment to its snapshot line (c.code) in the current content: same line if unchanged, else the
1826
+ // nearest exact match of that line. A comment is NEVER auto-deleted. If its line can't be found we leave it
1827
+ // where it is this happens routinely WITHOUT the file changing: a comment anchored to a deleted/old-side
1828
+ // diff line (comments carry no side, so old-side text never matches the new content) would otherwise vanish.
1829
+ // Silently dropping user-authored comments loses data; the reviewer can remove a stale one with the × button.
1830
+ // Files whose content isn't loaded yet (lazy) are skipped here and reconciled once loadSourceData arrives.
1775
1831
  function remapComments() {
1776
1832
  if (!reviewComments.length) return;
1777
- var dropped = [], moved = 0;
1778
- reviewComments = reviewComments.filter(function (c) {
1833
+ var moved = 0;
1834
+ reviewComments.forEach(function (c) {
1779
1835
  var file = sourceByPath.get(c.path);
1780
- if (!file || !file.embedded || typeof file.content !== 'string' || !file.content) return true;
1836
+ if (!file || !file.embedded || typeof file.content !== 'string' || !file.content) return;
1781
1837
  var code = c.code == null ? '' : String(c.code);
1782
- if (!code.trim()) return true;
1838
+ if (!code.trim()) return;
1783
1839
  var lines = file.content.split(/\r?\n/);
1784
- if (lines[c.line - 1] === code) return true;
1840
+ if (lines[c.line - 1] === code) return;
1785
1841
  var best = -1, bestDist = Infinity;
1786
1842
  for (var i = 0; i < lines.length; i++) {
1787
1843
  if (lines[i] === code) { var d = Math.abs(i - (c.line - 1)); if (d < bestDist) { bestDist = d; best = i; } }
1788
1844
  }
1789
- if (best >= 0) { if (c.line !== best + 1) moved++; c.line = best + 1; return true; }
1790
- dropped.push(c);
1791
- return false;
1845
+ if (best >= 0 && c.line !== best + 1) { c.line = best + 1; moved++; } // moved to follow the line; not found -> keep as-is
1792
1846
  });
1793
- if (!dropped.length && !moved) return; // nothing changed — skip the save/re-render
1847
+ if (!moved) return; // nothing moved — skip the save/re-render
1794
1848
  saveComments();
1795
- var byPath = {};
1796
- dropped.forEach(function (c) { byPath[c.path] = (byPath[c.path] || 0) + 1; });
1797
- Object.keys(byPath).forEach(function (p) {
1798
- showToast(t('toast.commentsDropped').replace('{n}', byPath[p]).replace('{file}', String(p).split('/').pop()));
1799
- });
1800
1849
  refreshComments();
1801
1850
  }
1802
1851
  function saveComments() {
@@ -1821,6 +1870,14 @@ function addComment(kind, path, line, code, text) {
1821
1870
  reviewComments.push({ seq: commentSeq, kind: kind, path: path, line: line, code: String(code || ''), text: trimmed });
1822
1871
  saveComments();
1823
1872
  }
1873
+ // Edit an existing comment in place (e on a selected box -> composer prefilled -> save). Empty text deletes it.
1874
+ function updateComment(seq, text) {
1875
+ var c = reviewComments.find(function (x) { return x.seq === seq; });
1876
+ if (!c) return;
1877
+ var trimmed = String(text || '').trim();
1878
+ if (trimmed) { c.text = trimmed; saveComments(); }
1879
+ else { deleteComment(seq); }
1880
+ }
1824
1881
  function deleteComment(seq) {
1825
1882
  reviewComments = reviewComments.filter(function (c) { return c.seq !== seq; });
1826
1883
  saveComments();
@@ -1901,6 +1958,7 @@ function composerTargetLabel(s) {
1901
1958
  function threadHtml(path, line) {
1902
1959
  var html = '';
1903
1960
  commentsAt(path, line).forEach(function (c) {
1961
+ if (composerState && composerState.editSeq === c.seq) return; // being edited -> rendered as the composer below
1904
1962
  html += '<div class="mc-card mc-' + c.kind + '">'
1905
1963
  + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(c.kind) + '</span>'
1906
1964
  + '<button type="button" class="mc-del" data-seq="' + c.seq + '" title="' + escapeHtml(t('composer.delete')) + '">×</button></div>'
@@ -1910,7 +1968,7 @@ function threadHtml(path, line) {
1910
1968
  var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
1911
1969
  html += '<div class="mc-card mc-' + composerState.kind + ' mc-composer">'
1912
1970
  + '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(composerState.kind) + '</span><span class="mc-target" title="' + escapeHtml(composerState.path || '') + '">' + escapeHtml(composerTargetLabel(composerState)) + '</span></div>'
1913
- + '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '"></textarea>'
1971
+ + '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '">' + escapeHtml(composerState.editText || '') + '</textarea>'
1914
1972
  + '<div class="mc-actions"><button type="button" class="mc-btn mc-save">' + escapeHtml(t('composer.save')) + '</button>'
1915
1973
  + '<button type="button" class="mc-btn mc-ghost mc-cancel">' + escapeHtml(t('composer.cancel')) + '</button>'
1916
1974
  + '<span class="mc-hint">' + escapeHtml(t('composer.hint')) + '</span></div></div>';
@@ -2089,7 +2147,8 @@ function saveComposer(ta) {
2089
2147
  if (!composerState) return;
2090
2148
  var box = ta || activeComposerInput();
2091
2149
  if (!box) return;
2092
- addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
2150
+ if (composerState.editSeq != null) updateComment(composerState.editSeq, box.value);
2151
+ else addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
2093
2152
  composerState = null;
2094
2153
  refreshComments();
2095
2154
  }
@@ -2281,7 +2340,14 @@ function openMergedView(kind) {
2281
2340
  area.value = buildMergedText(kind);
2282
2341
  };
2283
2342
  if (area.selectionStart !== area.selectionEnd || seqs.length > 1) {
2284
- showCustomDropdown(x, y, [{ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } }], flipTop);
2343
+ // Select-all / multi-comment: offer send-to-terminal (the whole merged text) FIRST, then remove-all.
2344
+ // Can't "Go to comment" across many at once, so navigate is omitted here.
2345
+ var multi = [];
2346
+ if (window.__monacoriTerminal && typeof window.__monacoriTerminal.isOpen === 'function' && window.__monacoriTerminal.isOpen()) {
2347
+ multi.push({ label: t('merged.sendToTerminal'), onSelect: function () { var text = buildMergedText(kind); modal.remove(); window.__monacoriTerminal.enterSendMode(text); } });
2348
+ }
2349
+ multi.push({ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } });
2350
+ showCustomDropdown(x, y, multi, flipTop);
2285
2351
  } else {
2286
2352
  var seq = seqs[0];
2287
2353
  showCustomDropdown(x, y, [
@@ -2291,23 +2357,8 @@ function openMergedView(kind) {
2291
2357
  }
2292
2358
  });
2293
2359
  closeBtn.addEventListener('click', function () { modal.remove(); });
2294
- // Terminal send (Electron, terminal open): close the modal and hand off to pane-pick mode ON the
2295
- // terminal — the chosen pane is highlighted, the rest dimmed, arrows change the choice, Enter sends.
2296
- // One button here; the actual pick happens visually over the live claude/codex sessions.
2297
- var sendBtn = null;
2298
- if (window.__monacoriTerminal && typeof window.__monacoriTerminal.isOpen === 'function' && window.__monacoriTerminal.isOpen()) {
2299
- sendBtn = document.createElement('button');
2300
- sendBtn.type = 'button';
2301
- sendBtn.className = 'mc-btn mc-send-term';
2302
- sendBtn.textContent = t('merged.sendToTerminal');
2303
- sendBtn.addEventListener('click', function () {
2304
- var text = buildMergedText(kind);
2305
- modal.remove();
2306
- window.__monacoriTerminal.enterSendMode(text);
2307
- });
2308
- }
2360
+ // Send-to-terminal now lives in the Opt+Enter dropdown (select-all -> first item), not as a header button.
2309
2361
  head.appendChild(title);
2310
- if (sendBtn) head.appendChild(sendBtn);
2311
2362
  head.appendChild(closeBtn);
2312
2363
  panel.appendChild(head);
2313
2364
  panel.appendChild(area);
@@ -2315,9 +2366,9 @@ function openMergedView(kind) {
2315
2366
  modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
2316
2367
  modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
2317
2368
  document.body.appendChild(modal);
2318
- // Focus the send button (Enter starts pane-pick) when present, else the read-only text. Electron
2319
- // async-restores focus to <body>, so retry briefly (same as the composer).
2320
- var modalFocusTarget = area; // focus the text (not the send button) so the caret is visible and Opt+Arrow/Enter work; Send-to-terminal is a click
2369
+ // Focus the read-only text so the caret is visible and Opt+Arrow / Opt+Enter (incl. the send-to-terminal
2370
+ // dropdown item) work. Electron async-restores focus to <body>, so retry briefly (same as the composer).
2371
+ var modalFocusTarget = area;
2321
2372
  var modalFocusTries = 0;
2322
2373
  var tryFocusModal = function () {
2323
2374
  if (!document.getElementById('mc-modal')) return true;
@@ -2850,8 +2901,10 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
2850
2901
  // Capture so closing settings wins over other Escape handlers (lightbox / composer).
2851
2902
  document.addEventListener('keydown', function (e) {
2852
2903
  if (e.key === 'Escape' && !modal.classList.contains('hidden')) { e.stopPropagation(); e.preventDefault(); close(); return; }
2853
- // Cmd/Ctrl+, (the standard "Preferences" accelerator) toggles the settings panel from anywhere.
2904
+ // Cmd/Ctrl+, (the standard "Preferences" accelerator) toggles the settings panel from anywhere — but not
2905
+ // while another floating overlay (merged / memo) owns focus; that one must be Esc'd first.
2854
2906
  if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === ',' || e.code === 'Comma')) {
2907
+ if (modal.classList.contains('hidden') && (document.getElementById('mc-modal') || document.getElementById('mc-memo'))) return;
2855
2908
  e.preventDefault(); e.stopPropagation();
2856
2909
  if (modal.classList.contains('hidden')) open('general'); else close();
2857
2910
  }
@@ -3009,6 +3062,10 @@ function applyDiffUpdate(u) {
3009
3062
  var wasSource = isSourceViewerVisible();
3010
3063
  var container = document.getElementById('diff2html-container');
3011
3064
  var diffScrollTop = container ? container.scrollTop : 0;
3065
+ // Did the file the user is CURRENTLY viewing actually change in this build? If not, we must not re-render
3066
+ // the source view — an unrelated file's edit would otherwise flicker the pane they're reading. Capture the
3067
+ // open file's signature BEFORE fileSignatureByPath is rebuilt below.
3068
+ var prevOpenSig = openPath ? (fileSignatureByPath.get(openPath) || '') : '';
3012
3069
 
3013
3070
  // 1) Replace the visible regions straight from the payload (no full-HTML parse).
3014
3071
  if (container) container.innerHTML = u.diffContainer || '';
@@ -3027,6 +3084,9 @@ function applyDiffUpdate(u) {
3027
3084
  // 2) Re-derive module-level state directly from the payload objects.
3028
3085
  fileStates = u.fileStates || [];
3029
3086
  fileSignatureByPath = new Map(fileStates.map(function (f) { return [f.path, f.signature]; }));
3087
+ // The open file changed iff its signature moved (or it vanished from the new build). Drives whether we
3088
+ // re-render the source view below.
3089
+ var openFileChanged = !openPath || prevOpenSig !== (fileSignatureByPath.get(openPath) || '');
3030
3090
  sourceFiles = u.sourceFilesMeta || [];
3031
3091
  sourceByPath = new Map(sourceFiles.map(function (f) { return [f.path, f]; }));
3032
3092
  httpEnvironments = u.httpEnvironments || {};
@@ -3040,7 +3100,9 @@ function applyDiffUpdate(u) {
3040
3100
  diffBootDone = false;
3041
3101
  sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
3042
3102
  sourceLoading = false;
3043
- sourceBodyPath = null; // the new build may have changed the open file's content force a body re-render on next open
3103
+ // Force a source body re-render on next open ONLY if the open file actually changed; otherwise keep
3104
+ // sourceBodyPath so the already-painted (unchanged) source view is left exactly as-is — no flicker.
3105
+ if (openFileChanged) sourceBodyPath = null;
3044
3106
  symbolIndex = null;
3045
3107
  if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
3046
3108
  else { prepareDiff2HtmlHunks(); diffBootDone = true; }
@@ -3053,9 +3115,10 @@ function applyDiffUpdate(u) {
3053
3115
  remapComments(); // follow/drop comments whose anchor line moved or vanished in the new build
3054
3116
  refreshComments();
3055
3117
 
3056
- // 5) Best-effort restore of what the user was looking at.
3118
+ // 5) Best-effort restore of what the user was looking at. Re-render the source view only when the open file
3119
+ // actually changed; an unchanged file stays painted as-is, so an unrelated edit doesn't flicker the pane.
3057
3120
  if (wasSource && openPath && sourceByPath.has(openPath)) {
3058
- openSourceFile(openPath, false);
3121
+ if (openFileChanged) openSourceFile(openPath, false);
3059
3122
  } else if (container) {
3060
3123
  showDiffView(false);
3061
3124
  container.scrollTop = diffScrollTop;
@@ -3313,6 +3376,17 @@ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLi
3313
3376
  recordNav(navEntryOf('source'));
3314
3377
  }
3315
3378
  var sourceRevealRaf = 0, sourceRevealPrev = null;
3379
+ // Source rows are a fixed monospace height, so the caret-follow scroll can be computed from
3380
+ // lineIndex*rowHeight instead of reading the caret's getBoundingClientRect — which forces a full reflow on
3381
+ // every move (~15ms on a 400-line file; the main caret-follow stutter). Cached; invalidated on resize.
3382
+ var _srcRowH = 0;
3383
+ function sourceRowHeight() {
3384
+ if (_srcRowH > 0) return _srcRowH;
3385
+ var r = document.querySelector('#source-body .source-row');
3386
+ if (r) { var h = r.offsetHeight; if (h > 0) _srcRowH = h; }
3387
+ return _srcRowH;
3388
+ }
3389
+ if (typeof window !== 'undefined') window.addEventListener('resize', function () { _srcRowH = 0; });
3316
3390
  function scheduleSourceReveal(prev) {
3317
3391
  // First prev of a coalesced burst wins: a fast ArrowDown updates viewerCursor many times before the frame
3318
3392
  // fires; render the caret once (first prev -> final viewerCursor) and scroll in the SAME frame so caret and
@@ -3326,8 +3400,23 @@ function scheduleSourceReveal(prev) {
3326
3400
  if (!f || !f.embedded) return;
3327
3401
  var lines = f.content.split(/\r?\n/);
3328
3402
  updateSourceCaret(p, lines, f.language || 'text');
3329
- var cl = document.querySelector('.source-row.cursor-line');
3330
- revealAt(cl, document.getElementById('source-body'), 0.42);
3403
+ var sb = document.getElementById('source-body');
3404
+ var rowH = sourceRowHeight();
3405
+ if (rowH > 0 && sb && !sb.classList.contains('rendered-body')) {
3406
+ // Scrolloff, not follow: scroll ONLY when the caret would otherwise leave the viewport, keeping it
3407
+ // within a 15% margin of the top/bottom edge. While the caret moves comfortably inside that band the
3408
+ // view stays put — continuous follow was dizzying (the file slid even when everything was visible) and
3409
+ // it forced a scroll/reflow on every move. lineIndex*rowH avoids getBoundingClientRect entirely, and
3410
+ // skipping the scroll when it's unnecessary removes the reflow on most moves too.
3411
+ var caretTop = viewerCursor.lineIndex * rowH;
3412
+ var ch = sb.clientHeight;
3413
+ var margin = Math.round(ch * 0.15);
3414
+ var vTop = sb.scrollTop;
3415
+ if (caretTop < vTop + margin) sb.scrollTop = Math.max(0, caretTop - margin);
3416
+ else if (caretTop + rowH > vTop + ch - margin) sb.scrollTop = caretTop + rowH - ch + margin;
3417
+ } else {
3418
+ revealAt(document.querySelector('.source-row.cursor-line'), sb, 0.85);
3419
+ }
3331
3420
  });
3332
3421
  }
3333
3422
 
@@ -3424,9 +3513,9 @@ function selectCommentRow(row) {
3424
3513
  selectedCommentRow = row || null;
3425
3514
  if (!selectedCommentRow) return;
3426
3515
  selectedCommentRow.classList.add('mc-row-selected');
3427
- // hide the text caret while the box is "selected" (no re-render happens during plain selection)
3428
- document.querySelectorAll('#source-body .source-row.cursor-line').forEach(function (r) { r.classList.remove('cursor-line'); });
3429
- document.querySelectorAll('#source-body .code-cursor').forEach(function (s) { var p = s.parentNode; if (p) { p.removeChild(s); if (p.normalize) p.normalize(); } });
3516
+ // Keep the caret visible: the box's active outline (.mc-row-selected) already shows the selection, and the
3517
+ // caret must never be hidden ("어떤 경우에도 커서는 가려지면 안 됨"). Previously this removed cursor-line +
3518
+ // code-cursor, so Go-to-comment ArrowDown (which selects the comment box on that line) made the caret vanish.
3430
3519
  }
3431
3520
  function deleteCommentsInRow(row) {
3432
3521
  if (!row) return;
@@ -3438,6 +3527,21 @@ function deleteCommentsInRow(row) {
3438
3527
  }
3439
3528
  refreshComments(); // remaining comment rows re-injected; the caret stays hidden until the next arrow press
3440
3529
  }
3530
+ // Open the composer in EDIT mode for the first comment in `row`, pre-filled with its text. threadHtml renders
3531
+ // the composer in place of that card (via composerState.editSeq), and saveComposer routes editSeq through
3532
+ // updateComment instead of addComment. Triggered by `e` while a comment box is selected.
3533
+ function editCommentInRow(row) {
3534
+ if (!row) return;
3535
+ var del = row.querySelector('.mc-del');
3536
+ if (!del) return;
3537
+ var seq = parseInt(del.dataset.seq, 10);
3538
+ var c = reviewComments.find(function (x) { return x.seq === seq; });
3539
+ if (!c) return;
3540
+ row.classList.remove('mc-row-selected');
3541
+ selectedCommentRow = null;
3542
+ composerState = { kind: c.kind, path: c.path, line: c.line, code: c.code, editSeq: seq, editText: c.text };
3543
+ refreshComments();
3544
+ }
3441
3545
  function handleSourceCaretKey(event) {
3442
3546
  if (!viewerCursor) return false;
3443
3547
  var ae = document.activeElement;
@@ -3446,6 +3550,7 @@ function handleSourceCaretKey(event) {
3446
3550
  // A comment box is selected (caret hidden): Backspace/Delete removes it; an arrow steps off it.
3447
3551
  if (selectedCommentRow) {
3448
3552
  if (event.key === 'Backspace' || event.key === 'Delete') { event.preventDefault(); deleteCommentsInRow(selectedCommentRow); return true; }
3553
+ if (event.key === 'e' || event.key === 'E') { event.preventDefault(); editCommentInRow(selectedCommentRow); return true; }
3449
3554
  if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Escape') {
3450
3555
  var dir = event.key === 'ArrowUp' ? -1 : (event.key === 'ArrowDown' ? 1 : 0);
3451
3556
  var sib = dir < 0 ? selectedCommentRow.previousElementSibling : (dir > 0 ? selectedCommentRow.nextElementSibling : null);
@@ -4036,6 +4141,7 @@ function toggleRenderMode() {
4036
4141
  var btn = document.getElementById('render-toggle');
4037
4142
  if (btn) btn.addEventListener('click', function () { toggleRenderMode(); });
4038
4143
  document.addEventListener('keydown', function (e) {
4144
+ if (isFloatingModalOpen()) return; // a floating overlay owns focus -> no render-toggle shortcut beneath it
4039
4145
  if ((e.metaKey || e.ctrlKey) && e.shiftKey && !e.altKey && (e.key === 'M' || e.key === 'm' || e.code === 'KeyM')) {
4040
4146
  var sv = document.getElementById('source-viewer');
4041
4147
  var open = sv && sv.dataset.openPath;
package/dist/viewer.css CHANGED
@@ -294,6 +294,10 @@ body {
294
294
  border: 0;
295
295
  border-radius: 0;
296
296
  background: var(--panel);
297
+ /* #diff2html-container is a column flexbox; a flex item with overflow:hidden has min-height:0, so a single
298
+ shown file (showOnlyFile) shrinks to the viewport height and overflow:hidden clips the rest — the diff
299
+ can't scroll and the caret leaves the screen near the bottom of a big file. Pin to content height. */
300
+ flex-shrink: 0;
297
301
  }
298
302
  /* The per-file header is merged into the sticky toolbar (path + status + Viewed) to save vertical space. */
299
303
  .d2h-file-header { display: none; }
@@ -370,6 +374,7 @@ body {
370
374
  background: transparent;
371
375
  border: 0;
372
376
  }
377
+ .d2h-code-line-ctn { position: relative; } /* anchors the absolutely-positioned empty-line caret (so blank rows need no inline position) */
373
378
  .d2h-code-side-line, .d2h-code-line {
374
379
  /* left pad must exceed the 58px absolutely-positioned line-number, else the +/- prefix renders behind it and looks clipped */
375
380
  padding: 0 0.6em 0 64px;
@@ -689,11 +694,6 @@ h1 { margin: 0; font-size: 18px; }
689
694
  key scrolls the view CONTINUOUSLY instead of leaving it still until the caret reaches the viewport edge
690
695
  (the "stutter every ~viewport" the user reported). Applies to the source body, the diff, and the sidebar. */
691
696
  .source-body, #diff2html-container, .sidebar-scroll { scroll-padding-block: 35vh; }
692
- /* revealAt() sets scrollTop directly, and scroll-padding only affects scrollIntoView — so near EOF the
693
- caret can't reach the 42% line and pins to the viewport bottom, where the footer progress bar overlaps
694
- and HIDES it ("the caret leaves the screen at the end of a file"). Real trailing space lets the last
695
- lines scroll up to the middle. The diff caret showed this worst (95% vs source's 81%); both get it. */
696
- .source-body, #diff2html-container { padding-bottom: 45vh; }
697
697
  .source-body {
698
698
  border: 1px solid var(--border);
699
699
  overflow: auto;
@@ -750,8 +750,11 @@ h1 { margin: 0; font-size: 18px; }
750
750
  }
751
751
  /* perf: let the browser skip layout/paint for off-screen rows in large files/diffs.
752
752
  DOM is unchanged (nav, search, comment anchoring still query every row); degrades
753
- gracefully where unsupported. contain-intrinsic-size keeps the scrollbar stable. */
754
- .source-row { content-visibility: auto; contain-intrinsic-size: auto 19px; }
753
+ gracefully where unsupported. contain-intrinsic-size keeps the scrollbar stable.
754
+ (Removing this from .source-row made the caret stutter WORSE the full DOM forces a bigger reflow on
755
+ every scroll. The stutter is fixed instead by computing the source scroll from lineIndex*rowHeight,
756
+ which skips getBoundingClientRect's forced reflow entirely; see scheduleSourceReveal.) */
757
+ .source-row { content-visibility: auto; contain-intrinsic-size: auto 21px; }
755
758
  .d2h-diff-table tr { content-visibility: auto; contain-intrinsic-size: auto 18px; }
756
759
  /* Comment/composer rows are tall and interactive (a textarea lives here). Skip-rendering them
757
760
  with a tiny 18px placeholder made the browser re-evaluate their render state on every
@@ -834,6 +837,11 @@ body.mc-composing .mc-diff-cursor-row .d2h-code-side-line { box-shadow: none; }
834
837
  body.mc-composing .source-row.cursor-line .md-cell,
835
838
  body.mc-composing .source-row.csv-row.cursor-line .csv-cell { background: transparent; }
836
839
  body.mc-composing .source-row.cursor-line .num { color: inherit; }
840
+ /* Same single-caret rule for a floating overlay (merged comments / prompt memo / settings): it owns the only
841
+ caret while open, so hide the file's blinking caret behind it — never two carets across visible panels. */
842
+ body:has(#mc-modal) .code-cursor,
843
+ body:has(#mc-memo) .code-cursor,
844
+ body:has(#settings-modal:not(.hidden)) .code-cursor { display: none; }
837
845
  .mc-kind {
838
846
  font-weight: 700; font-size: 10px; letter-spacing: 0.05em; text-transform: uppercase;
839
847
  padding: 2px 8px; border-radius: 999px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happy-nut/monacori",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Validation control plane for AI-generated code changes.",
5
5
  "type": "module",
6
6
  "repository": {