@happy-nut/monacori 0.1.10 → 0.1.12
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/README.md +3 -22
- package/dist/app-main.js +67 -11
- package/dist/commands.js +57 -216
- package/dist/diff.js +33 -18
- package/dist/git.d.ts +1 -0
- package/dist/git.js +9 -0
- package/dist/i18n.js +10 -0
- package/dist/render.d.ts +1 -1
- package/dist/render.js +11 -2
- package/dist/util.js +13 -3
- package/dist/viewer.client.js +346 -37
- package/dist/viewer.css +94 -26
- package/package.json +1 -1
package/dist/viewer.client.js
CHANGED
|
@@ -156,6 +156,21 @@ function applyI18n() {
|
|
|
156
156
|
var sel = document.getElementById('settings-language');
|
|
157
157
|
if (sel) sel.value = locale;
|
|
158
158
|
}
|
|
159
|
+
// Theme mirrors the locale pattern: persisted choice, applied by toggling data-theme on <html> so the
|
|
160
|
+
// :root[data-theme="light"] palette takes over. Dark is the default (matches the inline :root). Applied
|
|
161
|
+
// immediately at script start to minimize a first-paint flash from the dark default to light.
|
|
162
|
+
var THEME_KEY = 'monacori-theme';
|
|
163
|
+
var theme = (function () {
|
|
164
|
+
var v = persistRead(THEME_KEY);
|
|
165
|
+
if (v !== 'light' && v !== 'dark') { try { v = localStorage.getItem(THEME_KEY); } catch (e) {} }
|
|
166
|
+
return (v === 'light' || v === 'dark') ? v : 'dark';
|
|
167
|
+
})();
|
|
168
|
+
function applyTheme() {
|
|
169
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
170
|
+
var sel = document.getElementById('settings-theme');
|
|
171
|
+
if (sel) sel.value = theme;
|
|
172
|
+
}
|
|
173
|
+
applyTheme();
|
|
159
174
|
let fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
|
|
160
175
|
let httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
|
|
161
176
|
let httpEnvNames = Object.keys(httpEnvironments);
|
|
@@ -168,6 +183,10 @@ let sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
|
|
|
168
183
|
// and the source view shows a brief loading state. Non-lazy-load modes embed source -> already loaded.
|
|
169
184
|
var sourceLoaded = !REVIEW_LAZY_LOAD;
|
|
170
185
|
var pendingSourceOpen = null;
|
|
186
|
+
// The path whose content is ACTUALLY painted in #source-body right now. dataset.openPath is the INTENDED
|
|
187
|
+
// path and gets set BEFORE the body paints in the lazy-LOAD branch, so the caret fast-path must check this
|
|
188
|
+
// instead — else it patches the caret onto a stale body, leaving one file's content under another's path.
|
|
189
|
+
var sourceBodyPath = null;
|
|
171
190
|
var sourceLoading = false;
|
|
172
191
|
var pendingSymbol = null;
|
|
173
192
|
var sourceTabs = []; // Files-mode tab paths (session-only); see addSourceTab / renderSourceTabs.
|
|
@@ -831,17 +850,33 @@ function isTreeRowVisible(el) {
|
|
|
831
850
|
function treeRows() {
|
|
832
851
|
const panel = document.querySelector('.tab-panel:not(.hidden)');
|
|
833
852
|
if (!panel) return [];
|
|
834
|
-
|
|
853
|
+
// isTreeRowVisible walks ancestor <details> (cheap, layout-free) and already excludes rows inside
|
|
854
|
+
// collapsed folders. The previous extra `getClientRects().length > 0` check forced a SYNCHRONOUS
|
|
855
|
+
// reflow per node — 6k forced layouts on every arrow key in a large source tree, which froze input.
|
|
856
|
+
// The details walk makes the rects check redundant, so drop it.
|
|
857
|
+
return Array.from(panel.querySelectorAll('summary, .file-link')).filter(isTreeRowVisible);
|
|
835
858
|
}
|
|
836
859
|
|
|
837
860
|
function focusTree(index) {
|
|
838
861
|
const rows = treeRows();
|
|
839
862
|
if (rows.length === 0) return;
|
|
840
|
-
// Incremental: drop the old focus class and add the new one — no full forEach over every row per keystroke.
|
|
841
|
-
if (treeFocusIndex >= 0 && treeFocusIndex < rows.length) rows[treeFocusIndex]?.classList.remove('tree-focus');
|
|
842
863
|
treeFocusIndex = Math.max(0, Math.min(rows.length - 1, index));
|
|
843
|
-
|
|
844
|
-
|
|
864
|
+
// Render the focus class AND scroll in the SAME frame. A fast key-repeat queues many ArrowDowns before a
|
|
865
|
+
// frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump ~one
|
|
866
|
+
// viewport (~20 rows) at a time. Coalescing both keeps focus + scroll in lockstep so it scrolls smoothly.
|
|
867
|
+
scheduleTreeFocus();
|
|
868
|
+
}
|
|
869
|
+
var treeFocusRaf = 0;
|
|
870
|
+
function scheduleTreeFocus() {
|
|
871
|
+
if (treeFocusRaf) return;
|
|
872
|
+
treeFocusRaf = requestAnimationFrame(function () {
|
|
873
|
+
treeFocusRaf = 0;
|
|
874
|
+
const rows = treeRows();
|
|
875
|
+
if (treeFocusIndex < 0 || treeFocusIndex >= rows.length) return;
|
|
876
|
+
const el = rows[treeFocusIndex];
|
|
877
|
+
document.querySelectorAll('.tree-focus').forEach((e) => { if (e !== el) e.classList.remove('tree-focus'); });
|
|
878
|
+
if (el) { el.classList.add('tree-focus'); el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }
|
|
879
|
+
});
|
|
845
880
|
}
|
|
846
881
|
|
|
847
882
|
function clearTreeFocus() {
|
|
@@ -1171,14 +1206,19 @@ document.addEventListener('keydown', (event) => {
|
|
|
1171
1206
|
|
|
1172
1207
|
if (event.key === 'F7') {
|
|
1173
1208
|
event.preventDefault();
|
|
1174
|
-
|
|
1175
|
-
|
|
1209
|
+
const delta = event.shiftKey ? -1 : 1;
|
|
1210
|
+
const sourceViewer = document.getElementById('source-viewer');
|
|
1211
|
+
// Forward F7 from the source view enters the diff at the open file's own hunk, so the reviewer lands
|
|
1212
|
+
// where they were reading. Shift+F7 — and any file with no hunk of its own — falls through to plain
|
|
1213
|
+
// prev/next-change navigation across the whole diff.
|
|
1214
|
+
if (delta > 0 && sourceViewer && !sourceViewer.classList.contains('hidden')) {
|
|
1215
|
+
const sourceHunk = firstHunkForPath(sourceViewer.dataset.openPath || '');
|
|
1176
1216
|
if (sourceHunk >= 0) {
|
|
1177
1217
|
setActive(sourceHunk);
|
|
1178
1218
|
return;
|
|
1179
1219
|
}
|
|
1180
1220
|
}
|
|
1181
|
-
next(
|
|
1221
|
+
next(delta);
|
|
1182
1222
|
}
|
|
1183
1223
|
});
|
|
1184
1224
|
|
|
@@ -1213,15 +1253,18 @@ document.getElementById('usages')?.addEventListener('click', function (event) {
|
|
|
1213
1253
|
if (event.target && event.target.id === 'usages') closeUsages();
|
|
1214
1254
|
});
|
|
1215
1255
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1256
|
+
// Delegated (like #files-panel below) so it survives the in-place diff update that re-captures `links`
|
|
1257
|
+
// on every watch tick — per-element listeners would be lost on the new nodes, and then Cmd+0 → arrow →
|
|
1258
|
+
// Enter (which calls row.click()) would silently do nothing.
|
|
1259
|
+
document.getElementById('changes-panel')?.addEventListener('click', (event) => {
|
|
1260
|
+
const link = event.target && event.target.closest ? event.target.closest('.file-link') : null;
|
|
1261
|
+
if (!link) return;
|
|
1262
|
+
showDiffView(false);
|
|
1263
|
+
const target = Number(link.dataset.hunk);
|
|
1264
|
+
if (!Number.isNaN(target) && target >= 0 && target < hunkTotal()) {
|
|
1265
|
+
event.preventDefault();
|
|
1266
|
+
setActive(target);
|
|
1267
|
+
}
|
|
1225
1268
|
});
|
|
1226
1269
|
|
|
1227
1270
|
// Delegated so it works whether the tree is inline (small repos) or materialized later (big repos).
|
|
@@ -1262,8 +1305,11 @@ if (!REVIEW_LAZY_LOAD) scheduleSymbolIndex(); // non-lazy indexes when idle; laz
|
|
|
1262
1305
|
const restored = restoreUiState();
|
|
1263
1306
|
if (!restored) {
|
|
1264
1307
|
const initial = location.hash.match(/^#hunk-(\d+)$/);
|
|
1308
|
+
const hasDiff = Boolean(document.querySelector('#diff2html-container .d2h-file-wrapper'));
|
|
1265
1309
|
if (initial) setActive(Number(initial[1]), false);
|
|
1266
|
-
|
|
1310
|
+
// Clean tree (nothing to review): open a file (README first) instead of staring at an empty diff.
|
|
1311
|
+
else if (!hasDiff) openDefaultSourceFile();
|
|
1312
|
+
else if (REVIEW_LAZY_LOAD) showDiffView(false); // big repos with changes: open to the diff (Changes); the source tree stays deferred until the Files tab is opened
|
|
1267
1313
|
else openDefaultSourceFile();
|
|
1268
1314
|
}
|
|
1269
1315
|
initSourceTreeFolds();
|
|
@@ -1476,11 +1522,32 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
|
|
|
1476
1522
|
var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
|
|
1477
1523
|
diffCursor = { path: path, side: side, rowIndex: ri, column: col };
|
|
1478
1524
|
diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1525
|
+
if (reveal) {
|
|
1526
|
+
// Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
|
|
1527
|
+
// before one frame; rendering the caret immediately (while the coalesced scroll lags) would push it many
|
|
1528
|
+
// rows past the viewport, then the view would snap ~one viewport at a time. Coalescing both keeps the
|
|
1529
|
+
// caret and scroll in lockstep, so holding ArrowDown scrolls smoothly instead of jumping every ~15 lines.
|
|
1530
|
+
scheduleDiffReveal(wrapper, side, ri);
|
|
1531
|
+
} else {
|
|
1532
|
+
renderDiffCaret();
|
|
1533
|
+
applyDiffSelection();
|
|
1534
|
+
}
|
|
1482
1535
|
recordNav(navEntryOf('diff'));
|
|
1483
1536
|
}
|
|
1537
|
+
var diffRevealRaf = 0, diffRevealTarget = null;
|
|
1538
|
+
function scheduleDiffReveal(wrapper, side, ri) {
|
|
1539
|
+
diffRevealTarget = { wrapper: wrapper, side: side, ri: ri };
|
|
1540
|
+
if (diffRevealRaf) return;
|
|
1541
|
+
diffRevealRaf = requestAnimationFrame(function () {
|
|
1542
|
+
diffRevealRaf = 0;
|
|
1543
|
+
var t = diffRevealTarget; diffRevealTarget = null;
|
|
1544
|
+
renderDiffCaret();
|
|
1545
|
+
applyDiffSelection();
|
|
1546
|
+
if (!t) return;
|
|
1547
|
+
var row = diffRowAt(t.wrapper, t.side, t.ri);
|
|
1548
|
+
if (row && row.scrollIntoView) { try { row.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (x) {} }
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1484
1551
|
function navEntryOf(kind) {
|
|
1485
1552
|
if (kind === 'diff') {
|
|
1486
1553
|
if (!diffCursor) return null;
|
|
@@ -1729,6 +1796,13 @@ function currentCommentTarget() {
|
|
|
1729
1796
|
return { path: path, line: toLine, code: hasSel ? selText : '', from: hasSel ? Math.min(fromLine, toLine) : null, to: hasSel ? Math.max(fromLine, toLine) : null, side: side };
|
|
1730
1797
|
}
|
|
1731
1798
|
|
|
1799
|
+
// "live_trading_engine.py:424" (or ":420–424" for a multi-line drag) — shown in the composer head so the
|
|
1800
|
+
// reviewer always sees WHICH file + line(s) a comment targets instead of a bare, context-free box.
|
|
1801
|
+
function composerTargetLabel(s) {
|
|
1802
|
+
var base = (s.path || '').split('/').pop() || s.path || '';
|
|
1803
|
+
var loc = (s.from != null && s.to != null && s.from !== s.to) ? (s.from + '–' + s.to) : String(s.line);
|
|
1804
|
+
return base + ':' + loc;
|
|
1805
|
+
}
|
|
1732
1806
|
function threadHtml(path, line) {
|
|
1733
1807
|
var html = '';
|
|
1734
1808
|
commentsAt(path, line).forEach(function (c) {
|
|
@@ -1740,7 +1814,7 @@ function threadHtml(path, line) {
|
|
|
1740
1814
|
if (composerState && composerState.path === path && composerState.line === line) {
|
|
1741
1815
|
var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
|
|
1742
1816
|
html += '<div class="mc-card mc-' + composerState.kind + ' mc-composer">'
|
|
1743
|
-
+ '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(composerState.kind) + '</span></div>'
|
|
1817
|
+
+ '<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>'
|
|
1744
1818
|
+ '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '"></textarea>'
|
|
1745
1819
|
+ '<div class="mc-actions"><button type="button" class="mc-btn mc-save">' + escapeHtml(t('composer.save')) + '</button>'
|
|
1746
1820
|
+ '<button type="button" class="mc-btn mc-ghost mc-cancel">' + escapeHtml(t('composer.cancel')) + '</button>'
|
|
@@ -1853,6 +1927,18 @@ function refreshComments() {
|
|
|
1853
1927
|
if (isSourceViewerVisible()) renderSourceComments();
|
|
1854
1928
|
renderCommentBadges();
|
|
1855
1929
|
applyCommentSelectionHighlight();
|
|
1930
|
+
// Keep body.mc-composing (which hides the file caret) tied to the ACTUAL on-screen composer, not just
|
|
1931
|
+
// composerState. Leaving the composer by any path other than save/cancel (opening another file, switching
|
|
1932
|
+
// views) would otherwise leave the class stuck and hide EVERY caret — making arrow navigation and
|
|
1933
|
+
// comment-box selection look dead. This single sync point covers all refreshComments callers.
|
|
1934
|
+
var visibleComposer = false;
|
|
1935
|
+
var composerInputs = document.querySelectorAll('.mc-composer .mc-input');
|
|
1936
|
+
for (var ci = 0; ci < composerInputs.length; ci++) {
|
|
1937
|
+
if (composerInputs[ci].closest('#diff-view') && !isDiffViewVisible()) continue;
|
|
1938
|
+
if (composerInputs[ci].closest('#source-viewer') && !isSourceViewerVisible()) continue;
|
|
1939
|
+
visibleComposer = true; break;
|
|
1940
|
+
}
|
|
1941
|
+
document.body.classList.toggle('mc-composing', visibleComposer);
|
|
1856
1942
|
if (composerState) {
|
|
1857
1943
|
var composerFocusTries = 0;
|
|
1858
1944
|
var tryFocusComposer = function () {
|
|
@@ -1881,7 +1967,8 @@ function openComposer(kind) {
|
|
|
1881
1967
|
// Keep the dragged code visibly highlighted via the .mc-sel-line class (applyCommentSelectionHighlight),
|
|
1882
1968
|
// and clear the native selection so its highlight doesn't bleed into the composer/cards below it.
|
|
1883
1969
|
try { var psel = window.getSelection(); if (psel) psel.removeAllRanges(); } catch (e) {}
|
|
1884
|
-
refreshComments();
|
|
1970
|
+
refreshComments(); // refreshComments syncs body.mc-composing from the on-screen composer
|
|
1971
|
+
|
|
1885
1972
|
}
|
|
1886
1973
|
function closeComposer() {
|
|
1887
1974
|
if (!composerState) return;
|
|
@@ -1932,6 +2019,79 @@ function saveMergePrompt(kind, text) {
|
|
|
1932
2019
|
persistSave(mergePromptsKey, saved);
|
|
1933
2020
|
}
|
|
1934
2021
|
|
|
2022
|
+
// Reusable custom dropdown (keyboard + mouse). options: [{ label, onSelect }]. First item is pre-selected;
|
|
2023
|
+
// Arrow keys move, Enter chooses, Esc / click-outside dismiss. Replaces native <select>/menus everywhere.
|
|
2024
|
+
function showCustomDropdown(x, y, options) {
|
|
2025
|
+
var existing = document.getElementById('mc-dropdown');
|
|
2026
|
+
if (existing) existing.remove();
|
|
2027
|
+
var dd = document.createElement('div');
|
|
2028
|
+
dd.id = 'mc-dropdown';
|
|
2029
|
+
dd.className = 'mc-dropdown';
|
|
2030
|
+
var active = 0;
|
|
2031
|
+
function setActive(i) { active = i; for (var j = 0; j < dd.children.length; j++) dd.children[j].classList.toggle('active', j === i); }
|
|
2032
|
+
function close() { dd.remove(); document.removeEventListener('keydown', onKey, true); document.removeEventListener('mousedown', onOutside, true); }
|
|
2033
|
+
function onKey(e) {
|
|
2034
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setActive(Math.min(active + 1, options.length - 1)); }
|
|
2035
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setActive(Math.max(active - 1, 0)); }
|
|
2036
|
+
else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); var o = options[active]; close(); if (o) o.onSelect(); }
|
|
2037
|
+
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
|
|
2038
|
+
}
|
|
2039
|
+
function onOutside(e) { if (!dd.contains(e.target)) close(); }
|
|
2040
|
+
options.forEach(function (opt, i) {
|
|
2041
|
+
var item = document.createElement('button');
|
|
2042
|
+
item.type = 'button';
|
|
2043
|
+
item.className = 'mc-dropdown-item' + (i === 0 ? ' active' : '');
|
|
2044
|
+
item.textContent = opt.label;
|
|
2045
|
+
item.addEventListener('click', function () { close(); opt.onSelect(); });
|
|
2046
|
+
item.addEventListener('mousemove', function () { setActive(i); });
|
|
2047
|
+
dd.appendChild(item);
|
|
2048
|
+
});
|
|
2049
|
+
dd.style.left = Math.round(x) + 'px';
|
|
2050
|
+
dd.style.top = Math.round(y) + 'px';
|
|
2051
|
+
document.body.appendChild(dd);
|
|
2052
|
+
document.addEventListener('keydown', onKey, true);
|
|
2053
|
+
document.addEventListener('mousedown', onOutside, true);
|
|
2054
|
+
}
|
|
2055
|
+
// Map a char range in the merged textarea back to the comment seq(s) it covers. Each comment is a
|
|
2056
|
+
// "### path:line" block; the caret's block (or every block a selection spans) identifies the comment(s).
|
|
2057
|
+
function mergedCommentSeqs(kind, start, end) {
|
|
2058
|
+
var items = reviewComments.filter(function (c) { return c.kind === kind; });
|
|
2059
|
+
var text = buildMergedText(kind);
|
|
2060
|
+
var lines = text.split(String.fromCharCode(10));
|
|
2061
|
+
var seqs = [], pos = 0, idx = -1;
|
|
2062
|
+
for (var i = 0; i < lines.length; i++) {
|
|
2063
|
+
var lineStart = pos, lineEnd = pos + lines[i].length;
|
|
2064
|
+
if (lines[i].indexOf('### ') === 0) idx++;
|
|
2065
|
+
if (idx >= 0 && idx < items.length && lineEnd >= start && lineStart <= end) {
|
|
2066
|
+
var s = items[idx].seq;
|
|
2067
|
+
if (seqs.indexOf(s) < 0) seqs.push(s);
|
|
2068
|
+
}
|
|
2069
|
+
pos = lineEnd + 1;
|
|
2070
|
+
}
|
|
2071
|
+
return seqs;
|
|
2072
|
+
}
|
|
2073
|
+
function navigateToComment(seq) {
|
|
2074
|
+
var c = reviewComments.find(function (x) { return x.seq === seq; });
|
|
2075
|
+
if (!c) return;
|
|
2076
|
+
openSourceFile(c.path);
|
|
2077
|
+
requestAnimationFrame(function () { setSourceCursor(c.path, Math.max(0, (c.line || 1) - 1), 0, true, -1); });
|
|
2078
|
+
}
|
|
2079
|
+
// Move the merged-view caret to the next (dir=1) / previous (dir=-1) "### path:line" header and center it,
|
|
2080
|
+
// so Opt+Arrow steps comment-by-comment in the merged view.
|
|
2081
|
+
function jumpMergedComment(area, dir) {
|
|
2082
|
+
var text = area.value;
|
|
2083
|
+
var headers = [], pos = 0;
|
|
2084
|
+
text.split('\n').forEach(function (ln) { if (ln.indexOf('### ') === 0) headers.push(pos); pos += ln.length + 1; });
|
|
2085
|
+
if (!headers.length) return;
|
|
2086
|
+
var cur = area.selectionStart;
|
|
2087
|
+
var target;
|
|
2088
|
+
if (dir > 0) { target = headers.find(function (h) { return h > cur; }); if (target == null) target = headers[headers.length - 1]; }
|
|
2089
|
+
else { var before = headers.filter(function (h) { return h < cur; }); target = before.length ? before[before.length - 1] : headers[0]; }
|
|
2090
|
+
area.selectionStart = area.selectionEnd = target;
|
|
2091
|
+
var lineNum = text.slice(0, target).split('\n').length - 1;
|
|
2092
|
+
var lineH = parseFloat(getComputedStyle(area).lineHeight) || 18;
|
|
2093
|
+
area.scrollTop = Math.max(0, lineNum * lineH - area.clientHeight / 2);
|
|
2094
|
+
}
|
|
1935
2095
|
function buildMergedText(kind) {
|
|
1936
2096
|
var items = reviewComments.filter(function (c) { return c.kind === kind; });
|
|
1937
2097
|
var nl = String.fromCharCode(10);
|
|
@@ -1969,8 +2129,44 @@ function openMergedView(kind) {
|
|
|
1969
2129
|
closeBtn.textContent = t('merged.close');
|
|
1970
2130
|
var area = document.createElement('textarea');
|
|
1971
2131
|
area.className = 'mc-modal-text';
|
|
1972
|
-
|
|
2132
|
+
// NOT readOnly: a readOnly textarea hides the caret in Chromium, yet we need it VISIBLE so the user sees
|
|
2133
|
+
// which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead — read-only in
|
|
2134
|
+
// effect while the caret and selection stay fully interactive.
|
|
1973
2135
|
area.value = buildMergedText(kind);
|
|
2136
|
+
area.addEventListener('beforeinput', function (e) { e.preventDefault(); });
|
|
2137
|
+
// Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret — "Go to comment"
|
|
2138
|
+
// + "Remove" for a single caret; "Remove" only for a drag/select-all (can't navigate to many at once).
|
|
2139
|
+
// Removing here calls deleteComment(), which re-syncs the on-screen comment boxes via refreshComments.
|
|
2140
|
+
area.addEventListener('keydown', function (e) {
|
|
2141
|
+
// Opt/Alt + Arrow steps the caret to the next/previous comment block so you can move comment-to-comment
|
|
2142
|
+
// and act on each with Opt+Enter, without hand-scrolling.
|
|
2143
|
+
if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
2144
|
+
e.preventDefault();
|
|
2145
|
+
e.stopPropagation();
|
|
2146
|
+
jumpMergedComment(area, e.key === 'ArrowDown' ? 1 : -1);
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
if (!e.altKey || (e.key !== 'Enter' && e.code !== 'Enter')) return;
|
|
2150
|
+
e.preventDefault();
|
|
2151
|
+
e.stopPropagation();
|
|
2152
|
+
var seqs = mergedCommentSeqs(kind, area.selectionStart, area.selectionEnd);
|
|
2153
|
+
if (!seqs.length) return;
|
|
2154
|
+
var rect = area.getBoundingClientRect();
|
|
2155
|
+
var x = rect.left + 24, y = rect.top + 48;
|
|
2156
|
+
var rerender = function () {
|
|
2157
|
+
if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) { modal.remove(); return; }
|
|
2158
|
+
area.value = buildMergedText(kind);
|
|
2159
|
+
};
|
|
2160
|
+
if (area.selectionStart !== area.selectionEnd || seqs.length > 1) {
|
|
2161
|
+
showCustomDropdown(x, y, [{ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } }]);
|
|
2162
|
+
} else {
|
|
2163
|
+
var seq = seqs[0];
|
|
2164
|
+
showCustomDropdown(x, y, [
|
|
2165
|
+
{ label: t('dropdown.navigate'), onSelect: function () { modal.remove(); navigateToComment(seq); } },
|
|
2166
|
+
{ label: t('dropdown.remove'), onSelect: function () { deleteComment(seq); rerender(); } },
|
|
2167
|
+
]);
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
1974
2170
|
closeBtn.addEventListener('click', function () { modal.remove(); });
|
|
1975
2171
|
// Terminal send (Electron, terminal open): close the modal and hand off to pane-pick mode ON the
|
|
1976
2172
|
// terminal — the chosen pane is highlighted, the rest dimmed, arrows change the choice, Enter sends.
|
|
@@ -2314,12 +2510,21 @@ refreshComments();
|
|
|
2314
2510
|
}
|
|
2315
2511
|
}
|
|
2316
2512
|
function toggle() { setOpen(!isOpen()); }
|
|
2513
|
+
// The keyboard shortcut is "focus-first": when the terminal is visible but focus is elsewhere, the first
|
|
2514
|
+
// press just moves focus INTO the terminal; only when it already owns focus does another press toggle it
|
|
2515
|
+
// closed. (The footer button stays a plain toggle — a mouse click should open/close in one step.)
|
|
2516
|
+
function toggleOrFocus() {
|
|
2517
|
+
if (!isOpen()) { setOpen(true); return; } // setOpen(true) also focuses the active pane
|
|
2518
|
+
var ae = document.activeElement;
|
|
2519
|
+
if (ae && panel.contains(ae)) { setOpen(false); return; } // focus already in the terminal → close
|
|
2520
|
+
if (active) { try { active.term.focus(); } catch (e) {} } // visible but unfocused → just grab focus
|
|
2521
|
+
}
|
|
2317
2522
|
|
|
2318
2523
|
if (toggleBtn) toggleBtn.addEventListener('click', toggle);
|
|
2319
2524
|
if (closeBtn) closeBtn.addEventListener('click', function () { setOpen(false); });
|
|
2320
2525
|
// Toggle (Ctrl+`/Alt+F12) and split (Cmd+D) arrive from the Terminal menu accelerators (app-main),
|
|
2321
2526
|
// because Chromium swallows Cmd+D before a renderer keydown would ever see it.
|
|
2322
|
-
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalToggle === 'function') window.monacoriMenu.onTerminalToggle(
|
|
2527
|
+
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalToggle === 'function') window.monacoriMenu.onTerminalToggle(toggleOrFocus);
|
|
2323
2528
|
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalSplit === 'function') window.monacoriMenu.onTerminalSplit(split);
|
|
2324
2529
|
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneFocus === 'function') window.monacoriMenu.onTerminalPaneFocus(focusPaneByDelta);
|
|
2325
2530
|
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneRename === 'function') window.monacoriMenu.onTerminalPaneRename(function () { renamePane(active); });
|
|
@@ -2563,6 +2768,18 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
|
|
|
2563
2768
|
if (mergedModal) { var mk = mergedModal.dataset.kind || 'q'; mergedModal.remove(); openMergedView(mk); }
|
|
2564
2769
|
});
|
|
2565
2770
|
}
|
|
2771
|
+
// Theme: flip data-theme on <html> live (no reload) and persist the choice.
|
|
2772
|
+
var themeSel = document.getElementById('settings-theme');
|
|
2773
|
+
if (themeSel) {
|
|
2774
|
+
themeSel.value = theme;
|
|
2775
|
+
themeSel.addEventListener('change', function () {
|
|
2776
|
+
var next = themeSel.value === 'light' ? 'light' : 'dark';
|
|
2777
|
+
if (next === theme) return;
|
|
2778
|
+
theme = next;
|
|
2779
|
+
persistSave(THEME_KEY, theme);
|
|
2780
|
+
applyTheme();
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2566
2783
|
})();
|
|
2567
2784
|
|
|
2568
2785
|
function setTab(name) {
|
|
@@ -2707,6 +2924,7 @@ function applyDiffUpdate(u) {
|
|
|
2707
2924
|
diffBootDone = false;
|
|
2708
2925
|
sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
|
|
2709
2926
|
sourceLoading = false;
|
|
2927
|
+
sourceBodyPath = null; // the new build may have changed the open file's content — force a body re-render on next open
|
|
2710
2928
|
symbolIndex = null;
|
|
2711
2929
|
if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
|
|
2712
2930
|
else { prepareDiff2HtmlHunks(); diffBootDone = true; }
|
|
@@ -2785,7 +3003,14 @@ function updateTreeVisibility(root, query) {
|
|
|
2785
3003
|
}
|
|
2786
3004
|
|
|
2787
3005
|
function openDefaultSourceFile() {
|
|
3006
|
+
const isReadme = (candidate) => /^readme(\.|$)/i.test(candidate.name || '');
|
|
3007
|
+
const depthOf = (candidate) => (candidate.path || '').split('/').length;
|
|
3008
|
+
// Prefer the TOP-MOST README (root before any nested one), not just the first match in tree order.
|
|
3009
|
+
const rootReadme = sourceFiles
|
|
3010
|
+
.filter((candidate) => candidate.embedded && isReadme(candidate))
|
|
3011
|
+
.sort((a, b) => depthOf(a) - depthOf(b))[0];
|
|
2788
3012
|
const file = sourceFiles.find((candidate) => candidate.changed && candidate.embedded)
|
|
3013
|
+
|| rootReadme // top-most README when nothing changed
|
|
2789
3014
|
|| sourceFiles.find((candidate) => candidate.embedded)
|
|
2790
3015
|
|| sourceFiles.find((candidate) => candidate.changed)
|
|
2791
3016
|
|| sourceFiles[0];
|
|
@@ -2948,20 +3173,46 @@ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLi
|
|
|
2948
3173
|
// Fast path: the file is already on screen and only the caret moved. Re-rendering the whole
|
|
2949
3174
|
// file on every keystroke blocks the main thread on large files, so patch just the previous
|
|
2950
3175
|
// and new caret lines in place instead.
|
|
3176
|
+
// sourceBodyPath (the file actually painted in the body) must match too — dataset.openPath/viewerCursor
|
|
3177
|
+
// are metadata that can be set before the body repaints (lazy fetch in flight, fast file switch, watch
|
|
3178
|
+
// refresh), so without this the caret patches a STALE body and one file's content shows under another's
|
|
3179
|
+
// breadcrumb. On mismatch we fall through to openSourceFile, which re-renders the body for `path`.
|
|
2951
3180
|
const sameFileOpen = Boolean(viewer && viewer.dataset.openPath === path && !viewer.classList.contains('hidden')
|
|
2952
|
-
&& prev && prev.path === path && !isHttpFile(path));
|
|
3181
|
+
&& prev && prev.path === path && !isHttpFile(path) && sourceBodyPath === path);
|
|
2953
3182
|
|
|
2954
3183
|
viewerCursor = { path, lineIndex: boundedLine, column: boundedColumn, targetLine };
|
|
2955
3184
|
|
|
2956
3185
|
if (sameFileOpen) {
|
|
2957
|
-
|
|
3186
|
+
// Coalesce caret render + scroll into ONE frame on reveal (ArrowDown) so a fast key-repeat doesn't run
|
|
3187
|
+
// the caret several rows ahead of the lagging (rAF) scroll and snap ~one viewport at a time ("stutter
|
|
3188
|
+
// every ~26 lines"). Click (no reveal) stays instant.
|
|
3189
|
+
if (shouldReveal) scheduleSourceReveal(prev);
|
|
3190
|
+
else updateSourceCaret(prev, lines, file.language || 'text');
|
|
2958
3191
|
} else {
|
|
2959
3192
|
const shouldSwitch = !viewer || viewer.dataset.openPath !== path || viewer.classList.contains('hidden');
|
|
2960
3193
|
openSourceFile(path, shouldSwitch);
|
|
3194
|
+
if (shouldReveal) scheduleScrollIntoView(document.querySelector('.source-row.cursor-line'));
|
|
2961
3195
|
}
|
|
2962
|
-
if (shouldReveal) scheduleScrollIntoView(document.querySelector('.source-row.cursor-line'));
|
|
2963
3196
|
recordNav(navEntryOf('source'));
|
|
2964
3197
|
}
|
|
3198
|
+
var sourceRevealRaf = 0, sourceRevealPrev = null;
|
|
3199
|
+
function scheduleSourceReveal(prev) {
|
|
3200
|
+
// First prev of a coalesced burst wins: a fast ArrowDown updates viewerCursor many times before the frame
|
|
3201
|
+
// fires; render the caret once (first prev -> final viewerCursor) and scroll in the SAME frame so caret and
|
|
3202
|
+
// scroll stay locked together instead of the scroll snapping a viewport behind.
|
|
3203
|
+
if (!sourceRevealRaf) sourceRevealPrev = prev;
|
|
3204
|
+
if (sourceRevealRaf) return;
|
|
3205
|
+
sourceRevealRaf = requestAnimationFrame(function () {
|
|
3206
|
+
sourceRevealRaf = 0;
|
|
3207
|
+
var p = sourceRevealPrev; sourceRevealPrev = null;
|
|
3208
|
+
var f = sourceByPath.get(viewerCursor.path);
|
|
3209
|
+
if (!f || !f.embedded) return;
|
|
3210
|
+
var lines = f.content.split(/\r?\n/);
|
|
3211
|
+
updateSourceCaret(p, lines, f.language || 'text');
|
|
3212
|
+
var cl = document.querySelector('.source-row.cursor-line');
|
|
3213
|
+
if (cl && cl.scrollIntoView) { try { cl.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (x) {} }
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
2965
3216
|
|
|
2966
3217
|
// Move the caret by patching only the affected line cells, never the whole <table>. This keeps
|
|
2967
3218
|
// large files responsive (no full re-highlight per keystroke) and, because the new caret line is
|
|
@@ -3399,17 +3650,21 @@ function startSymbolIndex() {
|
|
|
3399
3650
|
function setIndexProgress(done, total) {
|
|
3400
3651
|
var el = document.getElementById('index-status');
|
|
3401
3652
|
var bar = document.getElementById('index-progress');
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3653
|
+
var foot = document.getElementById('footer-progress');
|
|
3654
|
+
var running = Boolean(total) && done < total;
|
|
3655
|
+
var pct = running ? Math.round(done / total * 100) + '%' : '0%';
|
|
3656
|
+
if (el) {
|
|
3657
|
+
el.textContent = running ? (t('status.indexing') + ' ' + done + '/' + total + '…') : ((total || 0) + ' ' + t('status.indexed'));
|
|
3407
3658
|
}
|
|
3408
|
-
el.textContent = t('status.indexing') + ' ' + done + '/' + total + '…';
|
|
3409
3659
|
if (bar) {
|
|
3410
|
-
bar.classList.
|
|
3411
|
-
|
|
3412
|
-
|
|
3660
|
+
bar.classList.toggle('hidden', !running);
|
|
3661
|
+
if (running && bar.firstElementChild) bar.firstElementChild.style.width = pct;
|
|
3662
|
+
}
|
|
3663
|
+
// The same signal, mirrored as a thin bar pinned under the version block at the bottom of the
|
|
3664
|
+
// sidebar — so background work (indexing) is visible even when the toolbar status is out of view.
|
|
3665
|
+
if (foot) {
|
|
3666
|
+
foot.classList.toggle('hidden', !running);
|
|
3667
|
+
if (foot.firstElementChild) foot.firstElementChild.style.width = pct;
|
|
3413
3668
|
}
|
|
3414
3669
|
}
|
|
3415
3670
|
function wordAtDiffCaret() {
|
|
@@ -3530,12 +3785,16 @@ function cycleSourceTab(dir) {
|
|
|
3530
3785
|
function openSourceFile(path, shouldSwitch = true) {
|
|
3531
3786
|
const file = sourceByPath.get(path);
|
|
3532
3787
|
if (!file) return;
|
|
3788
|
+
// Switching to another file abandons any in-progress comment elsewhere; closeComposer() clears
|
|
3789
|
+
// composerState and (via refreshComments) drops body.mc-composing so no caret stays hidden.
|
|
3790
|
+
if (composerState && composerState.path !== path) closeComposer();
|
|
3533
3791
|
addSourceTab(path);
|
|
3534
3792
|
renderSourceTabs(path);
|
|
3535
3793
|
// lazy-LOAD: source content not fetched yet -> show a loading state; loadSourceData re-opens it.
|
|
3536
3794
|
if (REVIEW_LAZY_LOAD && !sourceLoaded && file.embedded) {
|
|
3537
3795
|
pendingSourceOpen = { path: path, shouldSwitch: shouldSwitch };
|
|
3538
3796
|
loadSourceData();
|
|
3797
|
+
sourceBodyPath = null; // body shows a loading placeholder, not this path's content yet
|
|
3539
3798
|
document.getElementById('source-viewer').dataset.openPath = path;
|
|
3540
3799
|
sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
|
|
3541
3800
|
renderBreadcrumb(document.getElementById('source-title'), path);
|
|
@@ -3548,6 +3807,7 @@ function openSourceFile(path, shouldSwitch = true) {
|
|
|
3548
3807
|
return;
|
|
3549
3808
|
}
|
|
3550
3809
|
rememberRecent(path, 'source');
|
|
3810
|
+
sourceBodyPath = path; // past the lazy guard — every branch below paints THIS path's body (text/image/not-embedded)
|
|
3551
3811
|
document.getElementById('source-viewer').dataset.openPath = path;
|
|
3552
3812
|
sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
|
|
3553
3813
|
renderBreadcrumb(document.getElementById('source-title'), path);
|
|
@@ -3705,6 +3965,34 @@ function renderInlineMd(text) {
|
|
|
3705
3965
|
return s;
|
|
3706
3966
|
}
|
|
3707
3967
|
|
|
3968
|
+
// Render HTML embedded in Markdown (GitHub-style) safely. Parse in an INERT <template> — scripts don't
|
|
3969
|
+
// run and resources don't load there — then strip dangerous tags + on*/javascript: attributes before
|
|
3970
|
+
// returning the HTML. The result is injected via innerHTML, so only the sanitized subset survives.
|
|
3971
|
+
function sanitizeHtml(html) {
|
|
3972
|
+
var tpl = document.createElement('template');
|
|
3973
|
+
tpl.innerHTML = String(html);
|
|
3974
|
+
var BAD = { SCRIPT: 1, STYLE: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, LINK: 1, META: 1, BASE: 1, FORM: 1, INPUT: 1, BUTTON: 1, TEXTAREA: 1, SELECT: 1, NOSCRIPT: 1 };
|
|
3975
|
+
var walk = function (node) {
|
|
3976
|
+
var kids = Array.prototype.slice.call(node.children || []);
|
|
3977
|
+
for (var k = 0; k < kids.length; k++) {
|
|
3978
|
+
var el = kids[k];
|
|
3979
|
+
if (BAD[el.tagName]) { el.parentNode.removeChild(el); continue; }
|
|
3980
|
+
var attrs = Array.prototype.slice.call(el.attributes);
|
|
3981
|
+
for (var a = 0; a < attrs.length; a++) {
|
|
3982
|
+
var nm = attrs[a].name.toLowerCase();
|
|
3983
|
+
if (nm.indexOf('on') === 0) { el.removeAttribute(attrs[a].name); continue; }
|
|
3984
|
+
if ((nm === 'href' || nm === 'src' || nm === 'xlink:href' || nm === 'srcset')
|
|
3985
|
+
&& /^\s*(javascript|vbscript|data:text\/html):/i.test(attrs[a].value || '')) {
|
|
3986
|
+
el.removeAttribute(attrs[a].name);
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
walk(el);
|
|
3990
|
+
}
|
|
3991
|
+
};
|
|
3992
|
+
walk(tpl.content);
|
|
3993
|
+
return tpl.innerHTML;
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3708
3996
|
function mdFenceLang(lang) {
|
|
3709
3997
|
var l = (lang || '').toLowerCase();
|
|
3710
3998
|
if (l === 'js' || l === 'jsx' || l === 'ts' || l === 'tsx') return 'typescript';
|
|
@@ -3740,6 +4028,16 @@ function renderMarkdownBlocks(content) {
|
|
|
3740
4028
|
continue;
|
|
3741
4029
|
}
|
|
3742
4030
|
if (/^\s*$/.test(line)) { i++; continue; }
|
|
4031
|
+
// Raw HTML block (GitHub-flavored Markdown): a line beginning with a tag. Accumulate to the next
|
|
4032
|
+
// blank line and render it as sanitized HTML, so README markup (<div>, <img>, <table>, …) shows
|
|
4033
|
+
// rendered instead of as escaped text.
|
|
4034
|
+
if (/^\s*<(\/?[a-zA-Z][\w-]*|!--)/.test(line)) {
|
|
4035
|
+
var hbuf = [line];
|
|
4036
|
+
i++;
|
|
4037
|
+
while (i < lines.length && !/^\s*$/.test(lines[i])) { hbuf.push(lines[i]); i++; }
|
|
4038
|
+
blocks.push({ line: start, html: '<div class="md-html">' + sanitizeHtml(hbuf.join('\n')) + '</div>' });
|
|
4039
|
+
continue;
|
|
4040
|
+
}
|
|
3743
4041
|
var h = line.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
|
|
3744
4042
|
if (h) { var lv = h[1].length; blocks.push({ line: start, html: '<h' + lv + ' class="md-h md-h' + lv + '">' + renderInlineMd(h[2].replace(/\s+#+\s*$/, '')) + '</h' + lv + '>' }); i++; continue; }
|
|
3745
4043
|
if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) { blocks.push({ line: start, html: '<hr class="md-hr">' }); i++; continue; }
|
|
@@ -4167,6 +4465,14 @@ function highlightLine(text, language) {
|
|
|
4167
4465
|
index = end;
|
|
4168
4466
|
continue;
|
|
4169
4467
|
}
|
|
4468
|
+
if (char === '@') {
|
|
4469
|
+
const decorator = rest.match(/^@[A-Za-z_$][\w$.]*/);
|
|
4470
|
+
if (decorator) {
|
|
4471
|
+
output += '<span class="tok-decorator">' + escapeHtml(decorator[0]) + '</span>';
|
|
4472
|
+
index += decorator[0].length;
|
|
4473
|
+
continue;
|
|
4474
|
+
}
|
|
4475
|
+
}
|
|
4170
4476
|
const number = rest.match(/^\b\d+(?:\.\d+)?\b/);
|
|
4171
4477
|
if (number) {
|
|
4172
4478
|
output += '<span class="tok-number">' + escapeHtml(number[0]) + '</span>';
|
|
@@ -4176,8 +4482,11 @@ function highlightLine(text, language) {
|
|
|
4176
4482
|
const identifier = rest.match(/^[A-Za-z_$][\w$-]*/);
|
|
4177
4483
|
if (identifier) {
|
|
4178
4484
|
const value = identifier[0];
|
|
4485
|
+
const trailing = text.slice(index + value.length);
|
|
4179
4486
|
if (keywords.has(value)) output += '<span class="tok-keyword">' + escapeHtml(value) + '</span>';
|
|
4180
4487
|
else if (literals.has(value)) output += '<span class="tok-literal">' + escapeHtml(value) + '</span>';
|
|
4488
|
+
else if (/^\s*\(/.test(trailing)) output += '<span class="tok-function">' + escapeHtml(value) + '</span>';
|
|
4489
|
+
else if (/^[A-Z]/.test(value) && /[a-z]/.test(value)) output += '<span class="tok-type">' + escapeHtml(value) + '</span>';
|
|
4181
4490
|
else output += escapeHtml(value);
|
|
4182
4491
|
index += value.length;
|
|
4183
4492
|
continue;
|