@happy-nut/monacori 0.1.11 → 0.1.13
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 +54 -215
- package/dist/diff.js +22 -9
- package/dist/i18n.js +12 -0
- package/dist/render.d.ts +1 -1
- package/dist/render.js +12 -3
- package/dist/util.js +13 -3
- package/dist/viewer.client.js +423 -50
- package/dist/viewer.css +95 -25
- package/package.json +1 -1
package/dist/viewer.client.js
CHANGED
|
@@ -147,15 +147,51 @@ var locale = (function () {
|
|
|
147
147
|
return (v === 'ko' || v === 'en') ? v : 'en';
|
|
148
148
|
})();
|
|
149
149
|
function t(key) { var m = (I18N[locale] || I18N.en || {}); return (m && key in m) ? m[key] : ((I18N.en && I18N.en[key]) || key); }
|
|
150
|
+
var langSelectRef = null, themeSelectRef = null;
|
|
151
|
+
// Replace a native <select> with a button that opens our custom dropdown (consistent with the comment
|
|
152
|
+
// dropdown + themable; native <select> popups ignore the app theme). getOptions() -> [{value,label}];
|
|
153
|
+
// returns { render } so localized labels can be refreshed on a language switch.
|
|
154
|
+
function setupCustomSelect(id, getOptions, getCurrent, onPick) {
|
|
155
|
+
var el = document.getElementById(id);
|
|
156
|
+
if (!el) return null;
|
|
157
|
+
function render() {
|
|
158
|
+
var cur = getCurrent();
|
|
159
|
+
var match = getOptions().filter(function (o) { return o.value === cur; })[0];
|
|
160
|
+
el.textContent = match ? match.label : cur;
|
|
161
|
+
}
|
|
162
|
+
el.addEventListener('click', function (e) {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
var r = el.getBoundingClientRect(), cur = getCurrent();
|
|
165
|
+
showCustomDropdown(r.left, r.bottom + 4, getOptions().map(function (o) {
|
|
166
|
+
return { label: (o.value === cur ? '✓ ' : ' ') + o.label, onSelect: function () { onPick(o.value); render(); } };
|
|
167
|
+
}));
|
|
168
|
+
});
|
|
169
|
+
render();
|
|
170
|
+
return { render: render };
|
|
171
|
+
}
|
|
150
172
|
function applyI18n() {
|
|
151
173
|
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = t(el.getAttribute('data-i18n')); });
|
|
152
174
|
document.querySelectorAll('[data-i18n-ph]').forEach(function (el) { el.setAttribute('placeholder', t(el.getAttribute('data-i18n-ph'))); });
|
|
153
175
|
document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.setAttribute('title', t(el.getAttribute('data-i18n-title'))); });
|
|
154
176
|
document.querySelectorAll('[data-i18n-aria]').forEach(function (el) { el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria'))); });
|
|
155
177
|
document.documentElement.lang = locale;
|
|
156
|
-
|
|
157
|
-
if (
|
|
178
|
+
if (langSelectRef) langSelectRef.render();
|
|
179
|
+
if (themeSelectRef) themeSelectRef.render(); // theme labels are localized — refresh on a language switch too
|
|
180
|
+
}
|
|
181
|
+
// Theme mirrors the locale pattern: persisted choice, applied by toggling data-theme on <html> so the
|
|
182
|
+
// :root[data-theme="light"] palette takes over. Dark is the default (matches the inline :root). Applied
|
|
183
|
+
// immediately at script start to minimize a first-paint flash from the dark default to light.
|
|
184
|
+
var THEME_KEY = 'monacori-theme';
|
|
185
|
+
var theme = (function () {
|
|
186
|
+
var v = persistRead(THEME_KEY);
|
|
187
|
+
if (v !== 'light' && v !== 'dark') { try { v = localStorage.getItem(THEME_KEY); } catch (e) {} }
|
|
188
|
+
return (v === 'light' || v === 'dark') ? v : 'dark';
|
|
189
|
+
})();
|
|
190
|
+
function applyTheme() {
|
|
191
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
192
|
+
if (themeSelectRef) themeSelectRef.render();
|
|
158
193
|
}
|
|
194
|
+
applyTheme();
|
|
159
195
|
let fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
|
|
160
196
|
let httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
|
|
161
197
|
let httpEnvNames = Object.keys(httpEnvironments);
|
|
@@ -168,6 +204,10 @@ let sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
|
|
|
168
204
|
// and the source view shows a brief loading state. Non-lazy-load modes embed source -> already loaded.
|
|
169
205
|
var sourceLoaded = !REVIEW_LAZY_LOAD;
|
|
170
206
|
var pendingSourceOpen = null;
|
|
207
|
+
// The path whose content is ACTUALLY painted in #source-body right now. dataset.openPath is the INTENDED
|
|
208
|
+
// path and gets set BEFORE the body paints in the lazy-LOAD branch, so the caret fast-path must check this
|
|
209
|
+
// instead — else it patches the caret onto a stale body, leaving one file's content under another's path.
|
|
210
|
+
var sourceBodyPath = null;
|
|
171
211
|
var sourceLoading = false;
|
|
172
212
|
var pendingSymbol = null;
|
|
173
213
|
var sourceTabs = []; // Files-mode tab paths (session-only); see addSourceTab / renderSourceTabs.
|
|
@@ -194,6 +234,7 @@ function loadSourceData() {
|
|
|
194
234
|
sourceLoaded = true;
|
|
195
235
|
sourceLoading = false;
|
|
196
236
|
scheduleSymbolIndex();
|
|
237
|
+
remapComments(); // content just arrived — reconcile comment anchors against it
|
|
197
238
|
if (pendingSourceOpen) { var po = pendingSourceOpen; pendingSourceOpen = null; openSourceFile(po.path, po.shouldSwitch); }
|
|
198
239
|
else if (isSourceViewerVisible() && document.getElementById('source-viewer').dataset.openPath) { openSourceFile(document.getElementById('source-viewer').dataset.openPath, false); }
|
|
199
240
|
if (pendingSymbol) { var s = pendingSymbol; pendingSymbol = null; goToDefOrUsages(s); }
|
|
@@ -831,17 +872,33 @@ function isTreeRowVisible(el) {
|
|
|
831
872
|
function treeRows() {
|
|
832
873
|
const panel = document.querySelector('.tab-panel:not(.hidden)');
|
|
833
874
|
if (!panel) return [];
|
|
834
|
-
|
|
875
|
+
// isTreeRowVisible walks ancestor <details> (cheap, layout-free) and already excludes rows inside
|
|
876
|
+
// collapsed folders. The previous extra `getClientRects().length > 0` check forced a SYNCHRONOUS
|
|
877
|
+
// reflow per node — 6k forced layouts on every arrow key in a large source tree, which froze input.
|
|
878
|
+
// The details walk makes the rects check redundant, so drop it.
|
|
879
|
+
return Array.from(panel.querySelectorAll('summary, .file-link')).filter(isTreeRowVisible);
|
|
835
880
|
}
|
|
836
881
|
|
|
837
882
|
function focusTree(index) {
|
|
838
883
|
const rows = treeRows();
|
|
839
884
|
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
885
|
treeFocusIndex = Math.max(0, Math.min(rows.length - 1, index));
|
|
843
|
-
|
|
844
|
-
|
|
886
|
+
// Render the focus class AND scroll in the SAME frame. A fast key-repeat queues many ArrowDowns before a
|
|
887
|
+
// frame; moving the focus class instantly while the coalesced scroll lags makes the panel jump ~one
|
|
888
|
+
// viewport (~20 rows) at a time. Coalescing both keeps focus + scroll in lockstep so it scrolls smoothly.
|
|
889
|
+
scheduleTreeFocus();
|
|
890
|
+
}
|
|
891
|
+
var treeFocusRaf = 0;
|
|
892
|
+
function scheduleTreeFocus() {
|
|
893
|
+
if (treeFocusRaf) return;
|
|
894
|
+
treeFocusRaf = requestAnimationFrame(function () {
|
|
895
|
+
treeFocusRaf = 0;
|
|
896
|
+
const rows = treeRows();
|
|
897
|
+
if (treeFocusIndex < 0 || treeFocusIndex >= rows.length) return;
|
|
898
|
+
const el = rows[treeFocusIndex];
|
|
899
|
+
document.querySelectorAll('.tree-focus').forEach((e) => { if (e !== el) e.classList.remove('tree-focus'); });
|
|
900
|
+
if (el) { el.classList.add('tree-focus'); el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }
|
|
901
|
+
});
|
|
845
902
|
}
|
|
846
903
|
|
|
847
904
|
function clearTreeFocus() {
|
|
@@ -1171,14 +1228,19 @@ document.addEventListener('keydown', (event) => {
|
|
|
1171
1228
|
|
|
1172
1229
|
if (event.key === 'F7') {
|
|
1173
1230
|
event.preventDefault();
|
|
1174
|
-
|
|
1175
|
-
|
|
1231
|
+
const delta = event.shiftKey ? -1 : 1;
|
|
1232
|
+
const sourceViewer = document.getElementById('source-viewer');
|
|
1233
|
+
// Forward F7 from the source view enters the diff at the open file's own hunk, so the reviewer lands
|
|
1234
|
+
// where they were reading. Shift+F7 — and any file with no hunk of its own — falls through to plain
|
|
1235
|
+
// prev/next-change navigation across the whole diff.
|
|
1236
|
+
if (delta > 0 && sourceViewer && !sourceViewer.classList.contains('hidden')) {
|
|
1237
|
+
const sourceHunk = firstHunkForPath(sourceViewer.dataset.openPath || '');
|
|
1176
1238
|
if (sourceHunk >= 0) {
|
|
1177
1239
|
setActive(sourceHunk);
|
|
1178
1240
|
return;
|
|
1179
1241
|
}
|
|
1180
1242
|
}
|
|
1181
|
-
next(
|
|
1243
|
+
next(delta);
|
|
1182
1244
|
}
|
|
1183
1245
|
});
|
|
1184
1246
|
|
|
@@ -1213,15 +1275,18 @@ document.getElementById('usages')?.addEventListener('click', function (event) {
|
|
|
1213
1275
|
if (event.target && event.target.id === 'usages') closeUsages();
|
|
1214
1276
|
});
|
|
1215
1277
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1278
|
+
// Delegated (like #files-panel below) so it survives the in-place diff update that re-captures `links`
|
|
1279
|
+
// on every watch tick — per-element listeners would be lost on the new nodes, and then Cmd+0 → arrow →
|
|
1280
|
+
// Enter (which calls row.click()) would silently do nothing.
|
|
1281
|
+
document.getElementById('changes-panel')?.addEventListener('click', (event) => {
|
|
1282
|
+
const link = event.target && event.target.closest ? event.target.closest('.file-link') : null;
|
|
1283
|
+
if (!link) return;
|
|
1284
|
+
showDiffView(false);
|
|
1285
|
+
const target = Number(link.dataset.hunk);
|
|
1286
|
+
if (!Number.isNaN(target) && target >= 0 && target < hunkTotal()) {
|
|
1287
|
+
event.preventDefault();
|
|
1288
|
+
setActive(target);
|
|
1289
|
+
}
|
|
1225
1290
|
});
|
|
1226
1291
|
|
|
1227
1292
|
// Delegated so it works whether the tree is inline (small repos) or materialized later (big repos).
|
|
@@ -1262,8 +1327,11 @@ if (!REVIEW_LAZY_LOAD) scheduleSymbolIndex(); // non-lazy indexes when idle; laz
|
|
|
1262
1327
|
const restored = restoreUiState();
|
|
1263
1328
|
if (!restored) {
|
|
1264
1329
|
const initial = location.hash.match(/^#hunk-(\d+)$/);
|
|
1330
|
+
const hasDiff = Boolean(document.querySelector('#diff2html-container .d2h-file-wrapper'));
|
|
1265
1331
|
if (initial) setActive(Number(initial[1]), false);
|
|
1266
|
-
|
|
1332
|
+
// Clean tree (nothing to review): open a file (README first) instead of staring at an empty diff.
|
|
1333
|
+
else if (!hasDiff) openDefaultSourceFile();
|
|
1334
|
+
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
1335
|
else openDefaultSourceFile();
|
|
1268
1336
|
}
|
|
1269
1337
|
initSourceTreeFolds();
|
|
@@ -1476,11 +1544,32 @@ function setDiffCursor(path, side, rowIndex, column, reveal) {
|
|
|
1476
1544
|
var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
|
|
1477
1545
|
diffCursor = { path: path, side: side, rowIndex: ri, column: col };
|
|
1478
1546
|
diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1547
|
+
if (reveal) {
|
|
1548
|
+
// Render the caret AND scroll in the SAME animation frame. A fast key-repeat queues several ArrowDowns
|
|
1549
|
+
// before one frame; rendering the caret immediately (while the coalesced scroll lags) would push it many
|
|
1550
|
+
// rows past the viewport, then the view would snap ~one viewport at a time. Coalescing both keeps the
|
|
1551
|
+
// caret and scroll in lockstep, so holding ArrowDown scrolls smoothly instead of jumping every ~15 lines.
|
|
1552
|
+
scheduleDiffReveal(wrapper, side, ri);
|
|
1553
|
+
} else {
|
|
1554
|
+
renderDiffCaret();
|
|
1555
|
+
applyDiffSelection();
|
|
1556
|
+
}
|
|
1482
1557
|
recordNav(navEntryOf('diff'));
|
|
1483
1558
|
}
|
|
1559
|
+
var diffRevealRaf = 0, diffRevealTarget = null;
|
|
1560
|
+
function scheduleDiffReveal(wrapper, side, ri) {
|
|
1561
|
+
diffRevealTarget = { wrapper: wrapper, side: side, ri: ri };
|
|
1562
|
+
if (diffRevealRaf) return;
|
|
1563
|
+
diffRevealRaf = requestAnimationFrame(function () {
|
|
1564
|
+
diffRevealRaf = 0;
|
|
1565
|
+
var t = diffRevealTarget; diffRevealTarget = null;
|
|
1566
|
+
renderDiffCaret();
|
|
1567
|
+
applyDiffSelection();
|
|
1568
|
+
if (!t) return;
|
|
1569
|
+
var row = diffRowAt(t.wrapper, t.side, t.ri);
|
|
1570
|
+
if (row && row.scrollIntoView) { try { row.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (x) {} }
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1484
1573
|
function navEntryOf(kind) {
|
|
1485
1574
|
if (kind === 'diff') {
|
|
1486
1575
|
if (!diffCursor) return null;
|
|
@@ -1639,6 +1728,52 @@ function handleDiffCaretKey(event) {
|
|
|
1639
1728
|
|
|
1640
1729
|
// ===== Review comments: questions ("?") and change-requests (">") =====
|
|
1641
1730
|
// (COMMENTS_KEY / reviewComments / commentSeq / composerState are declared near the top of the script)
|
|
1731
|
+
// Bottom-left, non-blocking toast stack; each toast auto-dismisses. Used to tell the user when a file
|
|
1732
|
+
// change made some comments untrackable (they were removed).
|
|
1733
|
+
function showToast(message) {
|
|
1734
|
+
var stack = document.getElementById('mc-toasts');
|
|
1735
|
+
if (!stack) { stack = document.createElement('div'); stack.id = 'mc-toasts'; document.body.appendChild(stack); }
|
|
1736
|
+
var el = document.createElement('div');
|
|
1737
|
+
el.className = 'mc-toast';
|
|
1738
|
+
el.textContent = message;
|
|
1739
|
+
stack.appendChild(el);
|
|
1740
|
+
requestAnimationFrame(function () { el.classList.add('show'); });
|
|
1741
|
+
setTimeout(function () {
|
|
1742
|
+
el.classList.add('hide');
|
|
1743
|
+
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
|
|
1744
|
+
}, 4500);
|
|
1745
|
+
}
|
|
1746
|
+
// When a file changes, follow each comment to its snapshot line (c.code) in the new content: same line if
|
|
1747
|
+
// unchanged, else the nearest exact match of that line. If the line can't be found the change is too large
|
|
1748
|
+
// to trust — drop the comment and toast. Files whose content isn't loaded yet (lazy) are skipped here and
|
|
1749
|
+
// reconciled once loadSourceData brings the content in.
|
|
1750
|
+
function remapComments() {
|
|
1751
|
+
if (!reviewComments.length) return;
|
|
1752
|
+
var dropped = [], moved = 0;
|
|
1753
|
+
reviewComments = reviewComments.filter(function (c) {
|
|
1754
|
+
var file = sourceByPath.get(c.path);
|
|
1755
|
+
if (!file || !file.embedded || typeof file.content !== 'string' || !file.content) return true;
|
|
1756
|
+
var code = c.code == null ? '' : String(c.code);
|
|
1757
|
+
if (!code.trim()) return true;
|
|
1758
|
+
var lines = file.content.split(/\r?\n/);
|
|
1759
|
+
if (lines[c.line - 1] === code) return true;
|
|
1760
|
+
var best = -1, bestDist = Infinity;
|
|
1761
|
+
for (var i = 0; i < lines.length; i++) {
|
|
1762
|
+
if (lines[i] === code) { var d = Math.abs(i - (c.line - 1)); if (d < bestDist) { bestDist = d; best = i; } }
|
|
1763
|
+
}
|
|
1764
|
+
if (best >= 0) { if (c.line !== best + 1) moved++; c.line = best + 1; return true; }
|
|
1765
|
+
dropped.push(c);
|
|
1766
|
+
return false;
|
|
1767
|
+
});
|
|
1768
|
+
if (!dropped.length && !moved) return; // nothing changed — skip the save/re-render
|
|
1769
|
+
saveComments();
|
|
1770
|
+
var byPath = {};
|
|
1771
|
+
dropped.forEach(function (c) { byPath[c.path] = (byPath[c.path] || 0) + 1; });
|
|
1772
|
+
Object.keys(byPath).forEach(function (p) {
|
|
1773
|
+
showToast(t('toast.commentsDropped').replace('{n}', byPath[p]).replace('{file}', String(p).split('/').pop()));
|
|
1774
|
+
});
|
|
1775
|
+
refreshComments();
|
|
1776
|
+
}
|
|
1642
1777
|
function saveComments() {
|
|
1643
1778
|
persistSave(COMMENTS_KEY, reviewComments);
|
|
1644
1779
|
}
|
|
@@ -1689,14 +1824,16 @@ function currentCommentTarget() {
|
|
|
1689
1824
|
var f = Math.min(sa, sb), t = Math.max(sa, sb);
|
|
1690
1825
|
return { path: viewerCursor.path, line: t + 1, code: selText, from: f + 1, to: t + 1, side: null };
|
|
1691
1826
|
}
|
|
1692
|
-
|
|
1827
|
+
var scaretFile = sourceByPath.get(viewerCursor.path);
|
|
1828
|
+
var scaretCode = (scaretFile && typeof scaretFile.content === 'string') ? (scaretFile.content.split(/\r?\n/)[viewerCursor.lineIndex] || '') : '';
|
|
1829
|
+
return { path: viewerCursor.path, line: viewerCursor.lineIndex + 1, code: scaretCode, from: null, to: null, side: null };
|
|
1693
1830
|
}
|
|
1694
1831
|
// Diff view: prefer the explicit diff caret when there is no text selection.
|
|
1695
1832
|
if (!hasSel && diffCursor && isDiffViewVisible()) {
|
|
1696
1833
|
var dwrap = diffWrapperByPath(diffCursor.path);
|
|
1697
1834
|
var drow = dwrap ? diffRowAt(dwrap, diffCursor.side, diffCursor.rowIndex) : null;
|
|
1698
1835
|
var dline = drow ? diffLineNumber(drow) : null;
|
|
1699
|
-
if (dline != null) return { path: diffCursor.path, line: dline, code: '', from: null, to: null, side: null };
|
|
1836
|
+
if (dline != null) return { path: diffCursor.path, line: dline, code: diffLineText(drow) || '', from: null, to: null, side: null };
|
|
1700
1837
|
}
|
|
1701
1838
|
// Diff view with a selection (or click): anchor at the LAST line so the composer drops BELOW the
|
|
1702
1839
|
// drag; capture the selected code + line span (used to keep the drag highlighted via .mc-sel-line).
|
|
@@ -1729,6 +1866,13 @@ function currentCommentTarget() {
|
|
|
1729
1866
|
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
1867
|
}
|
|
1731
1868
|
|
|
1869
|
+
// "live_trading_engine.py:424" (or ":420–424" for a multi-line drag) — shown in the composer head so the
|
|
1870
|
+
// reviewer always sees WHICH file + line(s) a comment targets instead of a bare, context-free box.
|
|
1871
|
+
function composerTargetLabel(s) {
|
|
1872
|
+
var base = (s.path || '').split('/').pop() || s.path || '';
|
|
1873
|
+
var loc = (s.from != null && s.to != null && s.from !== s.to) ? (s.from + '–' + s.to) : String(s.line);
|
|
1874
|
+
return base + ':' + loc;
|
|
1875
|
+
}
|
|
1732
1876
|
function threadHtml(path, line) {
|
|
1733
1877
|
var html = '';
|
|
1734
1878
|
commentsAt(path, line).forEach(function (c) {
|
|
@@ -1740,7 +1884,7 @@ function threadHtml(path, line) {
|
|
|
1740
1884
|
if (composerState && composerState.path === path && composerState.line === line) {
|
|
1741
1885
|
var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
|
|
1742
1886
|
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>'
|
|
1887
|
+
+ '<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
1888
|
+ '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '"></textarea>'
|
|
1745
1889
|
+ '<div class="mc-actions"><button type="button" class="mc-btn mc-save">' + escapeHtml(t('composer.save')) + '</button>'
|
|
1746
1890
|
+ '<button type="button" class="mc-btn mc-ghost mc-cancel">' + escapeHtml(t('composer.cancel')) + '</button>'
|
|
@@ -1853,6 +1997,18 @@ function refreshComments() {
|
|
|
1853
1997
|
if (isSourceViewerVisible()) renderSourceComments();
|
|
1854
1998
|
renderCommentBadges();
|
|
1855
1999
|
applyCommentSelectionHighlight();
|
|
2000
|
+
// Keep body.mc-composing (which hides the file caret) tied to the ACTUAL on-screen composer, not just
|
|
2001
|
+
// composerState. Leaving the composer by any path other than save/cancel (opening another file, switching
|
|
2002
|
+
// views) would otherwise leave the class stuck and hide EVERY caret — making arrow navigation and
|
|
2003
|
+
// comment-box selection look dead. This single sync point covers all refreshComments callers.
|
|
2004
|
+
var visibleComposer = false;
|
|
2005
|
+
var composerInputs = document.querySelectorAll('.mc-composer .mc-input');
|
|
2006
|
+
for (var ci = 0; ci < composerInputs.length; ci++) {
|
|
2007
|
+
if (composerInputs[ci].closest('#diff-view') && !isDiffViewVisible()) continue;
|
|
2008
|
+
if (composerInputs[ci].closest('#source-viewer') && !isSourceViewerVisible()) continue;
|
|
2009
|
+
visibleComposer = true; break;
|
|
2010
|
+
}
|
|
2011
|
+
document.body.classList.toggle('mc-composing', visibleComposer);
|
|
1856
2012
|
if (composerState) {
|
|
1857
2013
|
var composerFocusTries = 0;
|
|
1858
2014
|
var tryFocusComposer = function () {
|
|
@@ -1881,7 +2037,8 @@ function openComposer(kind) {
|
|
|
1881
2037
|
// Keep the dragged code visibly highlighted via the .mc-sel-line class (applyCommentSelectionHighlight),
|
|
1882
2038
|
// and clear the native selection so its highlight doesn't bleed into the composer/cards below it.
|
|
1883
2039
|
try { var psel = window.getSelection(); if (psel) psel.removeAllRanges(); } catch (e) {}
|
|
1884
|
-
refreshComments();
|
|
2040
|
+
refreshComments(); // refreshComments syncs body.mc-composing from the on-screen composer
|
|
2041
|
+
|
|
1885
2042
|
}
|
|
1886
2043
|
function closeComposer() {
|
|
1887
2044
|
if (!composerState) return;
|
|
@@ -1932,6 +2089,79 @@ function saveMergePrompt(kind, text) {
|
|
|
1932
2089
|
persistSave(mergePromptsKey, saved);
|
|
1933
2090
|
}
|
|
1934
2091
|
|
|
2092
|
+
// Reusable custom dropdown (keyboard + mouse). options: [{ label, onSelect }]. First item is pre-selected;
|
|
2093
|
+
// Arrow keys move, Enter chooses, Esc / click-outside dismiss. Replaces native <select>/menus everywhere.
|
|
2094
|
+
function showCustomDropdown(x, y, options) {
|
|
2095
|
+
var existing = document.getElementById('mc-dropdown');
|
|
2096
|
+
if (existing) existing.remove();
|
|
2097
|
+
var dd = document.createElement('div');
|
|
2098
|
+
dd.id = 'mc-dropdown';
|
|
2099
|
+
dd.className = 'mc-dropdown';
|
|
2100
|
+
var active = 0;
|
|
2101
|
+
function setActive(i) { active = i; for (var j = 0; j < dd.children.length; j++) dd.children[j].classList.toggle('active', j === i); }
|
|
2102
|
+
function close() { dd.remove(); document.removeEventListener('keydown', onKey, true); document.removeEventListener('mousedown', onOutside, true); }
|
|
2103
|
+
function onKey(e) {
|
|
2104
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setActive(Math.min(active + 1, options.length - 1)); }
|
|
2105
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setActive(Math.max(active - 1, 0)); }
|
|
2106
|
+
else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); var o = options[active]; close(); if (o) o.onSelect(); }
|
|
2107
|
+
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
|
|
2108
|
+
}
|
|
2109
|
+
function onOutside(e) { if (!dd.contains(e.target)) close(); }
|
|
2110
|
+
options.forEach(function (opt, i) {
|
|
2111
|
+
var item = document.createElement('button');
|
|
2112
|
+
item.type = 'button';
|
|
2113
|
+
item.className = 'mc-dropdown-item' + (i === 0 ? ' active' : '');
|
|
2114
|
+
item.textContent = opt.label;
|
|
2115
|
+
item.addEventListener('click', function () { close(); opt.onSelect(); });
|
|
2116
|
+
item.addEventListener('mousemove', function () { setActive(i); });
|
|
2117
|
+
dd.appendChild(item);
|
|
2118
|
+
});
|
|
2119
|
+
dd.style.left = Math.round(x) + 'px';
|
|
2120
|
+
dd.style.top = Math.round(y) + 'px';
|
|
2121
|
+
document.body.appendChild(dd);
|
|
2122
|
+
document.addEventListener('keydown', onKey, true);
|
|
2123
|
+
document.addEventListener('mousedown', onOutside, true);
|
|
2124
|
+
}
|
|
2125
|
+
// Map a char range in the merged textarea back to the comment seq(s) it covers. Each comment is a
|
|
2126
|
+
// "### path:line" block; the caret's block (or every block a selection spans) identifies the comment(s).
|
|
2127
|
+
function mergedCommentSeqs(kind, start, end) {
|
|
2128
|
+
var items = reviewComments.filter(function (c) { return c.kind === kind; });
|
|
2129
|
+
var text = buildMergedText(kind);
|
|
2130
|
+
var lines = text.split(String.fromCharCode(10));
|
|
2131
|
+
var seqs = [], pos = 0, idx = -1;
|
|
2132
|
+
for (var i = 0; i < lines.length; i++) {
|
|
2133
|
+
var lineStart = pos, lineEnd = pos + lines[i].length;
|
|
2134
|
+
if (lines[i].indexOf('### ') === 0) idx++;
|
|
2135
|
+
if (idx >= 0 && idx < items.length && lineEnd >= start && lineStart <= end) {
|
|
2136
|
+
var s = items[idx].seq;
|
|
2137
|
+
if (seqs.indexOf(s) < 0) seqs.push(s);
|
|
2138
|
+
}
|
|
2139
|
+
pos = lineEnd + 1;
|
|
2140
|
+
}
|
|
2141
|
+
return seqs;
|
|
2142
|
+
}
|
|
2143
|
+
function navigateToComment(seq) {
|
|
2144
|
+
var c = reviewComments.find(function (x) { return x.seq === seq; });
|
|
2145
|
+
if (!c) return;
|
|
2146
|
+
openSourceFile(c.path);
|
|
2147
|
+
requestAnimationFrame(function () { setSourceCursor(c.path, Math.max(0, (c.line || 1) - 1), 0, true, -1); });
|
|
2148
|
+
}
|
|
2149
|
+
// Move the merged-view caret to the next (dir=1) / previous (dir=-1) "### path:line" header and center it,
|
|
2150
|
+
// so Opt+Arrow steps comment-by-comment in the merged view.
|
|
2151
|
+
function jumpMergedComment(area, dir) {
|
|
2152
|
+
var text = area.value;
|
|
2153
|
+
var headers = [], pos = 0;
|
|
2154
|
+
text.split('\n').forEach(function (ln) { if (ln.indexOf('### ') === 0) headers.push(pos); pos += ln.length + 1; });
|
|
2155
|
+
if (!headers.length) return;
|
|
2156
|
+
var cur = area.selectionStart;
|
|
2157
|
+
var target;
|
|
2158
|
+
if (dir > 0) { target = headers.find(function (h) { return h > cur; }); if (target == null) target = headers[headers.length - 1]; }
|
|
2159
|
+
else { var before = headers.filter(function (h) { return h < cur; }); target = before.length ? before[before.length - 1] : headers[0]; }
|
|
2160
|
+
area.selectionStart = area.selectionEnd = target;
|
|
2161
|
+
var lineNum = text.slice(0, target).split('\n').length - 1;
|
|
2162
|
+
var lineH = parseFloat(getComputedStyle(area).lineHeight) || 18;
|
|
2163
|
+
area.scrollTop = Math.max(0, lineNum * lineH - area.clientHeight / 2);
|
|
2164
|
+
}
|
|
1935
2165
|
function buildMergedText(kind) {
|
|
1936
2166
|
var items = reviewComments.filter(function (c) { return c.kind === kind; });
|
|
1937
2167
|
var nl = String.fromCharCode(10);
|
|
@@ -1969,8 +2199,44 @@ function openMergedView(kind) {
|
|
|
1969
2199
|
closeBtn.textContent = t('merged.close');
|
|
1970
2200
|
var area = document.createElement('textarea');
|
|
1971
2201
|
area.className = 'mc-modal-text';
|
|
1972
|
-
|
|
2202
|
+
// NOT readOnly: a readOnly textarea hides the caret in Chromium, yet we need it VISIBLE so the user sees
|
|
2203
|
+
// which comment Opt+Enter / Opt+Arrow will target. Block every edit via beforeinput instead — read-only in
|
|
2204
|
+
// effect while the caret and selection stay fully interactive.
|
|
1973
2205
|
area.value = buildMergedText(kind);
|
|
2206
|
+
area.addEventListener('beforeinput', function (e) { e.preventDefault(); });
|
|
2207
|
+
// Opt/Alt+Enter on the merged text: a custom dropdown for the comment under the caret — "Go to comment"
|
|
2208
|
+
// + "Remove" for a single caret; "Remove" only for a drag/select-all (can't navigate to many at once).
|
|
2209
|
+
// Removing here calls deleteComment(), which re-syncs the on-screen comment boxes via refreshComments.
|
|
2210
|
+
area.addEventListener('keydown', function (e) {
|
|
2211
|
+
// Opt/Alt + Arrow steps the caret to the next/previous comment block so you can move comment-to-comment
|
|
2212
|
+
// and act on each with Opt+Enter, without hand-scrolling.
|
|
2213
|
+
if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
2214
|
+
e.preventDefault();
|
|
2215
|
+
e.stopPropagation();
|
|
2216
|
+
jumpMergedComment(area, e.key === 'ArrowDown' ? 1 : -1);
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
if (!e.altKey || (e.key !== 'Enter' && e.code !== 'Enter')) return;
|
|
2220
|
+
e.preventDefault();
|
|
2221
|
+
e.stopPropagation();
|
|
2222
|
+
var seqs = mergedCommentSeqs(kind, area.selectionStart, area.selectionEnd);
|
|
2223
|
+
if (!seqs.length) return;
|
|
2224
|
+
var rect = area.getBoundingClientRect();
|
|
2225
|
+
var x = rect.left + 24, y = rect.top + 48;
|
|
2226
|
+
var rerender = function () {
|
|
2227
|
+
if (!reviewComments.filter(function (c) { return c.kind === kind; }).length) { modal.remove(); return; }
|
|
2228
|
+
area.value = buildMergedText(kind);
|
|
2229
|
+
};
|
|
2230
|
+
if (area.selectionStart !== area.selectionEnd || seqs.length > 1) {
|
|
2231
|
+
showCustomDropdown(x, y, [{ label: t('dropdown.remove'), onSelect: function () { seqs.forEach(deleteComment); rerender(); } }]);
|
|
2232
|
+
} else {
|
|
2233
|
+
var seq = seqs[0];
|
|
2234
|
+
showCustomDropdown(x, y, [
|
|
2235
|
+
{ label: t('dropdown.navigate'), onSelect: function () { modal.remove(); navigateToComment(seq); } },
|
|
2236
|
+
{ label: t('dropdown.remove'), onSelect: function () { deleteComment(seq); rerender(); } },
|
|
2237
|
+
]);
|
|
2238
|
+
}
|
|
2239
|
+
});
|
|
1974
2240
|
closeBtn.addEventListener('click', function () { modal.remove(); });
|
|
1975
2241
|
// Terminal send (Electron, terminal open): close the modal and hand off to pane-pick mode ON the
|
|
1976
2242
|
// terminal — the chosen pane is highlighted, the rest dimmed, arrows change the choice, Enter sends.
|
|
@@ -2314,12 +2580,21 @@ refreshComments();
|
|
|
2314
2580
|
}
|
|
2315
2581
|
}
|
|
2316
2582
|
function toggle() { setOpen(!isOpen()); }
|
|
2583
|
+
// The keyboard shortcut is "focus-first": when the terminal is visible but focus is elsewhere, the first
|
|
2584
|
+
// press just moves focus INTO the terminal; only when it already owns focus does another press toggle it
|
|
2585
|
+
// closed. (The footer button stays a plain toggle — a mouse click should open/close in one step.)
|
|
2586
|
+
function toggleOrFocus() {
|
|
2587
|
+
if (!isOpen()) { setOpen(true); return; } // setOpen(true) also focuses the active pane
|
|
2588
|
+
var ae = document.activeElement;
|
|
2589
|
+
if (ae && panel.contains(ae)) { setOpen(false); return; } // focus already in the terminal → close
|
|
2590
|
+
if (active) { try { active.term.focus(); } catch (e) {} } // visible but unfocused → just grab focus
|
|
2591
|
+
}
|
|
2317
2592
|
|
|
2318
2593
|
if (toggleBtn) toggleBtn.addEventListener('click', toggle);
|
|
2319
2594
|
if (closeBtn) closeBtn.addEventListener('click', function () { setOpen(false); });
|
|
2320
2595
|
// Toggle (Ctrl+`/Alt+F12) and split (Cmd+D) arrive from the Terminal menu accelerators (app-main),
|
|
2321
2596
|
// 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(
|
|
2597
|
+
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalToggle === 'function') window.monacoriMenu.onTerminalToggle(toggleOrFocus);
|
|
2323
2598
|
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalSplit === 'function') window.monacoriMenu.onTerminalSplit(split);
|
|
2324
2599
|
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneFocus === 'function') window.monacoriMenu.onTerminalPaneFocus(focusPaneByDelta);
|
|
2325
2600
|
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneRename === 'function') window.monacoriMenu.onTerminalPaneRename(function () { renamePane(active); });
|
|
@@ -2546,23 +2821,28 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function')
|
|
|
2546
2821
|
if (resetBtn) resetBtn.addEventListener('click', function () { saveMergePrompt('q', ''); saveMergePrompt('c', ''); fill(); flash(); });
|
|
2547
2822
|
// Language: live-switch the whole UI (no reload). Persist, re-apply the static chrome, then re-render
|
|
2548
2823
|
// any currently-shown dynamic text (open composer / merged modal / index status) so it follows too.
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
var next = langSel.value === 'ko' ? 'ko' : 'en';
|
|
2824
|
+
langSelectRef = setupCustomSelect('settings-language',
|
|
2825
|
+
function () { return [{ value: 'en', label: 'English' }, { value: 'ko', label: '한국어' }]; },
|
|
2826
|
+
function () { return locale; },
|
|
2827
|
+
function (next) {
|
|
2554
2828
|
if (next === locale) return;
|
|
2555
2829
|
locale = next;
|
|
2556
2830
|
persistSave(LOCALE_KEY, locale);
|
|
2557
2831
|
applyI18n();
|
|
2558
|
-
//
|
|
2559
|
-
fill();
|
|
2560
|
-
// Re-render dynamic, currently-visible text in the new locale.
|
|
2832
|
+
fill(); // merge-prompt placeholders are locale-dependent defaults
|
|
2561
2833
|
try { if (typeof refreshComments === 'function') refreshComments(); } catch (e) {}
|
|
2562
2834
|
var mergedModal = document.getElementById('mc-modal');
|
|
2563
2835
|
if (mergedModal) { var mk = mergedModal.dataset.kind || 'q'; mergedModal.remove(); openMergedView(mk); }
|
|
2564
2836
|
});
|
|
2565
|
-
|
|
2837
|
+
themeSelectRef = setupCustomSelect('settings-theme',
|
|
2838
|
+
function () { return [{ value: 'dark', label: t('theme.dark') }, { value: 'light', label: t('theme.light') }]; },
|
|
2839
|
+
function () { return theme; },
|
|
2840
|
+
function (next) {
|
|
2841
|
+
if (next === theme) return;
|
|
2842
|
+
theme = next;
|
|
2843
|
+
persistSave(THEME_KEY, theme);
|
|
2844
|
+
applyTheme();
|
|
2845
|
+
});
|
|
2566
2846
|
})();
|
|
2567
2847
|
|
|
2568
2848
|
function setTab(name) {
|
|
@@ -2707,6 +2987,7 @@ function applyDiffUpdate(u) {
|
|
|
2707
2987
|
diffBootDone = false;
|
|
2708
2988
|
sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
|
|
2709
2989
|
sourceLoading = false;
|
|
2990
|
+
sourceBodyPath = null; // the new build may have changed the open file's content — force a body re-render on next open
|
|
2710
2991
|
symbolIndex = null;
|
|
2711
2992
|
if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
|
|
2712
2993
|
else { prepareDiff2HtmlHunks(); diffBootDone = true; }
|
|
@@ -2716,6 +2997,7 @@ function applyDiffUpdate(u) {
|
|
|
2716
2997
|
applyI18n();
|
|
2717
2998
|
populateHttpEnvSelect();
|
|
2718
2999
|
initSourceTreeFolds();
|
|
3000
|
+
remapComments(); // follow/drop comments whose anchor line moved or vanished in the new build
|
|
2719
3001
|
refreshComments();
|
|
2720
3002
|
|
|
2721
3003
|
// 5) Best-effort restore of what the user was looking at.
|
|
@@ -2785,7 +3067,14 @@ function updateTreeVisibility(root, query) {
|
|
|
2785
3067
|
}
|
|
2786
3068
|
|
|
2787
3069
|
function openDefaultSourceFile() {
|
|
3070
|
+
const isReadme = (candidate) => /^readme(\.|$)/i.test(candidate.name || '');
|
|
3071
|
+
const depthOf = (candidate) => (candidate.path || '').split('/').length;
|
|
3072
|
+
// Prefer the TOP-MOST README (root before any nested one), not just the first match in tree order.
|
|
3073
|
+
const rootReadme = sourceFiles
|
|
3074
|
+
.filter((candidate) => candidate.embedded && isReadme(candidate))
|
|
3075
|
+
.sort((a, b) => depthOf(a) - depthOf(b))[0];
|
|
2788
3076
|
const file = sourceFiles.find((candidate) => candidate.changed && candidate.embedded)
|
|
3077
|
+
|| rootReadme // top-most README when nothing changed
|
|
2789
3078
|
|| sourceFiles.find((candidate) => candidate.embedded)
|
|
2790
3079
|
|| sourceFiles.find((candidate) => candidate.changed)
|
|
2791
3080
|
|| sourceFiles[0];
|
|
@@ -2948,20 +3237,46 @@ function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLi
|
|
|
2948
3237
|
// Fast path: the file is already on screen and only the caret moved. Re-rendering the whole
|
|
2949
3238
|
// file on every keystroke blocks the main thread on large files, so patch just the previous
|
|
2950
3239
|
// and new caret lines in place instead.
|
|
3240
|
+
// sourceBodyPath (the file actually painted in the body) must match too — dataset.openPath/viewerCursor
|
|
3241
|
+
// are metadata that can be set before the body repaints (lazy fetch in flight, fast file switch, watch
|
|
3242
|
+
// refresh), so without this the caret patches a STALE body and one file's content shows under another's
|
|
3243
|
+
// breadcrumb. On mismatch we fall through to openSourceFile, which re-renders the body for `path`.
|
|
2951
3244
|
const sameFileOpen = Boolean(viewer && viewer.dataset.openPath === path && !viewer.classList.contains('hidden')
|
|
2952
|
-
&& prev && prev.path === path && !isHttpFile(path));
|
|
3245
|
+
&& prev && prev.path === path && !isHttpFile(path) && sourceBodyPath === path);
|
|
2953
3246
|
|
|
2954
3247
|
viewerCursor = { path, lineIndex: boundedLine, column: boundedColumn, targetLine };
|
|
2955
3248
|
|
|
2956
3249
|
if (sameFileOpen) {
|
|
2957
|
-
|
|
3250
|
+
// Coalesce caret render + scroll into ONE frame on reveal (ArrowDown) so a fast key-repeat doesn't run
|
|
3251
|
+
// the caret several rows ahead of the lagging (rAF) scroll and snap ~one viewport at a time ("stutter
|
|
3252
|
+
// every ~26 lines"). Click (no reveal) stays instant.
|
|
3253
|
+
if (shouldReveal) scheduleSourceReveal(prev);
|
|
3254
|
+
else updateSourceCaret(prev, lines, file.language || 'text');
|
|
2958
3255
|
} else {
|
|
2959
3256
|
const shouldSwitch = !viewer || viewer.dataset.openPath !== path || viewer.classList.contains('hidden');
|
|
2960
3257
|
openSourceFile(path, shouldSwitch);
|
|
3258
|
+
if (shouldReveal) scheduleScrollIntoView(document.querySelector('.source-row.cursor-line'));
|
|
2961
3259
|
}
|
|
2962
|
-
if (shouldReveal) scheduleScrollIntoView(document.querySelector('.source-row.cursor-line'));
|
|
2963
3260
|
recordNav(navEntryOf('source'));
|
|
2964
3261
|
}
|
|
3262
|
+
var sourceRevealRaf = 0, sourceRevealPrev = null;
|
|
3263
|
+
function scheduleSourceReveal(prev) {
|
|
3264
|
+
// First prev of a coalesced burst wins: a fast ArrowDown updates viewerCursor many times before the frame
|
|
3265
|
+
// fires; render the caret once (first prev -> final viewerCursor) and scroll in the SAME frame so caret and
|
|
3266
|
+
// scroll stay locked together instead of the scroll snapping a viewport behind.
|
|
3267
|
+
if (!sourceRevealRaf) sourceRevealPrev = prev;
|
|
3268
|
+
if (sourceRevealRaf) return;
|
|
3269
|
+
sourceRevealRaf = requestAnimationFrame(function () {
|
|
3270
|
+
sourceRevealRaf = 0;
|
|
3271
|
+
var p = sourceRevealPrev; sourceRevealPrev = null;
|
|
3272
|
+
var f = sourceByPath.get(viewerCursor.path);
|
|
3273
|
+
if (!f || !f.embedded) return;
|
|
3274
|
+
var lines = f.content.split(/\r?\n/);
|
|
3275
|
+
updateSourceCaret(p, lines, f.language || 'text');
|
|
3276
|
+
var cl = document.querySelector('.source-row.cursor-line');
|
|
3277
|
+
if (cl && cl.scrollIntoView) { try { cl.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (x) {} }
|
|
3278
|
+
});
|
|
3279
|
+
}
|
|
2965
3280
|
|
|
2966
3281
|
// Move the caret by patching only the affected line cells, never the whole <table>. This keeps
|
|
2967
3282
|
// large files responsive (no full re-highlight per keystroke) and, because the new caret line is
|
|
@@ -3399,17 +3714,21 @@ function startSymbolIndex() {
|
|
|
3399
3714
|
function setIndexProgress(done, total) {
|
|
3400
3715
|
var el = document.getElementById('index-status');
|
|
3401
3716
|
var bar = document.getElementById('index-progress');
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3717
|
+
var foot = document.getElementById('footer-progress');
|
|
3718
|
+
var running = Boolean(total) && done < total;
|
|
3719
|
+
var pct = running ? Math.round(done / total * 100) + '%' : '0%';
|
|
3720
|
+
if (el) {
|
|
3721
|
+
el.textContent = running ? (t('status.indexing') + ' ' + done + '/' + total + '…') : ((total || 0) + ' ' + t('status.indexed'));
|
|
3407
3722
|
}
|
|
3408
|
-
el.textContent = t('status.indexing') + ' ' + done + '/' + total + '…';
|
|
3409
3723
|
if (bar) {
|
|
3410
|
-
bar.classList.
|
|
3411
|
-
|
|
3412
|
-
|
|
3724
|
+
bar.classList.toggle('hidden', !running);
|
|
3725
|
+
if (running && bar.firstElementChild) bar.firstElementChild.style.width = pct;
|
|
3726
|
+
}
|
|
3727
|
+
// The same signal, mirrored as a thin bar pinned under the version block at the bottom of the
|
|
3728
|
+
// sidebar — so background work (indexing) is visible even when the toolbar status is out of view.
|
|
3729
|
+
if (foot) {
|
|
3730
|
+
foot.classList.toggle('hidden', !running);
|
|
3731
|
+
if (foot.firstElementChild) foot.firstElementChild.style.width = pct;
|
|
3413
3732
|
}
|
|
3414
3733
|
}
|
|
3415
3734
|
function wordAtDiffCaret() {
|
|
@@ -3530,12 +3849,16 @@ function cycleSourceTab(dir) {
|
|
|
3530
3849
|
function openSourceFile(path, shouldSwitch = true) {
|
|
3531
3850
|
const file = sourceByPath.get(path);
|
|
3532
3851
|
if (!file) return;
|
|
3852
|
+
// Switching to another file abandons any in-progress comment elsewhere; closeComposer() clears
|
|
3853
|
+
// composerState and (via refreshComments) drops body.mc-composing so no caret stays hidden.
|
|
3854
|
+
if (composerState && composerState.path !== path) closeComposer();
|
|
3533
3855
|
addSourceTab(path);
|
|
3534
3856
|
renderSourceTabs(path);
|
|
3535
3857
|
// lazy-LOAD: source content not fetched yet -> show a loading state; loadSourceData re-opens it.
|
|
3536
3858
|
if (REVIEW_LAZY_LOAD && !sourceLoaded && file.embedded) {
|
|
3537
3859
|
pendingSourceOpen = { path: path, shouldSwitch: shouldSwitch };
|
|
3538
3860
|
loadSourceData();
|
|
3861
|
+
sourceBodyPath = null; // body shows a loading placeholder, not this path's content yet
|
|
3539
3862
|
document.getElementById('source-viewer').dataset.openPath = path;
|
|
3540
3863
|
sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
|
|
3541
3864
|
renderBreadcrumb(document.getElementById('source-title'), path);
|
|
@@ -3548,6 +3871,7 @@ function openSourceFile(path, shouldSwitch = true) {
|
|
|
3548
3871
|
return;
|
|
3549
3872
|
}
|
|
3550
3873
|
rememberRecent(path, 'source');
|
|
3874
|
+
sourceBodyPath = path; // past the lazy guard — every branch below paints THIS path's body (text/image/not-embedded)
|
|
3551
3875
|
document.getElementById('source-viewer').dataset.openPath = path;
|
|
3552
3876
|
sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
|
|
3553
3877
|
renderBreadcrumb(document.getElementById('source-title'), path);
|
|
@@ -3705,6 +4029,34 @@ function renderInlineMd(text) {
|
|
|
3705
4029
|
return s;
|
|
3706
4030
|
}
|
|
3707
4031
|
|
|
4032
|
+
// Render HTML embedded in Markdown (GitHub-style) safely. Parse in an INERT <template> — scripts don't
|
|
4033
|
+
// run and resources don't load there — then strip dangerous tags + on*/javascript: attributes before
|
|
4034
|
+
// returning the HTML. The result is injected via innerHTML, so only the sanitized subset survives.
|
|
4035
|
+
function sanitizeHtml(html) {
|
|
4036
|
+
var tpl = document.createElement('template');
|
|
4037
|
+
tpl.innerHTML = String(html);
|
|
4038
|
+
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 };
|
|
4039
|
+
var walk = function (node) {
|
|
4040
|
+
var kids = Array.prototype.slice.call(node.children || []);
|
|
4041
|
+
for (var k = 0; k < kids.length; k++) {
|
|
4042
|
+
var el = kids[k];
|
|
4043
|
+
if (BAD[el.tagName]) { el.parentNode.removeChild(el); continue; }
|
|
4044
|
+
var attrs = Array.prototype.slice.call(el.attributes);
|
|
4045
|
+
for (var a = 0; a < attrs.length; a++) {
|
|
4046
|
+
var nm = attrs[a].name.toLowerCase();
|
|
4047
|
+
if (nm.indexOf('on') === 0) { el.removeAttribute(attrs[a].name); continue; }
|
|
4048
|
+
if ((nm === 'href' || nm === 'src' || nm === 'xlink:href' || nm === 'srcset')
|
|
4049
|
+
&& /^\s*(javascript|vbscript|data:text\/html):/i.test(attrs[a].value || '')) {
|
|
4050
|
+
el.removeAttribute(attrs[a].name);
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
walk(el);
|
|
4054
|
+
}
|
|
4055
|
+
};
|
|
4056
|
+
walk(tpl.content);
|
|
4057
|
+
return tpl.innerHTML;
|
|
4058
|
+
}
|
|
4059
|
+
|
|
3708
4060
|
function mdFenceLang(lang) {
|
|
3709
4061
|
var l = (lang || '').toLowerCase();
|
|
3710
4062
|
if (l === 'js' || l === 'jsx' || l === 'ts' || l === 'tsx') return 'typescript';
|
|
@@ -3740,6 +4092,16 @@ function renderMarkdownBlocks(content) {
|
|
|
3740
4092
|
continue;
|
|
3741
4093
|
}
|
|
3742
4094
|
if (/^\s*$/.test(line)) { i++; continue; }
|
|
4095
|
+
// Raw HTML block (GitHub-flavored Markdown): a line beginning with a tag. Accumulate to the next
|
|
4096
|
+
// blank line and render it as sanitized HTML, so README markup (<div>, <img>, <table>, …) shows
|
|
4097
|
+
// rendered instead of as escaped text.
|
|
4098
|
+
if (/^\s*<(\/?[a-zA-Z][\w-]*|!--)/.test(line)) {
|
|
4099
|
+
var hbuf = [line];
|
|
4100
|
+
i++;
|
|
4101
|
+
while (i < lines.length && !/^\s*$/.test(lines[i])) { hbuf.push(lines[i]); i++; }
|
|
4102
|
+
blocks.push({ line: start, html: '<div class="md-html">' + sanitizeHtml(hbuf.join('\n')) + '</div>' });
|
|
4103
|
+
continue;
|
|
4104
|
+
}
|
|
3743
4105
|
var h = line.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
|
|
3744
4106
|
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
4107
|
if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) { blocks.push({ line: start, html: '<hr class="md-hr">' }); i++; continue; }
|
|
@@ -4167,6 +4529,14 @@ function highlightLine(text, language) {
|
|
|
4167
4529
|
index = end;
|
|
4168
4530
|
continue;
|
|
4169
4531
|
}
|
|
4532
|
+
if (char === '@') {
|
|
4533
|
+
const decorator = rest.match(/^@[A-Za-z_$][\w$.]*/);
|
|
4534
|
+
if (decorator) {
|
|
4535
|
+
output += '<span class="tok-decorator">' + escapeHtml(decorator[0]) + '</span>';
|
|
4536
|
+
index += decorator[0].length;
|
|
4537
|
+
continue;
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4170
4540
|
const number = rest.match(/^\b\d+(?:\.\d+)?\b/);
|
|
4171
4541
|
if (number) {
|
|
4172
4542
|
output += '<span class="tok-number">' + escapeHtml(number[0]) + '</span>';
|
|
@@ -4176,8 +4546,11 @@ function highlightLine(text, language) {
|
|
|
4176
4546
|
const identifier = rest.match(/^[A-Za-z_$][\w$-]*/);
|
|
4177
4547
|
if (identifier) {
|
|
4178
4548
|
const value = identifier[0];
|
|
4549
|
+
const trailing = text.slice(index + value.length);
|
|
4179
4550
|
if (keywords.has(value)) output += '<span class="tok-keyword">' + escapeHtml(value) + '</span>';
|
|
4180
4551
|
else if (literals.has(value)) output += '<span class="tok-literal">' + escapeHtml(value) + '</span>';
|
|
4552
|
+
else if (/^\s*\(/.test(trailing)) output += '<span class="tok-function">' + escapeHtml(value) + '</span>';
|
|
4553
|
+
else if (/^[A-Z]/.test(value) && /[a-z]/.test(value)) output += '<span class="tok-type">' + escapeHtml(value) + '</span>';
|
|
4181
4554
|
else output += escapeHtml(value);
|
|
4182
4555
|
index += value.length;
|
|
4183
4556
|
continue;
|