@happy-nut/monacori 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/icon.icns +0 -0
- package/dist/app-main.js +403 -155
- package/dist/build.d.ts +1 -0
- package/dist/build.js +8 -6
- package/dist/diff.d.ts +2 -1
- package/dist/diff.js +3 -3
- package/dist/i18n.js +8 -2
- package/dist/preload.cjs +7 -0
- package/dist/render.d.ts +4 -0
- package/dist/render.js +84 -0
- package/dist/viewer.client.js +489 -192
- package/dist/viewer.css +65 -13
- package/package.json +9 -2
- package/scripts/patch-electron-name.mjs +23 -14
package/dist/viewer.client.js
CHANGED
|
@@ -98,14 +98,19 @@ function whenFileReady(wrapper, cb) {
|
|
|
98
98
|
if (bodyPromise[idx]) { bodyPromise[idx].then(function () { cb(); }); return; }
|
|
99
99
|
cb();
|
|
100
100
|
}
|
|
101
|
+
var lazyIO = null; // remembered so each setupLazyDiff (re-run on every watch refresh) disconnects the prior
|
|
102
|
+
// observer instead of leaving a new one bound to detached wrappers — otherwise observers
|
|
103
|
+
// (and the old DOM they retain) pile up over a long-running session and slowly choke it.
|
|
101
104
|
function setupLazyDiff() {
|
|
102
105
|
var container = document.getElementById('diff2html-container');
|
|
103
106
|
if (!container) return;
|
|
107
|
+
if (lazyIO) { try { lazyIO.disconnect(); } catch (e) {} lazyIO = null; }
|
|
104
108
|
var wrappers = Array.prototype.slice.call(container.querySelectorAll('.d2h-file-wrapper'));
|
|
105
109
|
if (typeof IntersectionObserver !== 'undefined') {
|
|
106
110
|
var io = new IntersectionObserver(function (entries) {
|
|
107
111
|
entries.forEach(function (e) { if (e.isIntersecting) { ensureFileReady(e.target); io.unobserve(e.target); } });
|
|
108
112
|
}, { root: null, rootMargin: '600px 0px' });
|
|
113
|
+
lazyIO = io; // track this observer so the NEXT setupLazyDiff can disconnect it (callback keeps using local io)
|
|
109
114
|
wrappers.forEach(function (w) { io.observe(w); });
|
|
110
115
|
} else {
|
|
111
116
|
wrappers.forEach(function (w) { ensureFileReady(w); }); // no IntersectionObserver -> materialize all
|
|
@@ -319,7 +324,6 @@ function prepareDiff2HtmlHunks() {
|
|
|
319
324
|
prepareViewedControls();
|
|
320
325
|
|
|
321
326
|
function prepareViewedControls() {
|
|
322
|
-
pruneViewedState();
|
|
323
327
|
document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
|
|
324
328
|
const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
|
|
325
329
|
const toggle = wrapper.querySelector('.d2h-file-collapse');
|
|
@@ -356,34 +360,23 @@ function currentFileSignature(path) {
|
|
|
356
360
|
|
|
357
361
|
function isFileViewed(path) {
|
|
358
362
|
const viewed = loadViewedState();
|
|
359
|
-
|
|
360
|
-
return Boolean(signature && viewed[path] === signature);
|
|
363
|
+
return Boolean(viewed[path]); // boolean now; legacy signature strings are also truthy, so old marks still read as viewed
|
|
361
364
|
}
|
|
362
365
|
|
|
363
366
|
function setFileViewed(path, viewed) {
|
|
364
367
|
const state = loadViewedState();
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
368
|
+
// Persist a plain boolean (not the file signature) so a viewed mark survives a restart/refresh the way
|
|
369
|
+
// comments do. Tying it to the signature meant any re-generation that changed the signature silently
|
|
370
|
+
// cleared every viewed mark — exactly the "viewed didn't persist" the user hit.
|
|
371
|
+
if (viewed) state[path] = true;
|
|
372
|
+
else delete state[path];
|
|
371
373
|
saveViewedState(state);
|
|
372
374
|
applyViewedState();
|
|
373
375
|
}
|
|
374
376
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
377
|
+
// Viewed marks persist by path (a plain boolean), like comments — we deliberately DON'T prune on signature
|
|
378
|
+
// change or restart. Tying persistence to the file signature is what made viewed marks vanish on every
|
|
379
|
+
// re-generation; the user wants them to survive restarts the way comments do.
|
|
387
380
|
function applyViewedState() {
|
|
388
381
|
document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
|
|
389
382
|
const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
|
|
@@ -518,6 +511,18 @@ function revealAt(el, scroller, fraction) {
|
|
|
518
511
|
var off = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
519
512
|
scroller.scrollTop += off - scroller.clientHeight * fraction;
|
|
520
513
|
}
|
|
514
|
+
// Scrolloff variant: scroll ONLY when `el` would otherwise leave the viewport, keeping it within `marginFrac`
|
|
515
|
+
// of the top/bottom edge. While the row moves comfortably inside that band the view stays put — continuous
|
|
516
|
+
// centering scrolled the file even when everything was visible (dizzying). Used by the diff caret and the sidebar tree.
|
|
517
|
+
function scrolloffReveal(el, scroller, marginFrac) {
|
|
518
|
+
if (!el || !scroller || !scroller.clientHeight) return;
|
|
519
|
+
var top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
520
|
+
var rowH = el.offsetHeight || 18;
|
|
521
|
+
var ch = scroller.clientHeight;
|
|
522
|
+
var margin = Math.round(ch * marginFrac);
|
|
523
|
+
if (top < margin) scroller.scrollTop += top - margin;
|
|
524
|
+
else if (top + rowH > ch - margin) scroller.scrollTop += (top + rowH) - (ch - margin);
|
|
525
|
+
}
|
|
521
526
|
function scheduleScrollIntoView(el) {
|
|
522
527
|
pendingScrollEl = el || null;
|
|
523
528
|
if (scrollElRaf) return;
|
|
@@ -557,7 +562,7 @@ function applySetActive(idx, shouldScroll) {
|
|
|
557
562
|
history.replaceState(null, '', '#hunk-' + idx);
|
|
558
563
|
// Row-dependent work waits for the file body (sync for eager/Phase 1, async for cold lazy-LOAD).
|
|
559
564
|
whenFileReady(diffWrapperByPath(file), function () {
|
|
560
|
-
showOnlyFile(file);
|
|
565
|
+
showOnlyFile(file, true); // materialize + isolate the file, but leave the caret to focusDiffRow (skip ensureDiffCursor)
|
|
561
566
|
const active = document.getElementById('hunk-' + idx);
|
|
562
567
|
if (!active) return;
|
|
563
568
|
if (REVIEW_LAZY) {
|
|
@@ -571,16 +576,24 @@ function applySetActive(idx, shouldScroll) {
|
|
|
571
576
|
// F7/change navigation moves the caret but must NOT pollute the Cmd+[/] cursor history.
|
|
572
577
|
navSuppress = true;
|
|
573
578
|
try { focusDiffRow(targetRow); } finally { navSuppress = false; }
|
|
574
|
-
|
|
579
|
+
// Scroll inline in THIS frame, NOT via scheduleDiffScroll's extra rAF. showOnlyFile just display:none'd
|
|
580
|
+
// the previous file, but the scroll container keeps its old (larger) scrollTop — so for one frame the new
|
|
581
|
+
// file renders at that stale offset (≈ line 146) before a deferred scroll snaps to the change (≈ line 21):
|
|
582
|
+
// the visible 146→21 double jump on F7 across a file boundary. Scrolling synchronously here lands the
|
|
583
|
+
// view on the change before this frame paints, so the new file appears already at its first change.
|
|
584
|
+
if (shouldScroll && targetRow && targetRow.scrollIntoView) targetRow.scrollIntoView({ block: 'center' });
|
|
575
585
|
});
|
|
576
586
|
}
|
|
577
587
|
|
|
578
|
-
function showOnlyFile(fileName) {
|
|
588
|
+
function showOnlyFile(fileName, skipCursor) {
|
|
579
589
|
if (REVIEW_LAZY) ensureFileReady(diffWrapperByPath(fileName));
|
|
580
590
|
document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
|
|
581
591
|
wrapper.classList.toggle('df-inactive', diffWrapperPathKey(wrapper) !== fileName);
|
|
582
592
|
});
|
|
583
|
-
|
|
593
|
+
// applySetActive passes skipCursor: it sets the caret itself via focusDiffRow(targetRow). Letting
|
|
594
|
+
// ensureDiffCursor run here would first place the caret on the file's FIRST code row, then focusDiffRow
|
|
595
|
+
// overrides it to the change — a visible double jump (the F7 "first line → change" flash).
|
|
596
|
+
if (!skipCursor) ensureDiffCursor();
|
|
584
597
|
}
|
|
585
598
|
|
|
586
599
|
// The hunk the diff caret currently sits in. Arrow keys move the caret without touching the active
|
|
@@ -622,6 +635,10 @@ function changeBlockAnchors(wrapper) {
|
|
|
622
635
|
return anchors;
|
|
623
636
|
}
|
|
624
637
|
|
|
638
|
+
// Forward F7 at a file's last change announces "last change — press F7 again" once before crossing to the
|
|
639
|
+
// next file, giving a beat to mark-viewed. Holds the path we've already announced; any caret move clears it
|
|
640
|
+
// (see setDiffCursor), so leaving and returning to the last change re-arms the announcement.
|
|
641
|
+
var pendingFileBoundary = null;
|
|
625
642
|
function next(delta) {
|
|
626
643
|
if (hunkTotal() === 0) return;
|
|
627
644
|
// Within the caret's (unviewed) file, step change-block by change-block so a context-merged hunk
|
|
@@ -640,7 +657,18 @@ function next(delta) {
|
|
|
640
657
|
}
|
|
641
658
|
}
|
|
642
659
|
}
|
|
643
|
-
// File boundary
|
|
660
|
+
// File boundary: no more change blocks in this file. Forward F7 announces "last change — press F7 again
|
|
661
|
+
// to go to the next file" on the FIRST press (a beat to mark-viewed) and only crosses on the SECOND
|
|
662
|
+
// consecutive press. Already-viewed files (and backward nav) cross immediately — no announcement.
|
|
663
|
+
if (delta > 0 && diffCursor && isDiffViewVisible() && !isFileViewed(diffCursor.path)) {
|
|
664
|
+
if (pendingFileBoundary !== diffCursor.path) {
|
|
665
|
+
pendingFileBoundary = diffCursor.path;
|
|
666
|
+
showToast(t('diff.lastHunk'));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
pendingFileBoundary = null; // second consecutive press on the same file → fall through and cross
|
|
670
|
+
}
|
|
671
|
+
// hunk-level nav to the next/prev unviewed file.
|
|
644
672
|
const caretHunk = hunkIndexAtCaret();
|
|
645
673
|
const base = caretHunk >= 0 ? caretHunk : current;
|
|
646
674
|
let idx = base < 0 ? initialHunkForNavigation(delta) : base + delta;
|
|
@@ -652,6 +680,22 @@ function next(delta) {
|
|
|
652
680
|
// Every changed file is marked viewed — nothing left to review, so F7/[/] stay put.
|
|
653
681
|
}
|
|
654
682
|
|
|
683
|
+
// Jump to the first change of the next unviewed file after `path` (wrapping). Used right after marking a
|
|
684
|
+
// file viewed: its diff body is now hidden, so staying would blank the content — we advance to the next
|
|
685
|
+
// change instead. Returns false when every changed file is viewed (nothing to advance to).
|
|
686
|
+
function gotoNextUnviewedFile(path) {
|
|
687
|
+
const total = hunkTotal();
|
|
688
|
+
if (total === 0) return false;
|
|
689
|
+
const start = firstHunkForPath(path);
|
|
690
|
+
let idx = (start >= 0 ? start : (current >= 0 ? current : 0)) + 1;
|
|
691
|
+
for (let step = 0; step < total; step++) {
|
|
692
|
+
const norm = ((idx % total) + total) % total;
|
|
693
|
+
if (!isFileViewed(hunkPathAt(norm) || '')) { setActive(norm); return true; }
|
|
694
|
+
idx += 1;
|
|
695
|
+
}
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
|
|
655
699
|
function initialHunkForNavigation(delta) {
|
|
656
700
|
const openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
|
|
657
701
|
const sourceHunk = firstHunkForPath(openPath);
|
|
@@ -893,8 +937,10 @@ function focusTree(index) {
|
|
|
893
937
|
if (rows.length === 0) return;
|
|
894
938
|
treeFocusIndex = Math.max(0, Math.min(rows.length - 1, index));
|
|
895
939
|
// Render the focus class AND scroll in the SAME frame. A fast key-repeat queues many ArrowDowns before a
|
|
896
|
-
// frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump
|
|
897
|
-
//
|
|
940
|
+
// frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump. Coalescing
|
|
941
|
+
// both keeps focus + scroll in lockstep, and scrolloffReveal scrolls ONLY when the focused row nears the
|
|
942
|
+
// top/bottom edge — a row moving inside the visible band must never drag the whole panel (revealAt did,
|
|
943
|
+
// re-centering on every move so even a mid-list row scrolled the sidebar).
|
|
898
944
|
scheduleTreeFocus();
|
|
899
945
|
}
|
|
900
946
|
var treeFocusRaf = 0;
|
|
@@ -906,7 +952,7 @@ function scheduleTreeFocus() {
|
|
|
906
952
|
if (treeFocusIndex < 0 || treeFocusIndex >= rows.length) return;
|
|
907
953
|
const el = rows[treeFocusIndex];
|
|
908
954
|
document.querySelectorAll('.tree-focus').forEach((e) => { if (e !== el) e.classList.remove('tree-focus'); });
|
|
909
|
-
if (el) { el.classList.add('tree-focus');
|
|
955
|
+
if (el) { el.classList.add('tree-focus'); scrolloffReveal(el, document.querySelector('.sidebar-scroll'), 0.15); }
|
|
910
956
|
});
|
|
911
957
|
}
|
|
912
958
|
|
|
@@ -1026,6 +1072,16 @@ function handleTreeKey(event) {
|
|
|
1026
1072
|
if (Math.abs(e.deltaY) >= Math.abs(e.deltaX) && e.deltaY !== 0) { dsc.scrollTop += e.deltaY; e.preventDefault(); }
|
|
1027
1073
|
}, { passive: false });
|
|
1028
1074
|
})();
|
|
1075
|
+
// A floating, focus-grabbing overlay (merged-comments, prompt memo, settings) is open. While one is up it
|
|
1076
|
+
// owns focus AND the only caret, so global shortcuts stand down until Esc/close — we must not navigate a
|
|
1077
|
+
// panel the user can't even see behind the overlay (nor leave a second blinking caret in it).
|
|
1078
|
+
function isFloatingModalOpen() {
|
|
1079
|
+
var sm = document.getElementById('settings-modal');
|
|
1080
|
+
if (sm && !sm.classList.contains('hidden')) return true;
|
|
1081
|
+
// The merged/memo panels are now docked (inline), not overlays — but while one OWNS focus we still stand
|
|
1082
|
+
// down the global nav shortcuts so typing / ▲▼ inside it isn't hijacked. Focus elsewhere -> shortcuts run.
|
|
1083
|
+
return isDockFocused();
|
|
1084
|
+
}
|
|
1029
1085
|
document.addEventListener('keydown', (event) => {
|
|
1030
1086
|
if (!quickOpen?.classList.contains('hidden')) {
|
|
1031
1087
|
if (handleQuickOpenKey(event)) return;
|
|
@@ -1035,8 +1091,42 @@ document.addEventListener('keydown', (event) => {
|
|
|
1035
1091
|
if (handleUsagesKey(event)) return;
|
|
1036
1092
|
}
|
|
1037
1093
|
|
|
1094
|
+
// Dock controls fire regardless of focus (terminal / merged / memo) — they sit ABOVE the focus guard so
|
|
1095
|
+
// they still work from inside a dock panel. Cmd/Ctrl+Shift+' maximizes the active dock; Cmd/Ctrl+Shift+/
|
|
1096
|
+
// and +. open the merged views; Cmd/Ctrl+Shift+N toggles the memo. (Match event.code so IME/layout never
|
|
1097
|
+
// swallows the combo.) Settings is a true overlay, so these stand down while it is up.
|
|
1098
|
+
var settingsUp = (function () { var s = document.getElementById('settings-modal'); return !!(s && !s.classList.contains('hidden')); })();
|
|
1099
|
+
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.code === 'Quote') {
|
|
1100
|
+
event.preventDefault();
|
|
1101
|
+
toggleDockMaximized();
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (!settingsUp && (event.metaKey || event.ctrlKey) && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
|
|
1105
|
+
event.preventDefault();
|
|
1106
|
+
openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (!settingsUp && (event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
|
|
1110
|
+
event.preventDefault();
|
|
1111
|
+
openMemoView();
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Settings overlay (or a focused merged/memo dock) captures keys: stand down the rest of the global
|
|
1116
|
+
// shortcuts (Cmd+1, F7, Cmd+[/], Cmd+B, …). Each has its own Esc + editing handlers.
|
|
1117
|
+
if (isFloatingModalOpen()) return;
|
|
1118
|
+
|
|
1038
1119
|
if ((event.metaKey || event.ctrlKey) && event.key === '1') {
|
|
1039
1120
|
event.preventDefault();
|
|
1121
|
+
// Coming from the diff: open the file you were viewing as source so Cmd+1 lands ON it (not a stale/blank
|
|
1122
|
+
// source pane), and the tree below points at the same file. Capture the path BEFORE openSourceFile flips
|
|
1123
|
+
// the view (isDiffViewVisible would then be false).
|
|
1124
|
+
if (isDiffViewVisible()) {
|
|
1125
|
+
var dw1 = diffActiveWrapper();
|
|
1126
|
+
var dn1 = dw1 && dw1.querySelector('.d2h-file-name');
|
|
1127
|
+
var dpath1 = (diffCursor && diffCursor.path) || (dn1 ? (dn1.textContent || '').trim() : '');
|
|
1128
|
+
if (dpath1 && sourceByPath.has(dpath1)) openSourceFile(dpath1);
|
|
1129
|
+
}
|
|
1040
1130
|
setTab('files');
|
|
1041
1131
|
focusOpenFileInTree();
|
|
1042
1132
|
return;
|
|
@@ -1076,21 +1166,8 @@ document.addEventListener('keydown', (event) => {
|
|
|
1076
1166
|
}
|
|
1077
1167
|
}
|
|
1078
1168
|
|
|
1079
|
-
// Merged
|
|
1080
|
-
//
|
|
1081
|
-
// Match the PHYSICAL key (event.code) so macOS/IME/layout never swallows the combo; fires in any focus.
|
|
1082
|
-
if ((event.metaKey || event.ctrlKey) && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
|
|
1083
|
-
event.preventDefault();
|
|
1084
|
-
openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
// Cmd/Ctrl+Shift+N opens/closes the prompt memo. Electron also routes this via the Review menu; in the
|
|
1088
|
-
// browser/serve build (no menu) this keydown is the only path. Match the physical key so layout/IME never swallows it.
|
|
1089
|
-
if ((event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
|
|
1090
|
-
event.preventDefault();
|
|
1091
|
-
openMemoView();
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1169
|
+
// (Merged views Cmd/Ctrl+Shift+/ +. and the memo Cmd/Ctrl+Shift+N are handled above the focus guard so
|
|
1170
|
+
// they work from inside a dock too.)
|
|
1094
1171
|
// "?" = question, ">" = change-request composer on the current line/selection (no modifier).
|
|
1095
1172
|
if (!event.altKey && !event.metaKey && !event.ctrlKey && (event.key === '?' || event.key === '>')) {
|
|
1096
1173
|
const ce = document.activeElement;
|
|
@@ -1115,7 +1192,12 @@ document.addEventListener('keydown', (event) => {
|
|
|
1115
1192
|
}
|
|
1116
1193
|
if (vp && currentFileSignature(vp)) {
|
|
1117
1194
|
event.preventDefault();
|
|
1118
|
-
|
|
1195
|
+
const willView = !isFileViewed(vp);
|
|
1196
|
+
setFileViewed(vp, willView);
|
|
1197
|
+
// Marking viewed hides this file's diff body — don't strand the caret on the now-blank file.
|
|
1198
|
+
// Auto-advance to the next unviewed change (the user's flow: mark viewed -> jump to next).
|
|
1199
|
+
// Unmarking stays put. If every file is viewed, gotoNextUnviewedFile is a no-op.
|
|
1200
|
+
if (willView) gotoNextUnviewedFile(vp);
|
|
1119
1201
|
return;
|
|
1120
1202
|
}
|
|
1121
1203
|
}
|
|
@@ -1139,6 +1221,10 @@ document.addEventListener('keydown', (event) => {
|
|
|
1139
1221
|
var psc = isDiffViewVisible() ? document.getElementById('diff2html-container') : (isSourceViewerVisible() ? document.getElementById('source-body') : null);
|
|
1140
1222
|
if (psc) { event.preventDefault(); psc.scrollTop += (event.key === 'PageDown' ? 0.9 : -0.9) * psc.clientHeight; return; }
|
|
1141
1223
|
}
|
|
1224
|
+
// A non-Shift keystroke between the two Shifts cancels the pending double-Shift quick-open. Without this,
|
|
1225
|
+
// "Shift → type something → Shift" within 300ms still popped the search, so it fired on nearly every other
|
|
1226
|
+
// keystroke. Reset BEFORE the caret handlers below (they swallow arrows) so arrow keys break it too.
|
|
1227
|
+
if (event.key !== 'Shift') { lastShiftAt = 0; lastShiftSide = 0; }
|
|
1142
1228
|
if (treeFocusIndex >= 0 && handleTreeKey(event)) return;
|
|
1143
1229
|
if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isSourceViewerVisible() && handleSourceCaretKey(event)) return;
|
|
1144
1230
|
if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isDiffViewVisible() && handleDiffCaretKey(event)) return;
|
|
@@ -1259,8 +1345,12 @@ document.addEventListener('keydown', (event) => {
|
|
|
1259
1345
|
// where they were reading. Shift+F7 — and any file with no hunk of its own — falls through to plain
|
|
1260
1346
|
// prev/next-change navigation across the whole diff.
|
|
1261
1347
|
if (delta > 0 && sourceViewer && !sourceViewer.classList.contains('hidden')) {
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1348
|
+
const sp = sourceViewer.dataset.openPath || '';
|
|
1349
|
+
const sourceHunk = firstHunkForPath(sp);
|
|
1350
|
+
// Enter the diff at the open file's own hunk — UNLESS it's already viewed. A viewed file's diff body
|
|
1351
|
+
// is hidden (display:none), so landing on it blanks the content and F7 appears stuck; fall through to
|
|
1352
|
+
// next() instead so we skip to an unviewed change.
|
|
1353
|
+
if (sourceHunk >= 0 && !isFileViewed(sp)) {
|
|
1264
1354
|
setActive(sourceHunk);
|
|
1265
1355
|
return;
|
|
1266
1356
|
}
|
|
@@ -1360,7 +1450,11 @@ if (!restored) {
|
|
|
1360
1450
|
else openDefaultSourceFile();
|
|
1361
1451
|
}
|
|
1362
1452
|
initSourceTreeFolds();
|
|
1363
|
-
|
|
1453
|
+
// Electron receives live updates over IPC (monacoriMenu.onDiffUpdate); only serve/browser needs the HTTP
|
|
1454
|
+
// poller. Under file:// its fetch just fails every 1.5s for the app's whole life, so skip it in Electron.
|
|
1455
|
+
if (watchEnabled && !(window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function')) {
|
|
1456
|
+
setInterval(checkForLiveUpdate, 1500);
|
|
1457
|
+
}
|
|
1364
1458
|
window.addEventListener('beforeunload', saveUiState);
|
|
1365
1459
|
|
|
1366
1460
|
// First render has painted — drop the boot overlay (it bridged the blank gap right after loadFile). Two
|
|
@@ -1542,9 +1636,17 @@ function renderDiffCaret() {
|
|
|
1542
1636
|
row.classList.add('mc-diff-cursor-row');
|
|
1543
1637
|
var ctn = diffCellCtn(row);
|
|
1544
1638
|
if (!ctn) return;
|
|
1545
|
-
// Empty line (ctn is just a <br>):
|
|
1546
|
-
//
|
|
1547
|
-
if ((ctn.textContent || '').length === 0)
|
|
1639
|
+
// Empty line (ctn is just a <br>): an inline caret span would wrap onto a 2nd visual line and break the
|
|
1640
|
+
// row height, so position the caret absolutely — it shows without affecting the layout.
|
|
1641
|
+
if ((ctn.textContent || '').length === 0) {
|
|
1642
|
+
var espan = document.createElement('span');
|
|
1643
|
+
espan.className = 'code-cursor';
|
|
1644
|
+
espan.setAttribute('aria-hidden', 'true');
|
|
1645
|
+
espan.style.position = 'absolute';
|
|
1646
|
+
ctn.appendChild(espan);
|
|
1647
|
+
diffCaretSpan = espan;
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1548
1650
|
var pos = diffCaretDomPosition(ctn, diffCursor.column);
|
|
1549
1651
|
if (!pos) return;
|
|
1550
1652
|
var span = document.createElement('span');
|
|
@@ -1568,6 +1670,7 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
|
|
|
1568
1670
|
var ri = Math.max(0, Math.min(rowIndex, rows.length - 1));
|
|
1569
1671
|
var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
|
|
1570
1672
|
diffCursor = { path: path, side: side, rowIndex: ri, column: col };
|
|
1673
|
+
pendingFileBoundary = null; // any caret move re-arms the last-change announcement for the next F7 (see next)
|
|
1571
1674
|
diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
|
|
1572
1675
|
if (reveal) {
|
|
1573
1676
|
// Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
|
|
@@ -1592,7 +1695,7 @@ function scheduleDiffReveal(wrapper, side, ri) {
|
|
|
1592
1695
|
applyDiffSelection();
|
|
1593
1696
|
if (!t) return;
|
|
1594
1697
|
var row = diffRowAt(t.wrapper, t.side, t.ri);
|
|
1595
|
-
|
|
1698
|
+
scrolloffReveal(row, document.getElementById('diff2html-container'), 0.15);
|
|
1596
1699
|
});
|
|
1597
1700
|
}
|
|
1598
1701
|
function navEntryOf(kind) {
|
|
@@ -1739,11 +1842,51 @@ function moveDiffWord(dir, extend) {
|
|
|
1739
1842
|
setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, ncol, true);
|
|
1740
1843
|
if (anchor) { diffSelectionAnchor = anchor; applyDiffSelection(); }
|
|
1741
1844
|
}
|
|
1845
|
+
// Comment boxes are injected on the right(new) side, right after the line's row (see injectThreadRow /
|
|
1846
|
+
// renderDiffComments). Split-view rows align 1:1 by index, so the caret's row index on the new side finds
|
|
1847
|
+
// the adjacent box regardless of which side the caret sits on. Mirrors commentRowSiblingOf for the source view.
|
|
1848
|
+
function diffCommentBoxSiblingOf(dir) {
|
|
1849
|
+
if (!diffCursor) return null;
|
|
1850
|
+
var wrapper = diffWrapperByPath(diffCursor.path);
|
|
1851
|
+
if (!wrapper) return null;
|
|
1852
|
+
var rows = diffRowsOf(diffSideTable(wrapper, 'new'));
|
|
1853
|
+
var row = rows[diffCursor.rowIndex];
|
|
1854
|
+
if (!row) return null;
|
|
1855
|
+
var sib = dir < 0 ? row.previousElementSibling : row.nextElementSibling;
|
|
1856
|
+
return (sib && sib.classList && sib.classList.contains('mc-comment-row')) ? sib : null;
|
|
1857
|
+
}
|
|
1742
1858
|
function handleDiffCaretKey(event) {
|
|
1743
1859
|
if (!isDiffViewVisible() || !diffCursor) return false;
|
|
1744
1860
|
var ae = document.activeElement;
|
|
1745
1861
|
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return false;
|
|
1746
1862
|
var extend = event.shiftKey;
|
|
1863
|
+
// A comment box is selected: Backspace/Delete removes it, `e` edits it, an arrow/Escape steps off it.
|
|
1864
|
+
// Same contract as the source view (handleSourceCaretKey), but caret moves go through setDiffCursor.
|
|
1865
|
+
if (selectedCommentRow) {
|
|
1866
|
+
if (event.key === 'Backspace' || event.key === 'Delete') { event.preventDefault(); deleteCommentsInRow(selectedCommentRow); return true; }
|
|
1867
|
+
if (event.key === 'e' || event.key === 'E') { event.preventDefault(); editCommentInRow(selectedCommentRow); return true; }
|
|
1868
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Escape') {
|
|
1869
|
+
var dir = event.key === 'ArrowUp' ? -1 : (event.key === 'ArrowDown' ? 1 : 0);
|
|
1870
|
+
var sib = dir < 0 ? selectedCommentRow.previousElementSibling : (dir > 0 ? selectedCommentRow.nextElementSibling : null);
|
|
1871
|
+
selectedCommentRow.classList.remove('mc-row-selected');
|
|
1872
|
+
selectedCommentRow = null;
|
|
1873
|
+
event.preventDefault();
|
|
1874
|
+
var wrapper = diffWrapperByPath(diffCursor.path);
|
|
1875
|
+
if (sib && wrapper && isDiffCodeRow(sib)) {
|
|
1876
|
+
var rows = diffRowsOf(diffSideTable(wrapper, 'new'));
|
|
1877
|
+
var idx = rows.indexOf(sib);
|
|
1878
|
+
if (idx >= 0) { setDiffCursor(diffCursor.path, 'new', idx, 0, true); return true; }
|
|
1879
|
+
}
|
|
1880
|
+
setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, diffCursor.column, false); // restore caret where it was
|
|
1881
|
+
return true;
|
|
1882
|
+
}
|
|
1883
|
+
return false;
|
|
1884
|
+
}
|
|
1885
|
+
// Plain Up/Down: a comment box attached to the caret line is a selectable stop (caret stays visible).
|
|
1886
|
+
if (!extend && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
|
1887
|
+
var box = diffCommentBoxSiblingOf(event.key === 'ArrowUp' ? -1 : 1);
|
|
1888
|
+
if (box) { event.preventDefault(); selectCommentRow(box); return true; }
|
|
1889
|
+
}
|
|
1747
1890
|
if (event.key === 'ArrowDown') { event.preventDefault(); moveDiffCursor(1, 0, extend); return true; }
|
|
1748
1891
|
if (event.key === 'ArrowUp') { event.preventDefault(); moveDiffCursor(-1, 0, extend); return true; }
|
|
1749
1892
|
if (event.key === 'ArrowLeft') { event.preventDefault(); moveDiffCursor(0, -1, extend); return true; }
|
|
@@ -1768,35 +1911,30 @@ function showToast(message) {
|
|
|
1768
1911
|
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
|
|
1769
1912
|
}, 4500);
|
|
1770
1913
|
}
|
|
1771
|
-
//
|
|
1772
|
-
//
|
|
1773
|
-
//
|
|
1774
|
-
//
|
|
1914
|
+
// Follow each comment to its snapshot line (c.code) in the current content: same line if unchanged, else the
|
|
1915
|
+
// nearest exact match of that line. A comment is NEVER auto-deleted. If its line can't be found we leave it
|
|
1916
|
+
// where it is — this happens routinely WITHOUT the file changing: a comment anchored to a deleted/old-side
|
|
1917
|
+
// diff line (comments carry no side, so old-side text never matches the new content) would otherwise vanish.
|
|
1918
|
+
// Silently dropping user-authored comments loses data; the reviewer can remove a stale one with the × button.
|
|
1919
|
+
// Files whose content isn't loaded yet (lazy) are skipped here and reconciled once loadSourceData arrives.
|
|
1775
1920
|
function remapComments() {
|
|
1776
1921
|
if (!reviewComments.length) return;
|
|
1777
|
-
var
|
|
1778
|
-
reviewComments
|
|
1922
|
+
var moved = 0;
|
|
1923
|
+
reviewComments.forEach(function (c) {
|
|
1779
1924
|
var file = sourceByPath.get(c.path);
|
|
1780
|
-
if (!file || !file.embedded || typeof file.content !== 'string' || !file.content) return
|
|
1925
|
+
if (!file || !file.embedded || typeof file.content !== 'string' || !file.content) return;
|
|
1781
1926
|
var code = c.code == null ? '' : String(c.code);
|
|
1782
|
-
if (!code.trim()) return
|
|
1927
|
+
if (!code.trim()) return;
|
|
1783
1928
|
var lines = file.content.split(/\r?\n/);
|
|
1784
|
-
if (lines[c.line - 1] === code) return
|
|
1929
|
+
if (lines[c.line - 1] === code) return;
|
|
1785
1930
|
var best = -1, bestDist = Infinity;
|
|
1786
1931
|
for (var i = 0; i < lines.length; i++) {
|
|
1787
1932
|
if (lines[i] === code) { var d = Math.abs(i - (c.line - 1)); if (d < bestDist) { bestDist = d; best = i; } }
|
|
1788
1933
|
}
|
|
1789
|
-
if (best >= 0
|
|
1790
|
-
dropped.push(c);
|
|
1791
|
-
return false;
|
|
1934
|
+
if (best >= 0 && c.line !== best + 1) { c.line = best + 1; moved++; } // moved to follow the line; not found -> keep as-is
|
|
1792
1935
|
});
|
|
1793
|
-
if (!
|
|
1936
|
+
if (!moved) return; // nothing moved — skip the save/re-render
|
|
1794
1937
|
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
1938
|
refreshComments();
|
|
1801
1939
|
}
|
|
1802
1940
|
function saveComments() {
|
|
@@ -1821,6 +1959,14 @@ function addComment(kind, path, line, code, text) {
|
|
|
1821
1959
|
reviewComments.push({ seq: commentSeq, kind: kind, path: path, line: line, code: String(code || ''), text: trimmed });
|
|
1822
1960
|
saveComments();
|
|
1823
1961
|
}
|
|
1962
|
+
// Edit an existing comment in place (e on a selected box -> composer prefilled -> save). Empty text deletes it.
|
|
1963
|
+
function updateComment(seq, text) {
|
|
1964
|
+
var c = reviewComments.find(function (x) { return x.seq === seq; });
|
|
1965
|
+
if (!c) return;
|
|
1966
|
+
var trimmed = String(text || '').trim();
|
|
1967
|
+
if (trimmed) { c.text = trimmed; saveComments(); }
|
|
1968
|
+
else { deleteComment(seq); }
|
|
1969
|
+
}
|
|
1824
1970
|
function deleteComment(seq) {
|
|
1825
1971
|
reviewComments = reviewComments.filter(function (c) { return c.seq !== seq; });
|
|
1826
1972
|
saveComments();
|
|
@@ -1901,6 +2047,7 @@ function composerTargetLabel(s) {
|
|
|
1901
2047
|
function threadHtml(path, line) {
|
|
1902
2048
|
var html = '';
|
|
1903
2049
|
commentsAt(path, line).forEach(function (c) {
|
|
2050
|
+
if (composerState && composerState.editSeq === c.seq) return; // being edited -> rendered as the composer below
|
|
1904
2051
|
html += '<div class="mc-card mc-' + c.kind + '">'
|
|
1905
2052
|
+ '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(c.kind) + '</span>'
|
|
1906
2053
|
+ '<button type="button" class="mc-del" data-seq="' + c.seq + '" title="' + escapeHtml(t('composer.delete')) + '">×</button></div>'
|
|
@@ -1910,7 +2057,7 @@ function threadHtml(path, line) {
|
|
|
1910
2057
|
var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
|
|
1911
2058
|
html += '<div class="mc-card mc-' + composerState.kind + ' mc-composer">'
|
|
1912
2059
|
+ '<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) + '"
|
|
2060
|
+
+ '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '">' + escapeHtml(composerState.editText || '') + '</textarea>'
|
|
1914
2061
|
+ '<div class="mc-actions"><button type="button" class="mc-btn mc-save">' + escapeHtml(t('composer.save')) + '</button>'
|
|
1915
2062
|
+ '<button type="button" class="mc-btn mc-ghost mc-cancel">' + escapeHtml(t('composer.cancel')) + '</button>'
|
|
1916
2063
|
+ '<span class="mc-hint">' + escapeHtml(t('composer.hint')) + '</span></div></div>';
|
|
@@ -2069,6 +2216,7 @@ function closeComposer() {
|
|
|
2069
2216
|
if (!composerState) return;
|
|
2070
2217
|
composerState = null;
|
|
2071
2218
|
refreshComments();
|
|
2219
|
+
flushPendingDiffUpdate(); // apply any live watch refresh that was held while composing
|
|
2072
2220
|
}
|
|
2073
2221
|
// The composer is injected into BOTH the diff and source views (refreshComments renders comments in
|
|
2074
2222
|
// each), but only one view is on screen at a time — the other lives inside a `.hidden` container with
|
|
@@ -2089,9 +2237,11 @@ function saveComposer(ta) {
|
|
|
2089
2237
|
if (!composerState) return;
|
|
2090
2238
|
var box = ta || activeComposerInput();
|
|
2091
2239
|
if (!box) return;
|
|
2092
|
-
|
|
2240
|
+
if (composerState.editSeq != null) updateComment(composerState.editSeq, box.value);
|
|
2241
|
+
else addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
|
|
2093
2242
|
composerState = null;
|
|
2094
2243
|
refreshComments();
|
|
2244
|
+
flushPendingDiffUpdate(); // apply any live watch refresh that was held while composing
|
|
2095
2245
|
}
|
|
2096
2246
|
|
|
2097
2247
|
// Default merge-prompt headings, localized: a Korean user gets Korean defaults. Editable in
|
|
@@ -2233,36 +2383,145 @@ function buildMergedText(kind) {
|
|
|
2233
2383
|
return lines.join(nl);
|
|
2234
2384
|
}
|
|
2235
2385
|
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2386
|
+
// ===== Bottom dock: merged-prompt / memo / terminal share ONE docked slot below the editor =====
|
|
2387
|
+
// Only one is visible at a time — opening one closes the others (the terminal included). Cmd/Ctrl+Shift+'
|
|
2388
|
+
// maximizes the active dock over the editor area (the sidebar stays). A top resizer drags the height.
|
|
2389
|
+
var dockHeightKey = 'monacori-dock-height';
|
|
2390
|
+
var dockMaximized = false;
|
|
2391
|
+
function applyDockHeight(px) {
|
|
2392
|
+
var h = Math.max(140, Math.min(px, window.innerHeight - 120));
|
|
2393
|
+
document.documentElement.style.setProperty('--dock-height', h + 'px');
|
|
2394
|
+
}
|
|
2395
|
+
(function () { var s = parseInt(localStorage.getItem(dockHeightKey) || '', 10); if (s) applyDockHeight(s); })();
|
|
2396
|
+
// The dock panel currently filling the slot: a merged/memo panel, else the terminal when it's open.
|
|
2397
|
+
function activeDockPanel() {
|
|
2398
|
+
var mm = document.getElementById('mc-merged-panel') || document.getElementById('mc-memo-panel');
|
|
2399
|
+
if (mm) return mm;
|
|
2400
|
+
var term = document.getElementById('terminal-panel');
|
|
2401
|
+
return (term && !term.classList.contains('hidden')) ? term : null;
|
|
2402
|
+
}
|
|
2403
|
+
function applyDockMaximized() {
|
|
2404
|
+
if (!activeDockPanel()) dockMaximized = false; // nothing docked -> can't stay maximized
|
|
2405
|
+
document.body.classList.toggle('dock-maximized', dockMaximized);
|
|
2406
|
+
}
|
|
2407
|
+
function toggleDockMaximized() {
|
|
2408
|
+
if (!activeDockPanel()) return; // nothing docked -> nothing to maximize
|
|
2409
|
+
dockMaximized = !dockMaximized;
|
|
2410
|
+
applyDockMaximized();
|
|
2411
|
+
}
|
|
2412
|
+
function isDockFocused() {
|
|
2413
|
+
var ae = document.activeElement;
|
|
2414
|
+
return !!(ae && ae.closest && ae.closest('.dock-panel'));
|
|
2415
|
+
}
|
|
2416
|
+
// Close the merged/memo docks (the terminal's setOpen also calls this so the slot stays exclusive).
|
|
2417
|
+
function closeMergedMemoDocks() {
|
|
2418
|
+
var m = document.getElementById('mc-merged-panel'); if (m) m.remove();
|
|
2419
|
+
var n = document.getElementById('mc-memo-panel'); if (n) n.remove();
|
|
2420
|
+
document.querySelectorAll('.dock-backdrop').forEach(function (b) { b.remove(); });
|
|
2421
|
+
document.body.classList.toggle('dock-open', !!activeDockPanel());
|
|
2422
|
+
// floating-dock tracks merged/memo only (NOT the terminal) so the maximize CSS hides content for a
|
|
2423
|
+
// terminal dock but never for these floating panels.
|
|
2424
|
+
document.body.classList.toggle('floating-dock', !!(document.getElementById('mc-merged-panel') || document.getElementById('mc-memo-panel')));
|
|
2425
|
+
applyDockMaximized();
|
|
2426
|
+
}
|
|
2427
|
+
window.__monacoriCloseDocks = closeMergedMemoDocks;
|
|
2428
|
+
// Retry-focus a docked field (Electron async-restores focus to <body>, so a one-shot focus can lose the race).
|
|
2429
|
+
function focusDockField(field, panelSel) {
|
|
2430
|
+
var tries = 0;
|
|
2431
|
+
var tryF = function () {
|
|
2432
|
+
if (!document.querySelector(panelSel)) return true;
|
|
2433
|
+
if (document.activeElement === field) return true;
|
|
2434
|
+
try { field.focus(); } catch (e) {}
|
|
2435
|
+
return document.activeElement === field;
|
|
2436
|
+
};
|
|
2437
|
+
if (!tryF()) { var iv = setInterval(function () { if (tryF() || ++tries > 12) clearInterval(iv); }, 25); }
|
|
2438
|
+
}
|
|
2439
|
+
// Build a docked panel shell (resizer + bar with Maximize/Close + body) and mount it below the editor.
|
|
2440
|
+
// Opening it closes the terminal and any other merged/memo dock (the slot is exclusive). Returns
|
|
2441
|
+
// { panel, body, bar, close }.
|
|
2442
|
+
function mountDock(id, titleText) {
|
|
2443
|
+
if (window.__monacoriTerminal && typeof window.__monacoriTerminal.close === 'function') {
|
|
2444
|
+
try { window.__monacoriTerminal.close(); } catch (e) {}
|
|
2445
|
+
}
|
|
2446
|
+
var prior = document.getElementById(id);
|
|
2447
|
+
if (prior) prior.remove();
|
|
2448
|
+
closeMergedMemoDocks();
|
|
2243
2449
|
var panel = document.createElement('div');
|
|
2244
|
-
panel.
|
|
2245
|
-
|
|
2246
|
-
|
|
2450
|
+
panel.id = id;
|
|
2451
|
+
panel.className = 'dock-panel';
|
|
2452
|
+
panel.tabIndex = -1;
|
|
2453
|
+
// The panel floats over the editor; a dim backdrop sits behind it (click to dismiss).
|
|
2454
|
+
var backdrop = document.createElement('div');
|
|
2455
|
+
backdrop.className = 'dock-backdrop';
|
|
2456
|
+
var resizer = document.createElement('div');
|
|
2457
|
+
resizer.className = 'dock-resizer';
|
|
2458
|
+
resizer.setAttribute('aria-hidden', 'true');
|
|
2459
|
+
var bar = document.createElement('div');
|
|
2460
|
+
bar.className = 'dock-bar';
|
|
2247
2461
|
var title = document.createElement('span');
|
|
2248
|
-
title.
|
|
2462
|
+
title.className = 'dock-title';
|
|
2463
|
+
title.textContent = titleText;
|
|
2464
|
+
var maxBtn = document.createElement('button');
|
|
2465
|
+
maxBtn.type = 'button';
|
|
2466
|
+
maxBtn.className = 'dock-btn dock-max';
|
|
2467
|
+
maxBtn.setAttribute('data-i18n-title', 'dock.maximize');
|
|
2468
|
+
maxBtn.title = t('dock.maximize');
|
|
2469
|
+
maxBtn.textContent = '⤢'; // ⤢ maximize glyph
|
|
2249
2470
|
var closeBtn = document.createElement('button');
|
|
2250
2471
|
closeBtn.type = 'button';
|
|
2251
|
-
closeBtn.className = '
|
|
2472
|
+
closeBtn.className = 'dock-btn dock-close';
|
|
2473
|
+
closeBtn.setAttribute('data-i18n', 'merged.close');
|
|
2252
2474
|
closeBtn.textContent = t('merged.close');
|
|
2475
|
+
var body = document.createElement('div');
|
|
2476
|
+
body.className = 'dock-body';
|
|
2477
|
+
bar.appendChild(title);
|
|
2478
|
+
bar.appendChild(maxBtn);
|
|
2479
|
+
bar.appendChild(closeBtn);
|
|
2480
|
+
panel.appendChild(resizer);
|
|
2481
|
+
panel.appendChild(bar);
|
|
2482
|
+
panel.appendChild(body);
|
|
2483
|
+
document.body.appendChild(backdrop);
|
|
2484
|
+
document.body.appendChild(panel);
|
|
2485
|
+
function close() { panel.remove(); backdrop.remove(); closeMergedMemoDocks(); }
|
|
2486
|
+
maxBtn.addEventListener('click', function () { toggleDockMaximized(); });
|
|
2487
|
+
closeBtn.addEventListener('click', close);
|
|
2488
|
+
backdrop.addEventListener('click', close); // click the dim behind the panel to dismiss
|
|
2489
|
+
// Esc closes the dock when focus is inside it; the editor keeps its own handlers otherwise.
|
|
2490
|
+
panel.addEventListener('keydown', function (e) {
|
|
2491
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
|
|
2492
|
+
});
|
|
2493
|
+
resizer.addEventListener('mousedown', function (e) {
|
|
2494
|
+
e.preventDefault();
|
|
2495
|
+
resizer.classList.add('resizing');
|
|
2496
|
+
function move(ev) { applyDockHeight(window.innerHeight - ev.clientY); }
|
|
2497
|
+
function up() {
|
|
2498
|
+
resizer.classList.remove('resizing');
|
|
2499
|
+
document.removeEventListener('mousemove', move);
|
|
2500
|
+
document.removeEventListener('mouseup', up);
|
|
2501
|
+
var cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--dock-height'), 10);
|
|
2502
|
+
if (cur) { try { localStorage.setItem(dockHeightKey, String(cur)); } catch (x) {} }
|
|
2503
|
+
}
|
|
2504
|
+
document.addEventListener('mousemove', move);
|
|
2505
|
+
document.addEventListener('mouseup', up);
|
|
2506
|
+
});
|
|
2507
|
+
document.body.classList.add('dock-open');
|
|
2508
|
+
document.body.classList.add('floating-dock'); // scopes the maximize CSS so it doesn't hide the diff
|
|
2509
|
+
applyDockMaximized();
|
|
2510
|
+
return { panel: panel, body: body, bar: bar, close: close };
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function openMergedView(kind) {
|
|
2514
|
+
var dock = mountDock('mc-merged-panel', kind === 'q' ? t('merged.qTitle') : t('merged.cTitle'));
|
|
2515
|
+
dock.panel.dataset.kind = kind; // remembered so a live locale switch can re-render this same view
|
|
2253
2516
|
var area = document.createElement('textarea');
|
|
2254
2517
|
area.className = 'mc-modal-text';
|
|
2255
2518
|
// NOT readOnly: a readOnly textarea hides the caret in Chromium, yet we need it VISIBLE so the user sees
|
|
2256
|
-
// which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead
|
|
2257
|
-
// effect while the caret and selection stay fully interactive.
|
|
2519
|
+
// which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead.
|
|
2258
2520
|
area.value = buildMergedText(kind);
|
|
2259
2521
|
area.addEventListener('beforeinput', function (e) { e.preventDefault(); });
|
|
2260
|
-
// Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret
|
|
2261
|
-
//
|
|
2262
|
-
// Removing here calls deleteComment(), which re-syncs the on-screen comment boxes via refreshComments.
|
|
2522
|
+
// Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret. Opt/Alt+Arrow steps
|
|
2523
|
+
// the caret comment-to-comment so each can be acted on without hand-scrolling.
|
|
2263
2524
|
area.addEventListener('keydown', function (e) {
|
|
2264
|
-
// Opt/Alt + Arrow steps the caret to the next/previous comment block so you can move comment-to-comment
|
|
2265
|
-
// and act on each with Opt+Enter, without hand-scrolling.
|
|
2266
2525
|
if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
2267
2526
|
e.preventDefault();
|
|
2268
2527
|
e.stopPropagation();
|
|
@@ -2277,57 +2536,28 @@ function openMergedView(kind) {
|
|
|
2277
2536
|
var cxy = mergedCaretXY(area);
|
|
2278
2537
|
var x = cxy.x, y = cxy.below, flipTop = cxy.top;
|
|
2279
2538
|
var rerender = function () {
|
|
2280
|
-
if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) {
|
|
2539
|
+
if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) { dock.close(); return; }
|
|
2281
2540
|
area.value = buildMergedText(kind);
|
|
2282
2541
|
};
|
|
2283
2542
|
if (area.selectionStart !== area.selectionEnd || seqs.length > 1) {
|
|
2284
|
-
|
|
2543
|
+
// Select-all / multi-comment: offer send-to-terminal (the whole merged text) FIRST, then remove-all.
|
|
2544
|
+
var multi = [];
|
|
2545
|
+
if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
|
|
2546
|
+
multi.push({ label: t('merged.sendToTerminal'), onSelect: function () { var text = buildMergedText(kind); dock.close(); window.__monacoriTerminal.enterSendMode(text); } });
|
|
2547
|
+
}
|
|
2548
|
+
multi.push({ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } });
|
|
2549
|
+
showCustomDropdown(x, y, multi, flipTop);
|
|
2285
2550
|
} else {
|
|
2286
2551
|
var seq = seqs[0];
|
|
2287
2552
|
showCustomDropdown(x, y, [
|
|
2288
|
-
{ label: t('dropdown.navigate'), onSelect: function () {
|
|
2553
|
+
{ label: t('dropdown.navigate'), onSelect: function () { dock.close(); navigateToComment(seq); } },
|
|
2289
2554
|
{ label: t('dropdown.remove'), onSelect: function () { deleteComment(seq); rerender(); } },
|
|
2290
2555
|
], flipTop);
|
|
2291
2556
|
}
|
|
2292
2557
|
});
|
|
2293
|
-
|
|
2294
|
-
//
|
|
2295
|
-
|
|
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
|
-
}
|
|
2309
|
-
head.appendChild(title);
|
|
2310
|
-
if (sendBtn) head.appendChild(sendBtn);
|
|
2311
|
-
head.appendChild(closeBtn);
|
|
2312
|
-
panel.appendChild(head);
|
|
2313
|
-
panel.appendChild(area);
|
|
2314
|
-
modal.appendChild(panel);
|
|
2315
|
-
modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
|
|
2316
|
-
modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
|
|
2317
|
-
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
|
|
2321
|
-
var modalFocusTries = 0;
|
|
2322
|
-
var tryFocusModal = function () {
|
|
2323
|
-
if (!document.getElementById('mc-modal')) return true;
|
|
2324
|
-
if (document.activeElement === modalFocusTarget) return true;
|
|
2325
|
-
try { modalFocusTarget.focus(); modalFocusTarget.selectionStart = modalFocusTarget.selectionEnd = 0; } catch (e) {}
|
|
2326
|
-
return document.activeElement === modalFocusTarget;
|
|
2327
|
-
};
|
|
2328
|
-
if (!tryFocusModal()) {
|
|
2329
|
-
var modalFocusIv = setInterval(function () { if (tryFocusModal() || ++modalFocusTries > 12) clearInterval(modalFocusIv); }, 25);
|
|
2330
|
-
}
|
|
2558
|
+
dock.body.appendChild(area);
|
|
2559
|
+
// Focus the read-only text so the caret is visible and Opt+Arrow / Opt+Enter work; retry (Electron focus race).
|
|
2560
|
+
focusDockField(area, '#mc-merged-panel');
|
|
2331
2561
|
}
|
|
2332
2562
|
|
|
2333
2563
|
// Prompt memo (Cmd/Ctrl+Shift+N): one freeform Markdown scratchpad with a live split preview, persisted
|
|
@@ -2345,27 +2575,10 @@ function renderMemoMd(text) {
|
|
|
2345
2575
|
return renderMarkdownBlocks(text).map(function (b) { return b.html; }).join('');
|
|
2346
2576
|
}
|
|
2347
2577
|
function openMemoView() {
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
var
|
|
2351
|
-
|
|
2352
|
-
modal.className = 'mc-modal';
|
|
2353
|
-
var panel = document.createElement('div');
|
|
2354
|
-
panel.className = 'mc-modal-panel mc-memo-panel';
|
|
2355
|
-
var head = document.createElement('div');
|
|
2356
|
-
head.className = 'mc-modal-head';
|
|
2357
|
-
var title = document.createElement('span');
|
|
2358
|
-
title.setAttribute('data-i18n', 'memo.title');
|
|
2359
|
-
title.textContent = t('memo.title');
|
|
2360
|
-
var closeBtn = document.createElement('button');
|
|
2361
|
-
closeBtn.type = 'button';
|
|
2362
|
-
closeBtn.className = 'mc-btn mc-ghost';
|
|
2363
|
-
closeBtn.setAttribute('data-i18n', 'merged.close');
|
|
2364
|
-
closeBtn.textContent = t('merged.close');
|
|
2365
|
-
closeBtn.addEventListener('click', function () { modal.remove(); });
|
|
2366
|
-
|
|
2367
|
-
var body = document.createElement('div');
|
|
2368
|
-
body.className = 'mc-memo-body';
|
|
2578
|
+
if (document.getElementById('mc-memo-panel')) { closeMergedMemoDocks(); return; } // the shortcut toggles: 2nd press closes
|
|
2579
|
+
var dock = mountDock('mc-memo-panel', t('memo.title'));
|
|
2580
|
+
var memoBody = document.createElement('div');
|
|
2581
|
+
memoBody.className = 'mc-memo-body';
|
|
2369
2582
|
var area = document.createElement('textarea');
|
|
2370
2583
|
area.className = 'mc-modal-text mc-memo-edit';
|
|
2371
2584
|
area.spellcheck = false;
|
|
@@ -2379,45 +2592,25 @@ function openMemoView() {
|
|
|
2379
2592
|
saveMemo(area.value);
|
|
2380
2593
|
preview.innerHTML = renderMemoMd(area.value);
|
|
2381
2594
|
});
|
|
2382
|
-
|
|
2383
|
-
//
|
|
2384
|
-
// only once a terminal pane exists; enterSendMode reopens the panel if it was closed.
|
|
2385
|
-
var sendBtn = null;
|
|
2595
|
+
// Terminal send: hand the current draft to pane-pick mode. Shown only once a terminal pane exists;
|
|
2596
|
+
// enterSendMode reopens the terminal (which closes this memo dock — the slot is exclusive).
|
|
2386
2597
|
if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
|
|
2387
|
-
sendBtn = document.createElement('button');
|
|
2598
|
+
var sendBtn = document.createElement('button');
|
|
2388
2599
|
sendBtn.type = 'button';
|
|
2389
|
-
sendBtn.className = '
|
|
2600
|
+
sendBtn.className = 'dock-btn mc-send-term';
|
|
2390
2601
|
sendBtn.setAttribute('data-i18n', 'merged.sendToTerminal');
|
|
2391
2602
|
sendBtn.textContent = t('merged.sendToTerminal');
|
|
2392
2603
|
sendBtn.addEventListener('click', function () {
|
|
2393
2604
|
var text = area.value;
|
|
2394
|
-
|
|
2605
|
+
dock.close();
|
|
2395
2606
|
window.__monacoriTerminal.enterSendMode(text);
|
|
2396
2607
|
});
|
|
2608
|
+
dock.bar.insertBefore(sendBtn, dock.bar.querySelector('.dock-max'));
|
|
2397
2609
|
}
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
body.appendChild(area);
|
|
2403
|
-
body.appendChild(preview);
|
|
2404
|
-
panel.appendChild(head);
|
|
2405
|
-
panel.appendChild(body);
|
|
2406
|
-
modal.appendChild(panel);
|
|
2407
|
-
modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
|
|
2408
|
-
modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
|
|
2409
|
-
document.body.appendChild(modal);
|
|
2410
|
-
// Focus the editor; Electron async-restores focus to <body>, so retry briefly (same as the composer/merged view).
|
|
2411
|
-
var memoFocusTries = 0;
|
|
2412
|
-
var tryFocusMemo = function () {
|
|
2413
|
-
if (!document.getElementById('mc-memo')) return true;
|
|
2414
|
-
if (document.activeElement === area) return true;
|
|
2415
|
-
try { area.focus(); } catch (e) {}
|
|
2416
|
-
return document.activeElement === area;
|
|
2417
|
-
};
|
|
2418
|
-
if (!tryFocusMemo()) {
|
|
2419
|
-
var memoFocusIv = setInterval(function () { if (tryFocusMemo() || ++memoFocusTries > 12) clearInterval(memoFocusIv); }, 25);
|
|
2420
|
-
}
|
|
2610
|
+
memoBody.appendChild(area);
|
|
2611
|
+
memoBody.appendChild(preview);
|
|
2612
|
+
dock.body.appendChild(memoBody);
|
|
2613
|
+
focusDockField(area, '#mc-memo-panel');
|
|
2421
2614
|
}
|
|
2422
2615
|
|
|
2423
2616
|
document.addEventListener('click', function (event) {
|
|
@@ -2623,10 +2816,13 @@ refreshComments();
|
|
|
2623
2816
|
|
|
2624
2817
|
function isOpen() { return !panel.classList.contains('hidden'); }
|
|
2625
2818
|
function setOpen(open) {
|
|
2819
|
+
// The terminal shares the bottom dock slot with merged/memo — opening it closes those (exclusive slot).
|
|
2820
|
+
if (open && typeof window.__monacoriCloseDocks === 'function') { try { window.__monacoriCloseDocks(); } catch (e) {} }
|
|
2626
2821
|
panel.classList.toggle('hidden', !open);
|
|
2627
2822
|
document.body.classList.toggle('terminal-open', open);
|
|
2628
2823
|
if (toggleBtn) toggleBtn.classList.toggle('is-active', open);
|
|
2629
2824
|
try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch (e) {}
|
|
2825
|
+
if (typeof applyDockMaximized === 'function') applyDockMaximized(); // keep Cmd+Shift+' maximize in sync
|
|
2630
2826
|
if (open) {
|
|
2631
2827
|
if (panes.length === 0) makePane();
|
|
2632
2828
|
requestAnimationFrame(function () { fitAll(); if (active) try { active.term.focus(); } catch (e) {} });
|
|
@@ -2850,8 +3046,10 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
|
|
|
2850
3046
|
// Capture so closing settings wins over other Escape handlers (lightbox / composer).
|
|
2851
3047
|
document.addEventListener('keydown', function (e) {
|
|
2852
3048
|
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
|
|
3049
|
+
// Cmd/Ctrl+, (the standard "Preferences" accelerator) toggles the settings panel from anywhere — but not
|
|
3050
|
+
// while another floating overlay (merged / memo) owns focus; that one must be Esc'd first.
|
|
2854
3051
|
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === ',' || e.code === 'Comma')) {
|
|
3052
|
+
if (modal.classList.contains('hidden') && (document.getElementById('mc-modal') || document.getElementById('mc-memo'))) return;
|
|
2855
3053
|
e.preventDefault(); e.stopPropagation();
|
|
2856
3054
|
if (modal.classList.contains('hidden')) open('general'); else close();
|
|
2857
3055
|
}
|
|
@@ -3000,8 +3198,19 @@ function restoreUiState() {
|
|
|
3000
3198
|
// regions (diff container, sidebar trees, status, data) and re-run the bootstrap steps. The window never
|
|
3001
3199
|
// reloads, so the integrated terminal's pty sessions (claude/codex) survive a watch refresh. Electron's
|
|
3002
3200
|
// main pushes the payload over IPC (monacori:diff-update); serve mode's poller fetches /__ai_flow_update.
|
|
3201
|
+
// Live watch refreshes are HELD while a comment composer is open. applyDiffUpdate rebuilds the diff DOM, so
|
|
3202
|
+
// applying it mid-compose would destroy the composer textarea every watch tick — input stalls and characters
|
|
3203
|
+
// arrive in bursts — and flicker the page. Keep only the latest pending payload; flush it on close/save.
|
|
3204
|
+
var pendingDiffUpdate = null;
|
|
3205
|
+
function flushPendingDiffUpdate() {
|
|
3206
|
+
if (!pendingDiffUpdate) return;
|
|
3207
|
+
var u = pendingDiffUpdate;
|
|
3208
|
+
pendingDiffUpdate = null;
|
|
3209
|
+
try { applyDiffUpdate(u); } catch (e) {}
|
|
3210
|
+
}
|
|
3003
3211
|
function applyDiffUpdate(u) {
|
|
3004
3212
|
if (!u || !u.signature || u.signature === currentSignature) return false; // unchanged — nothing to do
|
|
3213
|
+
if (composerState) { pendingDiffUpdate = u; return false; } // composing a comment — hold the refresh until close/save
|
|
3005
3214
|
|
|
3006
3215
|
// Remember what to restore after the swap (comments/viewed persist on their own; these don't).
|
|
3007
3216
|
var sv = document.getElementById('source-viewer');
|
|
@@ -3009,6 +3218,23 @@ function applyDiffUpdate(u) {
|
|
|
3009
3218
|
var wasSource = isSourceViewerVisible();
|
|
3010
3219
|
var container = document.getElementById('diff2html-container');
|
|
3011
3220
|
var diffScrollTop = container ? container.scrollTop : 0;
|
|
3221
|
+
// Did the file the user is CURRENTLY viewing actually change in this build? If not, we must not re-render
|
|
3222
|
+
// the source view — an unrelated file's edit would otherwise flicker the pane they're reading. Capture the
|
|
3223
|
+
// open file's signature BEFORE fileSignatureByPath is rebuilt below.
|
|
3224
|
+
var prevOpenSig = openPath ? (fileSignatureByPath.get(openPath) || '') : '';
|
|
3225
|
+
|
|
3226
|
+
// Snapshot already-materialized file bodies (keyed by path + current signature) BEFORE the swap, so an
|
|
3227
|
+
// UNCHANGED file can be re-filled synchronously afterwards. Without this, the swap turns every wrapper into
|
|
3228
|
+
// an empty lazy shell that blanks until its body re-loads over IPC — the visible "flicker" on a watch tick.
|
|
3229
|
+
var prevBodies = {};
|
|
3230
|
+
if (REVIEW_LAZY && container) {
|
|
3231
|
+
container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
|
|
3232
|
+
var b = w.querySelector('.d2h-files-diff');
|
|
3233
|
+
if (!b || b.hasAttribute('data-lazy')) return; // only bodies that are actually materialized
|
|
3234
|
+
var p = diffWrapperPathKey(w);
|
|
3235
|
+
if (p) prevBodies[p] = { sig: fileSignatureByPath.get(p) || '', html: b.innerHTML };
|
|
3236
|
+
});
|
|
3237
|
+
}
|
|
3012
3238
|
|
|
3013
3239
|
// 1) Replace the visible regions straight from the payload (no full-HTML parse).
|
|
3014
3240
|
if (container) container.innerHTML = u.diffContainer || '';
|
|
@@ -3027,6 +3253,9 @@ function applyDiffUpdate(u) {
|
|
|
3027
3253
|
// 2) Re-derive module-level state directly from the payload objects.
|
|
3028
3254
|
fileStates = u.fileStates || [];
|
|
3029
3255
|
fileSignatureByPath = new Map(fileStates.map(function (f) { return [f.path, f.signature]; }));
|
|
3256
|
+
// The open file changed iff its signature moved (or it vanished from the new build). Drives whether we
|
|
3257
|
+
// re-render the source view below.
|
|
3258
|
+
var openFileChanged = !openPath || prevOpenSig !== (fileSignatureByPath.get(openPath) || '');
|
|
3030
3259
|
sourceFiles = u.sourceFilesMeta || [];
|
|
3031
3260
|
sourceByPath = new Map(sourceFiles.map(function (f) { return [f.path, f]; }));
|
|
3032
3261
|
httpEnvironments = u.httpEnvironments || {};
|
|
@@ -3036,16 +3265,40 @@ function applyDiffUpdate(u) {
|
|
|
3036
3265
|
sourceLinks = Array.from(document.querySelectorAll('.source-link'));
|
|
3037
3266
|
|
|
3038
3267
|
// 3) Reset lazy-materialize + index state so the new diff bodies / source / symbols rebuild on demand.
|
|
3268
|
+
// bodyCache is keyed by file INDEX, not content — after a watch rebuild the same index maps to the new
|
|
3269
|
+
// body, so it MUST be dropped too. Clearing only bodyPromise left loadBodyHtml() returning the cached
|
|
3270
|
+
// OLD body, so a watch change never showed up in the diff until a full reload.
|
|
3271
|
+
bodyCache = {};
|
|
3039
3272
|
bodyPromise = {};
|
|
3040
3273
|
diffBootDone = false;
|
|
3041
3274
|
sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
|
|
3042
3275
|
sourceLoading = false;
|
|
3043
|
-
|
|
3276
|
+
// Force a source body re-render on next open ONLY if the open file actually changed; otherwise keep
|
|
3277
|
+
// sourceBodyPath so the already-painted (unchanged) source view is left exactly as-is — no flicker.
|
|
3278
|
+
if (openFileChanged) sourceBodyPath = null;
|
|
3044
3279
|
symbolIndex = null;
|
|
3045
3280
|
if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
|
|
3046
3281
|
else { prepareDiff2HtmlHunks(); diffBootDone = true; }
|
|
3047
3282
|
if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
|
|
3048
3283
|
|
|
3284
|
+
// 3b) Re-fill UNCHANGED files' bodies synchronously from the snapshot so they don't blank-then-reload (the
|
|
3285
|
+
// flicker). The fresh wrapper carries the correct data-first-hunk + file index, so materializeBody numbers
|
|
3286
|
+
// hunks exactly as a normal lazy load would — this only skips the IPC round-trip for files whose content is
|
|
3287
|
+
// identical. Changed/new files stay shells and lazy-load as usual, so a real edit still refreshes the diff.
|
|
3288
|
+
if (REVIEW_LAZY && container) {
|
|
3289
|
+
container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
|
|
3290
|
+
var p = diffWrapperPathKey(w);
|
|
3291
|
+
var prev = p ? prevBodies[p] : null;
|
|
3292
|
+
if (!prev || !prev.sig || prev.sig !== (fileSignatureByPath.get(p) || '')) return; // changed/new -> lazy-load
|
|
3293
|
+
var shell = w.querySelector('.d2h-files-diff[data-lazy]');
|
|
3294
|
+
if (!shell) return;
|
|
3295
|
+
var idx = (w.id || '').replace('file-', '');
|
|
3296
|
+
materializeBody(w, prev.html); // fills the body + markWrapperHunks (uses the new data-first-hunk)
|
|
3297
|
+
bodyCache[idx] = prev.html; // keep the index cache consistent so it never refetches
|
|
3298
|
+
bodyPromise[idx] = Promise.resolve(w);
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3049
3302
|
// 4) Re-run the DOM-dependent bootstrap steps.
|
|
3050
3303
|
applyI18n();
|
|
3051
3304
|
populateHttpEnvSelect();
|
|
@@ -3053,9 +3306,10 @@ function applyDiffUpdate(u) {
|
|
|
3053
3306
|
remapComments(); // follow/drop comments whose anchor line moved or vanished in the new build
|
|
3054
3307
|
refreshComments();
|
|
3055
3308
|
|
|
3056
|
-
// 5) Best-effort restore of what the user was looking at.
|
|
3309
|
+
// 5) Best-effort restore of what the user was looking at. Re-render the source view only when the open file
|
|
3310
|
+
// actually changed; an unchanged file stays painted as-is, so an unrelated edit doesn't flicker the pane.
|
|
3057
3311
|
if (wasSource && openPath && sourceByPath.has(openPath)) {
|
|
3058
|
-
openSourceFile(openPath, false);
|
|
3312
|
+
if (openFileChanged) openSourceFile(openPath, false);
|
|
3059
3313
|
} else if (container) {
|
|
3060
3314
|
showDiffView(false);
|
|
3061
3315
|
container.scrollTop = diffScrollTop;
|
|
@@ -3313,6 +3567,17 @@ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLi
|
|
|
3313
3567
|
recordNav(navEntryOf('source'));
|
|
3314
3568
|
}
|
|
3315
3569
|
var sourceRevealRaf = 0, sourceRevealPrev = null;
|
|
3570
|
+
// Source rows are a fixed monospace height, so the caret-follow scroll can be computed from
|
|
3571
|
+
// lineIndex*rowHeight instead of reading the caret's getBoundingClientRect — which forces a full reflow on
|
|
3572
|
+
// every move (~15ms on a 400-line file; the main caret-follow stutter). Cached; invalidated on resize.
|
|
3573
|
+
var _srcRowH = 0;
|
|
3574
|
+
function sourceRowHeight() {
|
|
3575
|
+
if (_srcRowH > 0) return _srcRowH;
|
|
3576
|
+
var r = document.querySelector('#source-body .source-row');
|
|
3577
|
+
if (r) { var h = r.offsetHeight; if (h > 0) _srcRowH = h; }
|
|
3578
|
+
return _srcRowH;
|
|
3579
|
+
}
|
|
3580
|
+
if (typeof window !== 'undefined') window.addEventListener('resize', function () { _srcRowH = 0; });
|
|
3316
3581
|
function scheduleSourceReveal(prev) {
|
|
3317
3582
|
// First prev of a coalesced burst wins: a fast ArrowDown updates viewerCursor many times before the frame
|
|
3318
3583
|
// fires; render the caret once (first prev -> final viewerCursor) and scroll in the SAME frame so caret and
|
|
@@ -3326,8 +3591,23 @@ function scheduleSourceReveal(prev) {
|
|
|
3326
3591
|
if (!f || !f.embedded) return;
|
|
3327
3592
|
var lines = f.content.split(/\r?\n/);
|
|
3328
3593
|
updateSourceCaret(p, lines, f.language || 'text');
|
|
3329
|
-
var
|
|
3330
|
-
|
|
3594
|
+
var sb = document.getElementById('source-body');
|
|
3595
|
+
var rowH = sourceRowHeight();
|
|
3596
|
+
if (rowH > 0 && sb && !sb.classList.contains('rendered-body')) {
|
|
3597
|
+
// Scrolloff, not follow: scroll ONLY when the caret would otherwise leave the viewport, keeping it
|
|
3598
|
+
// within a 15% margin of the top/bottom edge. While the caret moves comfortably inside that band the
|
|
3599
|
+
// view stays put — continuous follow was dizzying (the file slid even when everything was visible) and
|
|
3600
|
+
// it forced a scroll/reflow on every move. lineIndex*rowH avoids getBoundingClientRect entirely, and
|
|
3601
|
+
// skipping the scroll when it's unnecessary removes the reflow on most moves too.
|
|
3602
|
+
var caretTop = viewerCursor.lineIndex * rowH;
|
|
3603
|
+
var ch = sb.clientHeight;
|
|
3604
|
+
var margin = Math.round(ch * 0.15);
|
|
3605
|
+
var vTop = sb.scrollTop;
|
|
3606
|
+
if (caretTop < vTop + margin) sb.scrollTop = Math.max(0, caretTop - margin);
|
|
3607
|
+
else if (caretTop + rowH > vTop + ch - margin) sb.scrollTop = caretTop + rowH - ch + margin;
|
|
3608
|
+
} else {
|
|
3609
|
+
revealAt(document.querySelector('.source-row.cursor-line'), sb, 0.85);
|
|
3610
|
+
}
|
|
3331
3611
|
});
|
|
3332
3612
|
}
|
|
3333
3613
|
|
|
@@ -3424,9 +3704,9 @@ function selectCommentRow(row) {
|
|
|
3424
3704
|
selectedCommentRow = row || null;
|
|
3425
3705
|
if (!selectedCommentRow) return;
|
|
3426
3706
|
selectedCommentRow.classList.add('mc-row-selected');
|
|
3427
|
-
//
|
|
3428
|
-
|
|
3429
|
-
|
|
3707
|
+
// Keep the caret visible: the box's active outline (.mc-row-selected) already shows the selection, and the
|
|
3708
|
+
// caret must never be hidden ("어떤 경우에도 커서는 가려지면 안 됨"). Previously this removed cursor-line +
|
|
3709
|
+
// code-cursor, so Go-to-comment → ArrowDown (which selects the comment box on that line) made the caret vanish.
|
|
3430
3710
|
}
|
|
3431
3711
|
function deleteCommentsInRow(row) {
|
|
3432
3712
|
if (!row) return;
|
|
@@ -3438,6 +3718,21 @@ function deleteCommentsInRow(row) {
|
|
|
3438
3718
|
}
|
|
3439
3719
|
refreshComments(); // remaining comment rows re-injected; the caret stays hidden until the next arrow press
|
|
3440
3720
|
}
|
|
3721
|
+
// Open the composer in EDIT mode for the first comment in `row`, pre-filled with its text. threadHtml renders
|
|
3722
|
+
// the composer in place of that card (via composerState.editSeq), and saveComposer routes editSeq through
|
|
3723
|
+
// updateComment instead of addComment. Triggered by `e` while a comment box is selected.
|
|
3724
|
+
function editCommentInRow(row) {
|
|
3725
|
+
if (!row) return;
|
|
3726
|
+
var del = row.querySelector('.mc-del');
|
|
3727
|
+
if (!del) return;
|
|
3728
|
+
var seq = parseInt(del.dataset.seq, 10);
|
|
3729
|
+
var c = reviewComments.find(function (x) { return x.seq === seq; });
|
|
3730
|
+
if (!c) return;
|
|
3731
|
+
row.classList.remove('mc-row-selected');
|
|
3732
|
+
selectedCommentRow = null;
|
|
3733
|
+
composerState = { kind: c.kind, path: c.path, line: c.line, code: c.code, editSeq: seq, editText: c.text };
|
|
3734
|
+
refreshComments();
|
|
3735
|
+
}
|
|
3441
3736
|
function handleSourceCaretKey(event) {
|
|
3442
3737
|
if (!viewerCursor) return false;
|
|
3443
3738
|
var ae = document.activeElement;
|
|
@@ -3446,6 +3741,7 @@ function handleSourceCaretKey(event) {
|
|
|
3446
3741
|
// A comment box is selected (caret hidden): Backspace/Delete removes it; an arrow steps off it.
|
|
3447
3742
|
if (selectedCommentRow) {
|
|
3448
3743
|
if (event.key === 'Backspace' || event.key === 'Delete') { event.preventDefault(); deleteCommentsInRow(selectedCommentRow); return true; }
|
|
3744
|
+
if (event.key === 'e' || event.key === 'E') { event.preventDefault(); editCommentInRow(selectedCommentRow); return true; }
|
|
3449
3745
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Escape') {
|
|
3450
3746
|
var dir = event.key === 'ArrowUp' ? -1 : (event.key === 'ArrowDown' ? 1 : 0);
|
|
3451
3747
|
var sib = dir < 0 ? selectedCommentRow.previousElementSibling : (dir > 0 ? selectedCommentRow.nextElementSibling : null);
|
|
@@ -4036,6 +4332,7 @@ function toggleRenderMode() {
|
|
|
4036
4332
|
var btn = document.getElementById('render-toggle');
|
|
4037
4333
|
if (btn) btn.addEventListener('click', function () { toggleRenderMode(); });
|
|
4038
4334
|
document.addEventListener('keydown', function (e) {
|
|
4335
|
+
if (isFloatingModalOpen()) return; // a floating overlay owns focus -> no render-toggle shortcut beneath it
|
|
4039
4336
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && !e.altKey && (e.key === 'M' || e.key === 'm' || e.code === 'KeyM')) {
|
|
4040
4337
|
var sv = document.getElementById('source-viewer');
|
|
4041
4338
|
var open = sv && sv.dataset.openPath;
|