@happy-nut/monacori 0.1.0 → 0.1.3
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 +44 -132
- package/assets/icon.png +0 -0
- package/assets/screenshots/diff-review.png +0 -0
- package/assets/screenshots/terminal.png +0 -0
- package/dist/app-main.js +210 -10
- package/dist/assets.d.ts +6 -0
- package/dist/assets.js +51 -0
- package/dist/build.d.ts +13 -0
- package/dist/build.js +77 -0
- package/dist/cli.d.ts +5 -33
- package/dist/cli.js +7 -3529
- package/dist/commands.d.ts +1 -0
- package/dist/commands.js +678 -0
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +11 -0
- package/dist/diff.d.ts +12 -0
- package/dist/diff.js +396 -0
- package/dist/git.d.ts +4 -0
- package/dist/git.js +23 -0
- package/dist/highlight.d.ts +1 -0
- package/dist/highlight.js +85 -0
- package/dist/i18n.d.ts +1 -0
- package/dist/i18n.js +256 -0
- package/dist/preload.cjs +83 -0
- package/dist/preload.d.cts +1 -0
- package/dist/render.d.ts +33 -0
- package/dist/render.js +406 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +175 -0
- package/dist/types.d.ts +99 -0
- package/dist/types.js +1 -0
- package/dist/util.d.ts +18 -0
- package/dist/util.js +144 -0
- package/dist/viewer.client.js +3935 -0
- package/dist/viewer.css +1094 -0
- package/package.json +9 -3
- package/scripts/patch-electron-name.mjs +65 -0
|
@@ -0,0 +1,3935 @@
|
|
|
1
|
+
|
|
2
|
+
const REVIEW_LAZY = document.getElementById('review-meta')?.dataset.lazy === 'true';
|
|
3
|
+
// lazy-LOAD (Phase 2): file bodies are NOT embedded; they are fetched on demand (serve: GET /file,
|
|
4
|
+
// Electron: window.monacoriFile.get) so the initial HTML stays small. Implies REVIEW_LAZY (shells).
|
|
5
|
+
const REVIEW_LAZY_LOAD = document.getElementById('review-meta')?.dataset.lazyLoad === 'true';
|
|
6
|
+
if (!REVIEW_LAZY) prepareDiff2HtmlHunks();
|
|
7
|
+
const hunks = REVIEW_LAZY ? [] : Array.from(document.querySelectorAll('.hunk'));
|
|
8
|
+
const hunkPeers = REVIEW_LAZY ? [] : Array.from(document.querySelectorAll('.hunk-peer'));
|
|
9
|
+
// Lazy mode: each file body lives in an inert <script type="text/html"> island (see splitDiffForLazy).
|
|
10
|
+
// Build a hunk index from the lightweight shells (data-first-hunk/data-hunk-count/data-path) so F7 and
|
|
11
|
+
// change-nav work without materializing everything. hunkRowAt() materializes the target file on demand.
|
|
12
|
+
const hunkMeta = [];
|
|
13
|
+
if (REVIEW_LAZY) {
|
|
14
|
+
Array.prototype.forEach.call(document.querySelectorAll('#diff2html-container .d2h-file-wrapper'), function (w) {
|
|
15
|
+
var base = parseInt(w.dataset.firstHunk || '0', 10) || 0;
|
|
16
|
+
var cnt = parseInt(w.dataset.hunkCount || '0', 10) || 0;
|
|
17
|
+
var p = w.dataset.path || ((w.querySelector('.d2h-file-name') || {}).textContent || '').trim();
|
|
18
|
+
for (var k = 0; k < cnt; k++) hunkMeta[base + k] = { path: p };
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
var diffBootDone = false;
|
|
22
|
+
function hunkTotal() { return REVIEW_LAZY ? hunkMeta.length : hunks.length; }
|
|
23
|
+
function hunkPathAt(i) { return REVIEW_LAZY ? (hunkMeta[i] ? hunkMeta[i].path : '') : (hunks[i] ? hunks[i].dataset.file : ''); }
|
|
24
|
+
function hunkRowAt(i) {
|
|
25
|
+
if (!REVIEW_LAZY) return hunks[i] || null;
|
|
26
|
+
var meta = hunkMeta[i];
|
|
27
|
+
if (!meta) return null;
|
|
28
|
+
ensureFileReady(diffWrapperByPath(meta.path));
|
|
29
|
+
return document.getElementById('hunk-' + i);
|
|
30
|
+
}
|
|
31
|
+
// Assign global hunk ids/classes to a freshly materialized file body, keyed off its shell's
|
|
32
|
+
// data-first-hunk so indices stay globally consistent with the eager numbering.
|
|
33
|
+
function markWrapperHunks(wrapper) {
|
|
34
|
+
var base = parseInt(wrapper.dataset.firstHunk || '0', 10) || 0;
|
|
35
|
+
var fileName = ((wrapper.querySelector('.d2h-file-name') || {}).textContent || '').trim();
|
|
36
|
+
var headerToIndex = new Map();
|
|
37
|
+
var local = 0;
|
|
38
|
+
Array.prototype.forEach.call(wrapper.querySelectorAll('tr'), function (row) {
|
|
39
|
+
var header = (row.textContent || '').trim();
|
|
40
|
+
if (header.indexOf('@@') !== 0) return;
|
|
41
|
+
var index = headerToIndex.get(header);
|
|
42
|
+
if (index === undefined) { index = base + local; headerToIndex.set(header, index); row.classList.add('hunk'); row.id = 'hunk-' + index; local += 1; }
|
|
43
|
+
else { row.classList.add('hunk-peer'); }
|
|
44
|
+
row.dataset.hunkIndex = String(index);
|
|
45
|
+
row.dataset.file = fileName;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
var bodyCache = {}; // file index -> diff body html (lazy-LOAD cache)
|
|
49
|
+
var bodyPromise = {}; // file index -> Promise that resolves once the body is materialized
|
|
50
|
+
function loadBodyHtml(index) {
|
|
51
|
+
if (bodyCache[index] != null) return Promise.resolve(bodyCache[index]);
|
|
52
|
+
var p;
|
|
53
|
+
if (typeof window !== 'undefined' && window.monacoriFile && typeof window.monacoriFile.get === 'function') {
|
|
54
|
+
p = Promise.resolve().then(function () { return window.monacoriFile.get(Number(index), 'diff'); });
|
|
55
|
+
} else if (typeof fetch !== 'undefined') {
|
|
56
|
+
p = fetch('file?index=' + index).then(function (r) { return r.ok ? r.text() : ''; });
|
|
57
|
+
} else {
|
|
58
|
+
p = Promise.resolve('');
|
|
59
|
+
}
|
|
60
|
+
return p.then(function (html) { bodyCache[index] = html || ''; return bodyCache[index]; }, function () { bodyCache[index] = ''; return ''; });
|
|
61
|
+
}
|
|
62
|
+
function materializeBody(wrapper, html) {
|
|
63
|
+
var body = wrapper.querySelector('.d2h-files-diff[data-lazy]');
|
|
64
|
+
if (!body) return;
|
|
65
|
+
body.innerHTML = html || '';
|
|
66
|
+
body.removeAttribute('data-lazy');
|
|
67
|
+
body.removeAttribute('data-loading');
|
|
68
|
+
markWrapperHunks(wrapper);
|
|
69
|
+
if (diffBootDone && typeof reviewComments !== 'undefined' && reviewComments.length) { try { refreshComments(); } catch (e) {} }
|
|
70
|
+
}
|
|
71
|
+
// Materialize a lazily-emitted file body. Phase 1 reads it from an inert embedded island (sync);
|
|
72
|
+
// Phase 2 lazy-LOAD fetches it on demand (async) — callers that then need rows must use whenFileReady().
|
|
73
|
+
function ensureFileReady(wrapper) {
|
|
74
|
+
if (!wrapper) return null;
|
|
75
|
+
var body = wrapper.querySelector('.d2h-files-diff[data-lazy]');
|
|
76
|
+
if (!body) return wrapper; // already materialized (or eager mode)
|
|
77
|
+
var idx = (wrapper.id || '').replace('file-', '');
|
|
78
|
+
if (REVIEW_LAZY_LOAD) {
|
|
79
|
+
if (!bodyPromise[idx]) {
|
|
80
|
+
body.setAttribute('data-loading', '1');
|
|
81
|
+
bodyPromise[idx] = loadBodyHtml(idx).then(function (html) { materializeBody(wrapper, html); return wrapper; });
|
|
82
|
+
}
|
|
83
|
+
return wrapper;
|
|
84
|
+
}
|
|
85
|
+
var island = document.getElementById('diff-body-' + idx);
|
|
86
|
+
if (island) materializeBody(wrapper, island.textContent || '');
|
|
87
|
+
return wrapper;
|
|
88
|
+
}
|
|
89
|
+
// Run cb once the wrapper's body is materialized — synchronously when it already is (eager / Phase 1
|
|
90
|
+
// island / cached), or after the fetch resolves (cold lazy-LOAD). Lets navigation stay correct without
|
|
91
|
+
// turning every caller async.
|
|
92
|
+
function whenFileReady(wrapper, cb) {
|
|
93
|
+
if (!wrapper) { cb(); return; }
|
|
94
|
+
ensureFileReady(wrapper);
|
|
95
|
+
var body = wrapper.querySelector('.d2h-files-diff');
|
|
96
|
+
if (!body || !body.hasAttribute('data-lazy')) { cb(); return; }
|
|
97
|
+
var idx = (wrapper.id || '').replace('file-', '');
|
|
98
|
+
if (bodyPromise[idx]) { bodyPromise[idx].then(function () { cb(); }); return; }
|
|
99
|
+
cb();
|
|
100
|
+
}
|
|
101
|
+
function setupLazyDiff() {
|
|
102
|
+
var container = document.getElementById('diff2html-container');
|
|
103
|
+
if (!container) return;
|
|
104
|
+
var wrappers = Array.prototype.slice.call(container.querySelectorAll('.d2h-file-wrapper'));
|
|
105
|
+
if (typeof IntersectionObserver !== 'undefined') {
|
|
106
|
+
var io = new IntersectionObserver(function (entries) {
|
|
107
|
+
entries.forEach(function (e) { if (e.isIntersecting) { ensureFileReady(e.target); io.unobserve(e.target); } });
|
|
108
|
+
}, { root: null, rootMargin: '600px 0px' });
|
|
109
|
+
wrappers.forEach(function (w) { io.observe(w); });
|
|
110
|
+
} else {
|
|
111
|
+
wrappers.forEach(function (w) { ensureFileReady(w); }); // no IntersectionObserver -> materialize all
|
|
112
|
+
}
|
|
113
|
+
if (wrappers[0]) ensureFileReady(wrappers[0]); // first file ready so the initial caret has a row to land on
|
|
114
|
+
}
|
|
115
|
+
if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
|
|
116
|
+
const links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
|
|
117
|
+
let sourceLinks = Array.from(document.querySelectorAll('.source-link')); // re-captured when a deferred tree materializes
|
|
118
|
+
const sourceFiles = JSON.parse(document.getElementById('source-files-data')?.textContent || '[]');
|
|
119
|
+
// i18n: the message catalog (en + ko) is emitted server-side; the locale lives in localStorage and the
|
|
120
|
+
// whole UI switches live (no reload). t() feeds dynamically-built text; applyI18n() rewrites the static
|
|
121
|
+
// chrome (data-i18n / -ph / -title / -aria). English is the first-paint default.
|
|
122
|
+
var I18N = JSON.parse(document.getElementById('i18n-data')?.textContent || '{}');
|
|
123
|
+
// Cross-reopen persistence. Electron persists via the main process (window.monacoriSettings — survives
|
|
124
|
+
// app restart; file:// localStorage doesn't); browser/serve falls back to localStorage. persistRead
|
|
125
|
+
// returns the bridge value (native) if present, else undefined so callers parse localStorage themselves.
|
|
126
|
+
function persistRead(key) {
|
|
127
|
+
try { if (window.monacoriSettings && window.monacoriSettings.all && key in window.monacoriSettings.all) return window.monacoriSettings.all[key]; } catch (e) {}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
function persistSave(key, value) {
|
|
131
|
+
try { localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value)); } catch (e) {}
|
|
132
|
+
try { if (window.monacoriSettings) window.monacoriSettings.set(key, value); } catch (e2) {}
|
|
133
|
+
}
|
|
134
|
+
var LOCALE_KEY = 'monacori-locale';
|
|
135
|
+
var locale = (function () {
|
|
136
|
+
var v = persistRead(LOCALE_KEY);
|
|
137
|
+
if (v !== 'ko' && v !== 'en') { try { v = localStorage.getItem(LOCALE_KEY); } catch (e) {} }
|
|
138
|
+
return (v === 'ko' || v === 'en') ? v : 'en';
|
|
139
|
+
})();
|
|
140
|
+
function t(key) { var m = (I18N[locale] || I18N.en || {}); return (m && key in m) ? m[key] : ((I18N.en && I18N.en[key]) || key); }
|
|
141
|
+
function applyI18n() {
|
|
142
|
+
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = t(el.getAttribute('data-i18n')); });
|
|
143
|
+
document.querySelectorAll('[data-i18n-ph]').forEach(function (el) { el.setAttribute('placeholder', t(el.getAttribute('data-i18n-ph'))); });
|
|
144
|
+
document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.setAttribute('title', t(el.getAttribute('data-i18n-title'))); });
|
|
145
|
+
document.querySelectorAll('[data-i18n-aria]').forEach(function (el) { el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria'))); });
|
|
146
|
+
document.documentElement.lang = locale;
|
|
147
|
+
var sel = document.getElementById('settings-language');
|
|
148
|
+
if (sel) sel.value = locale;
|
|
149
|
+
}
|
|
150
|
+
const fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
|
|
151
|
+
const httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
|
|
152
|
+
const httpEnvNames = Object.keys(httpEnvironments);
|
|
153
|
+
const httpEnvKey = 'monacori-http-env:' + location.pathname;
|
|
154
|
+
const httpRequestsByPath = new Map();
|
|
155
|
+
const httpVarsByPath = new Map();
|
|
156
|
+
const sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
|
|
157
|
+
// Phase 2b lazy-LOAD: source content is fetched once after first paint (serve /source-data or the
|
|
158
|
+
// Electron bridge) and merged into the metadata-only source records; until then sourceLoaded is false
|
|
159
|
+
// and the source view shows a brief loading state. Non-lazy-load modes embed source -> already loaded.
|
|
160
|
+
var sourceLoaded = !REVIEW_LAZY_LOAD;
|
|
161
|
+
var pendingSourceOpen = null;
|
|
162
|
+
var sourceLoading = false;
|
|
163
|
+
var pendingSymbol = null;
|
|
164
|
+
var sourceTabs = []; // Files-mode tab paths (session-only); see addSourceTab / renderSourceTabs.
|
|
165
|
+
// The source blob (content + image base64) is large on big repos, so lazy-LOAD fetches it lazily — on
|
|
166
|
+
// the first source-view open or go-to-definition — not eagerly at startup. Idempotent.
|
|
167
|
+
function loadSourceData() {
|
|
168
|
+
if (sourceLoaded || sourceLoading) return;
|
|
169
|
+
sourceLoading = true;
|
|
170
|
+
var p;
|
|
171
|
+
if (typeof window !== 'undefined' && window.monacoriFile && typeof window.monacoriFile.getSourceData === 'function') {
|
|
172
|
+
p = Promise.resolve().then(function () { return window.monacoriFile.getSourceData(); });
|
|
173
|
+
} else if (typeof fetch !== 'undefined') {
|
|
174
|
+
p = fetch('source-data').then(function (r) { return r.ok ? r.text() : '[]'; });
|
|
175
|
+
} else {
|
|
176
|
+
p = Promise.resolve('[]');
|
|
177
|
+
}
|
|
178
|
+
p.then(function (text) {
|
|
179
|
+
var data = [];
|
|
180
|
+
try { data = JSON.parse(text || '[]'); } catch (e) { data = []; }
|
|
181
|
+
for (var i = 0; i < data.length; i++) {
|
|
182
|
+
var existing = sourceByPath.get(data[i].path);
|
|
183
|
+
if (existing) { existing.content = data[i].content; if (data[i].image) existing.image = data[i].image; }
|
|
184
|
+
}
|
|
185
|
+
sourceLoaded = true;
|
|
186
|
+
sourceLoading = false;
|
|
187
|
+
try { startSymbolIndex(); } catch (e) {}
|
|
188
|
+
if (pendingSourceOpen) { var po = pendingSourceOpen; pendingSourceOpen = null; openSourceFile(po.path, po.shouldSwitch); }
|
|
189
|
+
else if (isSourceViewerVisible() && document.getElementById('source-viewer').dataset.openPath) { openSourceFile(document.getElementById('source-viewer').dataset.openPath, false); }
|
|
190
|
+
if (pendingSymbol) { var s = pendingSymbol; pendingSymbol = null; goToDefOrUsages(s); }
|
|
191
|
+
}, function () { sourceLoaded = true; sourceLoading = false; });
|
|
192
|
+
}
|
|
193
|
+
const fileSignatureByPath = new Map(fileStates.map((file) => [file.path, file.signature]));
|
|
194
|
+
const reviewMeta = document.getElementById('review-meta');
|
|
195
|
+
const watchEnabled = reviewMeta?.dataset.watch === 'true';
|
|
196
|
+
const currentSignature = reviewMeta?.dataset.signature || '';
|
|
197
|
+
const uiStateKey = 'monacori-diff-ui:' + location.pathname;
|
|
198
|
+
const recentKey = 'monacori-diff-recent:' + location.pathname;
|
|
199
|
+
const viewedKey = 'monacori-diff-viewed:' + location.pathname;
|
|
200
|
+
const quickOpen = document.getElementById('quick-open');
|
|
201
|
+
const quickInput = document.getElementById('quick-open-input');
|
|
202
|
+
const quickResults = document.getElementById('quick-open-results');
|
|
203
|
+
const quickModeLabel = document.getElementById('quick-open-mode');
|
|
204
|
+
let current = -1;
|
|
205
|
+
let checkingForUpdates = false;
|
|
206
|
+
let lastShiftAt = 0;
|
|
207
|
+
let lastShiftSide = 0;
|
|
208
|
+
let quickMode = 'all';
|
|
209
|
+
let quickItems = [];
|
|
210
|
+
let quickActive = 0;
|
|
211
|
+
let usageItems = []; // find-usages results for the Cmd+B-on-declaration popup
|
|
212
|
+
let usageActive = 0;
|
|
213
|
+
let viewerCursor = null;
|
|
214
|
+
let selectedCommentRow = null; // a comment box "selected" while navigating with arrows (caret hidden); Backspace deletes it
|
|
215
|
+
let currentHttpEnvName = (function () {
|
|
216
|
+
let saved = '';
|
|
217
|
+
try { saved = localStorage.getItem(httpEnvKey) || ''; } catch (error) { saved = ''; }
|
|
218
|
+
if (saved && httpEnvNames.indexOf(saved) >= 0) return saved;
|
|
219
|
+
return httpEnvNames.length ? httpEnvNames[0] : '';
|
|
220
|
+
})();
|
|
221
|
+
let treeFocusIndex = -1;
|
|
222
|
+
let selectionAnchor = null;
|
|
223
|
+
let diffCursor = null; // { path, side: 'old'|'new', rowIndex, column } — keyboard caret in the side-by-side diff
|
|
224
|
+
// Cursor-position history for Cmd/Ctrl+[ (back) and Cmd/Ctrl+] (forward), IDE-style.
|
|
225
|
+
let navList = [];
|
|
226
|
+
let navPos = -1;
|
|
227
|
+
let navSuppress = false;
|
|
228
|
+
var NAV_JUMP_LINES = 8;
|
|
229
|
+
var NAV_MAX = 60;
|
|
230
|
+
let diffSelectionAnchor = null; // { side, rowIndex, column } — Shift+Arrow drag-select origin in the diff
|
|
231
|
+
let measuredCharWidth = 0;
|
|
232
|
+
|
|
233
|
+
// Review-comment state — initialized here (early) so saved comments are loaded before
|
|
234
|
+
// restoreUiState()/openDefaultSourceFile() run on startup and try to render them.
|
|
235
|
+
var COMMENTS_KEY = 'monacori-comments:' + location.pathname;
|
|
236
|
+
var reviewComments = [];
|
|
237
|
+
reviewComments = (function () { var b = persistRead(COMMENTS_KEY); if (Array.isArray(b)) return b; try { return JSON.parse(localStorage.getItem(COMMENTS_KEY) || '[]'); } catch (commentsErr) { return []; } })();
|
|
238
|
+
if (!Array.isArray(reviewComments)) reviewComments = [];
|
|
239
|
+
var commentSeq = reviewComments.reduce(function (max, c) { return Math.max(max, c.seq || 0); }, 0);
|
|
240
|
+
var composerState = null;
|
|
241
|
+
|
|
242
|
+
function prepareDiff2HtmlHunks() {
|
|
243
|
+
const wrappers = Array.from(document.querySelectorAll('.d2h-file-wrapper'));
|
|
244
|
+
let globalHunkIndex = 0;
|
|
245
|
+
wrappers.forEach((wrapper, fileIndex) => {
|
|
246
|
+
wrapper.id = 'file-' + fileIndex;
|
|
247
|
+
const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
|
|
248
|
+
const headerToIndex = new Map();
|
|
249
|
+
const rows = Array.from(wrapper.querySelectorAll('tr'));
|
|
250
|
+
rows.forEach((row) => {
|
|
251
|
+
const header = row.textContent.trim();
|
|
252
|
+
if (!header.startsWith('@@')) return;
|
|
253
|
+
let index = headerToIndex.get(header);
|
|
254
|
+
if (index === undefined) {
|
|
255
|
+
index = globalHunkIndex;
|
|
256
|
+
headerToIndex.set(header, index);
|
|
257
|
+
row.classList.add('hunk');
|
|
258
|
+
row.id = 'hunk-' + index;
|
|
259
|
+
globalHunkIndex += 1;
|
|
260
|
+
} else {
|
|
261
|
+
row.classList.add('hunk-peer');
|
|
262
|
+
}
|
|
263
|
+
row.dataset.hunkIndex = String(index);
|
|
264
|
+
row.dataset.file = fileName;
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
prepareViewedControls();
|
|
270
|
+
|
|
271
|
+
function prepareViewedControls() {
|
|
272
|
+
pruneViewedState();
|
|
273
|
+
document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
|
|
274
|
+
const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
|
|
275
|
+
const toggle = wrapper.querySelector('.d2h-file-collapse');
|
|
276
|
+
const input = toggle?.querySelector('input');
|
|
277
|
+
if (!fileName || !toggle || !input) return;
|
|
278
|
+
toggle.title = t('btn.viewed.title');
|
|
279
|
+
input.tabIndex = -1;
|
|
280
|
+
toggle.addEventListener('click', (event) => {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
setFileViewed(fileName, !isFileViewed(fileName));
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
applyViewedState();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function loadViewedState() {
|
|
289
|
+
try {
|
|
290
|
+
const value = JSON.parse(localStorage.getItem(viewedKey) || '{}');
|
|
291
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
292
|
+
} catch {
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function saveViewedState(value) {
|
|
298
|
+
try {
|
|
299
|
+
localStorage.setItem(viewedKey, JSON.stringify(value));
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function currentFileSignature(path) {
|
|
304
|
+
return fileSignatureByPath.get(path) || '';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isFileViewed(path) {
|
|
308
|
+
const viewed = loadViewedState();
|
|
309
|
+
const signature = currentFileSignature(path);
|
|
310
|
+
return Boolean(signature && viewed[path] === signature);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function setFileViewed(path, viewed) {
|
|
314
|
+
const state = loadViewedState();
|
|
315
|
+
if (viewed) {
|
|
316
|
+
const signature = currentFileSignature(path);
|
|
317
|
+
if (signature) state[path] = signature;
|
|
318
|
+
} else {
|
|
319
|
+
delete state[path];
|
|
320
|
+
}
|
|
321
|
+
saveViewedState(state);
|
|
322
|
+
applyViewedState();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function pruneViewedState() {
|
|
326
|
+
const state = loadViewedState();
|
|
327
|
+
let changed = false;
|
|
328
|
+
Object.keys(state).forEach((path) => {
|
|
329
|
+
if (state[path] !== currentFileSignature(path)) {
|
|
330
|
+
delete state[path];
|
|
331
|
+
changed = true;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
if (changed) saveViewedState(state);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function applyViewedState() {
|
|
338
|
+
document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
|
|
339
|
+
const fileName = wrapper.querySelector('.d2h-file-name')?.textContent?.trim() || '';
|
|
340
|
+
const viewed = isFileViewed(fileName);
|
|
341
|
+
wrapper.classList.toggle('file-viewed', viewed);
|
|
342
|
+
const checkbox = wrapper.querySelector('.d2h-file-collapse-input');
|
|
343
|
+
if (checkbox) checkbox.checked = viewed;
|
|
344
|
+
});
|
|
345
|
+
// Viewed is a diff-review concept: only the Changes list shows it, not the Files/source tree.
|
|
346
|
+
links.forEach((link) => {
|
|
347
|
+
link.classList.toggle('viewed', isFileViewed(link.dataset.file || ''));
|
|
348
|
+
});
|
|
349
|
+
updateDiffViewedToggle();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The diff file header is merged into the toolbar; this reflects the active file's viewed state there.
|
|
353
|
+
function updateDiffViewedToggle() {
|
|
354
|
+
var btn = document.getElementById('diff-viewed-toggle');
|
|
355
|
+
if (!btn) return;
|
|
356
|
+
var path = btn.dataset.file || '';
|
|
357
|
+
var known = Boolean(path && currentFileSignature(path));
|
|
358
|
+
btn.hidden = !known;
|
|
359
|
+
if (!known) return;
|
|
360
|
+
var viewed = isFileViewed(path);
|
|
361
|
+
btn.classList.toggle('is-viewed', viewed);
|
|
362
|
+
btn.setAttribute('aria-pressed', viewed ? 'true' : 'false');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let activeDiffRow = null;
|
|
366
|
+
function firstCodeRowOfHunk(hunkRow) {
|
|
367
|
+
let row = hunkRow.nextElementSibling;
|
|
368
|
+
let firstRow = null;
|
|
369
|
+
while (row && !row.classList.contains('hunk') && !row.classList.contains('hunk-peer')) {
|
|
370
|
+
if (row.querySelector && row.querySelector('.d2h-code-side-line')) {
|
|
371
|
+
if (!firstRow) firstRow = row;
|
|
372
|
+
if (row.querySelector('.d2h-ins, .d2h-del, ins, del')) return row;
|
|
373
|
+
}
|
|
374
|
+
row = row.nextElementSibling;
|
|
375
|
+
}
|
|
376
|
+
return firstRow || hunkRow;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// First row in a hunk to land the caret on. F7 should track the NEW (right) file, so prefer the
|
|
380
|
+
// first change on the new side anywhere in the hunk (additions / modifications) and only fall back
|
|
381
|
+
// to the old side for a pure-deletion hunk that has nothing on the new side. The .hunk marker sits
|
|
382
|
+
// on the OLD side and the two side tables are positionally aligned row-for-row, so the new-side row
|
|
383
|
+
// at the same index is the counterpart. Without this, a hunk that begins with deletions lands the
|
|
384
|
+
// caret on the old-side deletion instead of the added lines below it.
|
|
385
|
+
function isChangeCodeRow(row) {
|
|
386
|
+
return !!(row && isDiffCodeRow(row) && row.querySelector('.d2h-ins, .d2h-del, ins, del'));
|
|
387
|
+
}
|
|
388
|
+
function firstChangeRowForCaret(hunkRow) {
|
|
389
|
+
const wrapper = hunkRow.closest('.d2h-file-wrapper');
|
|
390
|
+
const sides = wrapper ? wrapper.querySelectorAll('.d2h-file-side-diff') : [];
|
|
391
|
+
const hunkSideEl = hunkRow.closest('.d2h-file-side-diff');
|
|
392
|
+
if (sides.length >= 2 && hunkSideEl) {
|
|
393
|
+
const hunkRows = Array.from(hunkSideEl.querySelectorAll('tr')); // old side (carries the .hunk marker)
|
|
394
|
+
const otherEl = hunkSideEl === sides[0] ? sides[1] : sides[0]; // new side
|
|
395
|
+
const otherRows = Array.from(otherEl.querySelectorAll('tr'));
|
|
396
|
+
let fallbackOld = null;
|
|
397
|
+
for (let i = hunkRows.indexOf(hunkRow) + 1; i < hunkRows.length; i++) {
|
|
398
|
+
const hr = hunkRows[i];
|
|
399
|
+
if (hr.classList.contains('hunk') || hr.classList.contains('hunk-peer')) break;
|
|
400
|
+
if (isChangeCodeRow(otherRows[i])) return otherRows[i]; // first new-side change wins (track the new file)
|
|
401
|
+
if (fallbackOld === null && isChangeCodeRow(hr)) fallbackOld = hr; // remember the first old-side change
|
|
402
|
+
}
|
|
403
|
+
if (fallbackOld) return fallbackOld; // pure-deletion hunk: nothing added, so land on the deletion
|
|
404
|
+
}
|
|
405
|
+
return firstCodeRowOfHunk(hunkRow);
|
|
406
|
+
}
|
|
407
|
+
function focusDiffRow(row) {
|
|
408
|
+
if (activeDiffRow) activeDiffRow.classList.remove('diff-active-row');
|
|
409
|
+
activeDiffRow = row || null;
|
|
410
|
+
if (!row) return;
|
|
411
|
+
row.classList.add('diff-active-row');
|
|
412
|
+
// move the diff caret to follow hunk navigation (F7 / Shift+F7 / [ / ])
|
|
413
|
+
const navInfo = diffRowInfoFromNode(row);
|
|
414
|
+
if (navInfo && navInfo.path) {
|
|
415
|
+
let navSide = navInfo.side;
|
|
416
|
+
if (navSide === 'old') { // prefer the new (modified) side when it has a real line at this row
|
|
417
|
+
const navWrap = diffWrapperByPath(navInfo.path);
|
|
418
|
+
if (isDiffCodeRow(navWrap ? diffRowAt(navWrap, 'new', navInfo.rowIndex) : null)) navSide = 'new';
|
|
419
|
+
}
|
|
420
|
+
setDiffCursor(navInfo.path, navSide, navInfo.rowIndex, 0, false);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function renderBreadcrumb(container, path) {
|
|
425
|
+
if (!container) return;
|
|
426
|
+
container.textContent = '';
|
|
427
|
+
const parts = (path || '').split('/').filter(Boolean);
|
|
428
|
+
parts.forEach((seg, i) => {
|
|
429
|
+
if (i > 0) {
|
|
430
|
+
const sep = document.createElement('span');
|
|
431
|
+
sep.className = 'crumb-sep';
|
|
432
|
+
sep.textContent = '›';
|
|
433
|
+
container.appendChild(sep);
|
|
434
|
+
}
|
|
435
|
+
const span = document.createElement('span');
|
|
436
|
+
span.className = i === parts.length - 1 ? 'crumb crumb-leaf' : 'crumb';
|
|
437
|
+
span.textContent = seg;
|
|
438
|
+
container.appendChild(span);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Coalesce diff-nav scrolls: hammering F7 / [ / ] schedules at most one
|
|
443
|
+
// scrollIntoView per frame (to the latest target) instead of forcing a
|
|
444
|
+
// synchronous reflow on every keystroke.
|
|
445
|
+
var pendingDiffScrollRow = null;
|
|
446
|
+
var diffScrollRaf = 0;
|
|
447
|
+
function scheduleDiffScroll(row) {
|
|
448
|
+
pendingDiffScrollRow = row || null;
|
|
449
|
+
if (diffScrollRaf) return;
|
|
450
|
+
diffScrollRaf = requestAnimationFrame(function () {
|
|
451
|
+
diffScrollRaf = 0;
|
|
452
|
+
var r = pendingDiffScrollRow;
|
|
453
|
+
pendingDiffScrollRow = null;
|
|
454
|
+
if (r && r.scrollIntoView) r.scrollIntoView({ block: 'center' });
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function setActive(index, shouldScroll = true) {
|
|
459
|
+
if (hunkTotal() === 0) return;
|
|
460
|
+
current = ((index % hunkTotal()) + hunkTotal()) % hunkTotal();
|
|
461
|
+
document.getElementById('source-viewer')?.classList.add('hidden');
|
|
462
|
+
document.getElementById('diff-view')?.classList.remove('hidden');
|
|
463
|
+
setTab('changes');
|
|
464
|
+
const file = hunkPathAt(current);
|
|
465
|
+
const idx = current;
|
|
466
|
+
links.forEach((link) => link.classList.toggle('active', link.dataset.file === file));
|
|
467
|
+
renderBreadcrumb(document.getElementById('diff-breadcrumb'), file);
|
|
468
|
+
var dvt = document.getElementById('diff-viewed-toggle');
|
|
469
|
+
if (dvt) dvt.dataset.file = file || '';
|
|
470
|
+
updateDiffViewedToggle();
|
|
471
|
+
if (file) rememberRecent(file, 'change');
|
|
472
|
+
history.replaceState(null, '', '#hunk-' + idx);
|
|
473
|
+
// Row-dependent work waits for the file body (sync for eager/Phase 1, async for cold lazy-LOAD).
|
|
474
|
+
whenFileReady(diffWrapperByPath(file), function () {
|
|
475
|
+
showOnlyFile(file);
|
|
476
|
+
const active = document.getElementById('hunk-' + idx);
|
|
477
|
+
if (!active) return;
|
|
478
|
+
if (REVIEW_LAZY) {
|
|
479
|
+
document.querySelectorAll('#diff2html-container .hunk.active, #diff2html-container .hunk-peer.active').forEach((h) => h.classList.remove('active'));
|
|
480
|
+
document.querySelectorAll('#diff2html-container [data-hunk-index="' + idx + '"]').forEach((h) => h.classList.add('active'));
|
|
481
|
+
} else {
|
|
482
|
+
hunks.forEach((hunk, i) => hunk.classList.toggle('active', i === idx));
|
|
483
|
+
hunkPeers.forEach((hunk) => hunk.classList.toggle('active', Number(hunk.dataset.hunkIndex) === idx));
|
|
484
|
+
}
|
|
485
|
+
const targetRow = firstChangeRowForCaret(active);
|
|
486
|
+
// F7/change navigation moves the caret but must NOT pollute the Cmd+[/] cursor history.
|
|
487
|
+
navSuppress = true;
|
|
488
|
+
try { focusDiffRow(targetRow); } finally { navSuppress = false; }
|
|
489
|
+
if (shouldScroll && targetRow) scheduleDiffScroll(targetRow);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function showOnlyFile(fileName) {
|
|
494
|
+
if (REVIEW_LAZY) ensureFileReady(diffWrapperByPath(fileName));
|
|
495
|
+
document.querySelectorAll('.d2h-file-wrapper').forEach((wrapper) => {
|
|
496
|
+
wrapper.classList.toggle('df-inactive', diffWrapperPathKey(wrapper) !== fileName);
|
|
497
|
+
});
|
|
498
|
+
ensureDiffCursor();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// The hunk the diff caret currently sits in. Arrow keys move the caret without touching the active
|
|
502
|
+
// index (the F7 anchor), so navigation must read the caret's real position -- otherwise pressing F7
|
|
503
|
+
// after arrowing to the bottom of a file re-treads hunks already passed instead of going to the next file.
|
|
504
|
+
function hunkIndexAtCaret() {
|
|
505
|
+
if (!diffCursor) return -1;
|
|
506
|
+
const wrapper = diffWrapperByPath(diffCursor.path);
|
|
507
|
+
if (!wrapper) return -1;
|
|
508
|
+
const caretRow = diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex);
|
|
509
|
+
const sideEl = caretRow ? caretRow.closest('.d2h-file-side-diff') : null;
|
|
510
|
+
if (!sideEl) return -1;
|
|
511
|
+
let found = -1;
|
|
512
|
+
// @@ markers on the caret's side carry data-hunk-index; the nearest one at or above the caret wins.
|
|
513
|
+
sideEl.querySelectorAll('[data-hunk-index]').forEach((marker) => {
|
|
514
|
+
if (marker === caretRow || (caretRow.compareDocumentPosition(marker) & Node.DOCUMENT_POSITION_PRECEDING)) {
|
|
515
|
+
found = Number(marker.dataset.hunkIndex);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
return found;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// New-side row indices, one per change block — a run of change rows (ins/del) separated by context.
|
|
522
|
+
// A wide context window merges several edits into one @@ hunk; stepping by these stops at each edit.
|
|
523
|
+
function changeBlockAnchors(wrapper) {
|
|
524
|
+
if (!wrapper) return [];
|
|
525
|
+
if (wrapper.__anchors) return wrapper.__anchors;
|
|
526
|
+
var right = diffSideTables(wrapper).right;
|
|
527
|
+
if (!right) return []; // body not materialized yet — don't cache an empty result
|
|
528
|
+
var rows = diffRowsOf(right);
|
|
529
|
+
var anchors = [];
|
|
530
|
+
var prev = false;
|
|
531
|
+
for (var i = 0; i < rows.length; i++) {
|
|
532
|
+
var chg = isChangeCodeRow(rows[i]);
|
|
533
|
+
if (chg && !prev) anchors.push(i);
|
|
534
|
+
prev = chg;
|
|
535
|
+
}
|
|
536
|
+
wrapper.__anchors = anchors; // change-block layout is static once materialized
|
|
537
|
+
return anchors;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function next(delta) {
|
|
541
|
+
if (hunkTotal() === 0) return;
|
|
542
|
+
// Within the caret's (unviewed) file, step change-block by change-block so a context-merged hunk
|
|
543
|
+
// (several separate edits under one @@) stops at every edit instead of skipping to the next file.
|
|
544
|
+
if (diffCursor && isDiffViewVisible()) {
|
|
545
|
+
const w = diffWrapperByPath(diffCursor.path);
|
|
546
|
+
if (w && !isFileViewed(diffCursor.path)) {
|
|
547
|
+
const anchors = changeBlockAnchors(w);
|
|
548
|
+
const cur = diffCursor.rowIndex;
|
|
549
|
+
let target = null;
|
|
550
|
+
if (delta > 0) { for (let a = 0; a < anchors.length; a++) { if (anchors[a] > cur) { target = anchors[a]; break; } } }
|
|
551
|
+
else { for (let b = anchors.length - 1; b >= 0; b--) { if (anchors[b] < cur) { target = anchors[b]; break; } } }
|
|
552
|
+
if (target != null) {
|
|
553
|
+
const row = diffRowAt(w, 'new', target);
|
|
554
|
+
if (row) { navSuppress = true; try { focusDiffRow(row); } finally { navSuppress = false; } scheduleDiffScroll(row); return; }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// File boundary (no more change blocks this file) → hunk-level nav to the next/prev unviewed file.
|
|
559
|
+
const caretHunk = hunkIndexAtCaret();
|
|
560
|
+
const base = caretHunk >= 0 ? caretHunk : current;
|
|
561
|
+
let idx = base < 0 ? initialHunkForNavigation(delta) : base + delta;
|
|
562
|
+
for (let step = 0; step < hunkTotal(); step++) {
|
|
563
|
+
const norm = ((idx % hunkTotal()) + hunkTotal()) % hunkTotal();
|
|
564
|
+
if (!isFileViewed(hunkPathAt(norm) || '')) { setActive(norm); return; }
|
|
565
|
+
idx += delta;
|
|
566
|
+
}
|
|
567
|
+
// Every changed file is marked viewed — nothing left to review, so F7/[/] stay put.
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function initialHunkForNavigation(delta) {
|
|
571
|
+
const openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
|
|
572
|
+
const sourceHunk = firstHunkForPath(openPath);
|
|
573
|
+
if (sourceHunk >= 0) return sourceHunk;
|
|
574
|
+
return delta < 0 ? hunkTotal() - 1 : 0;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function firstHunkForPath(path) {
|
|
578
|
+
if (!path) return -1;
|
|
579
|
+
const link = links.find((candidate) => candidate.dataset.file === path);
|
|
580
|
+
if (!link) return -1;
|
|
581
|
+
const index = Number(link.dataset.hunk);
|
|
582
|
+
return Number.isNaN(index) ? -1 : index;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function openQuickOpen(mode) {
|
|
586
|
+
if (!quickOpen || !quickInput || !quickModeLabel) return;
|
|
587
|
+
quickMode = mode;
|
|
588
|
+
quickModeLabel.textContent = mode === 'recent' ? t('quickopen.recent') : mode === 'content' ? t('quickopen.findInFiles') : t('quickopen.searchFiles');
|
|
589
|
+
quickOpen.classList.remove('hidden');
|
|
590
|
+
quickInput.value = '';
|
|
591
|
+
renderQuickOpenResults();
|
|
592
|
+
setTimeout(() => quickInput.focus(), 0);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function closeQuickOpen() {
|
|
596
|
+
quickOpen?.classList.add('hidden');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function handleQuickOpenKey(event) {
|
|
600
|
+
if (event.key === 'Escape') {
|
|
601
|
+
event.preventDefault();
|
|
602
|
+
closeQuickOpen();
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
if (event.key === 'ArrowDown') {
|
|
606
|
+
event.preventDefault();
|
|
607
|
+
quickActive = Math.min(quickActive + 1, Math.max(quickItems.length - 1, 0));
|
|
608
|
+
updateQuickActive();
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
if (event.key === 'ArrowUp') {
|
|
612
|
+
event.preventDefault();
|
|
613
|
+
quickActive = Math.max(quickActive - 1, 0);
|
|
614
|
+
updateQuickActive();
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
if (event.key === 'Enter') {
|
|
618
|
+
event.preventDefault();
|
|
619
|
+
openQuickItem(quickItems[quickActive]);
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function renderQuickOpenResults() {
|
|
626
|
+
if (!quickResults) return;
|
|
627
|
+
const query = quickInput?.value.trim().toLowerCase() || '';
|
|
628
|
+
const candidates = quickMode === 'recent' && query.length === 0 ? recentItems() : allQuickItems();
|
|
629
|
+
quickItems = candidates
|
|
630
|
+
.filter((item) => quickMode !== 'recent' || query.length > 0 || item.recent)
|
|
631
|
+
.filter((item) => {
|
|
632
|
+
if (query.length === 0) return true;
|
|
633
|
+
if (quickMode === 'content') {
|
|
634
|
+
const file = sourceByPath.get(item.path);
|
|
635
|
+
return Boolean(file && file.embedded && file.content.toLowerCase().includes(query));
|
|
636
|
+
}
|
|
637
|
+
return (item.path + '\n' + item.name + '\n' + item.detail).toLowerCase().includes(query);
|
|
638
|
+
})
|
|
639
|
+
.sort((a, b) => scoreQuickItem(a, query) - scoreQuickItem(b, query) || a.path.localeCompare(b.path))
|
|
640
|
+
.slice(0, 80);
|
|
641
|
+
quickActive = Math.min(quickActive, Math.max(quickItems.length - 1, 0));
|
|
642
|
+
if (quickItems.length === 0) {
|
|
643
|
+
quickResults.innerHTML = '<div class="quick-open-empty">' + escapeHtml(t('quickopen.noFiles')) + '</div>';
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
quickResults.innerHTML = quickItems.map((item, index) => [
|
|
647
|
+
'<button type="button" class="quick-open-item' + (index === quickActive ? ' active' : '') + '" data-index="' + index + '">',
|
|
648
|
+
'<span class="quick-open-main">',
|
|
649
|
+
'<span class="quick-open-name">' + escapeHtml(item.name) + '</span>',
|
|
650
|
+
'<span class="quick-open-path">' + escapeHtml(item.path) + '</span>',
|
|
651
|
+
'</span>',
|
|
652
|
+
'<span class="quick-open-badge">' + escapeHtml(item.detail) + '</span>',
|
|
653
|
+
'</button>',
|
|
654
|
+
].join('')).join('');
|
|
655
|
+
renderQuickPreview(quickItems[quickActive]);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function updateQuickActive() {
|
|
659
|
+
quickResults?.querySelectorAll('.quick-open-item').forEach((element, index) => {
|
|
660
|
+
const active = index === quickActive;
|
|
661
|
+
element.classList.toggle('active', active);
|
|
662
|
+
if (active) element.scrollIntoView({ block: 'nearest' });
|
|
663
|
+
});
|
|
664
|
+
renderQuickPreview(quickItems[quickActive]);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function renderQuickPreview(item) {
|
|
668
|
+
const preview = document.getElementById('quick-open-preview');
|
|
669
|
+
if (!preview) return;
|
|
670
|
+
if (!item) { preview.innerHTML = ''; return; }
|
|
671
|
+
const file = sourceByPath.get(item.path);
|
|
672
|
+
if (!file || !file.embedded) {
|
|
673
|
+
preview.innerHTML = '<div class="qp-empty">' + escapeHtml(item.path) + '</div>';
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const query = ((quickInput && quickInput.value) || '').trim().toLowerCase();
|
|
677
|
+
const lines = file.content.split(/\r?\n/);
|
|
678
|
+
let firstHit = -1;
|
|
679
|
+
const rows = lines.map((line, i) => {
|
|
680
|
+
const hit = query.length > 0 && line.toLowerCase().includes(query);
|
|
681
|
+
if (hit && firstHit < 0) firstHit = i;
|
|
682
|
+
return '<div class="qp-line' + (hit ? ' qp-hit' : '') + '"><span class="qp-num">' + (i + 1) + '</span><span class="qp-code">' + highlightLine(line, file.language || 'text') + '</span></div>';
|
|
683
|
+
}).join('');
|
|
684
|
+
preview.innerHTML = '<div class="qp-head">' + escapeHtml(item.path) + '</div><div class="qp-body">' + rows + '</div>';
|
|
685
|
+
if (firstHit >= 0) {
|
|
686
|
+
const target = preview.querySelectorAll('.qp-line')[firstHit];
|
|
687
|
+
if (target) target.scrollIntoView({ block: 'center' });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function openQuickItem(item) {
|
|
692
|
+
if (!item) return;
|
|
693
|
+
closeQuickOpen();
|
|
694
|
+
rememberRecent(item.path, item.kind);
|
|
695
|
+
if (sourceByPath.has(item.path)) {
|
|
696
|
+
openSourceFile(item.path);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const link = links.find((candidate) => candidate.dataset.file === item.path);
|
|
700
|
+
if (!link) return;
|
|
701
|
+
const target = Number(link.dataset.hunk);
|
|
702
|
+
if (!Number.isNaN(target) && target >= 0 && target < hunkTotal()) {
|
|
703
|
+
setActive(target);
|
|
704
|
+
} else {
|
|
705
|
+
showDiffView(false);
|
|
706
|
+
const targetId = link.getAttribute('href')?.slice(1);
|
|
707
|
+
if (targetId) document.getElementById(targetId)?.scrollIntoView({ block: 'center' });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function allQuickItems() {
|
|
712
|
+
const items = sourceFiles.map((file) => ({
|
|
713
|
+
path: file.path,
|
|
714
|
+
name: baseName(file.path),
|
|
715
|
+
detail: [file.changed ? 'changed' : 'file', file.language || 'text'].join(' - '),
|
|
716
|
+
kind: 'source',
|
|
717
|
+
recent: false,
|
|
718
|
+
}));
|
|
719
|
+
links.forEach((link) => {
|
|
720
|
+
const path = link.dataset.file || '';
|
|
721
|
+
if (!path || sourceByPath.has(path)) return;
|
|
722
|
+
items.push({ path, name: baseName(path), detail: 'diff', kind: 'change', recent: false });
|
|
723
|
+
});
|
|
724
|
+
const recent = loadRecent();
|
|
725
|
+
const recentRank = new Map(recent.map((item, index) => [item.path, index]));
|
|
726
|
+
return items.map((item) => ({
|
|
727
|
+
...item,
|
|
728
|
+
recent: recentRank.has(item.path),
|
|
729
|
+
recentRank: recentRank.get(item.path) ?? 9999,
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function recentItems() {
|
|
734
|
+
const all = allQuickItems();
|
|
735
|
+
const byPath = new Map(all.map((item) => [item.path, item]));
|
|
736
|
+
return loadRecent()
|
|
737
|
+
.map((item) => byPath.get(item.path) || {
|
|
738
|
+
path: item.path,
|
|
739
|
+
name: baseName(item.path),
|
|
740
|
+
detail: item.kind === 'change' ? 'diff' : 'file',
|
|
741
|
+
kind: item.kind,
|
|
742
|
+
recent: true,
|
|
743
|
+
recentRank: 0,
|
|
744
|
+
})
|
|
745
|
+
.map((item, index) => ({ ...item, recent: true, recentRank: index }));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function scoreQuickItem(item, query) {
|
|
749
|
+
let score = item.recentRank ?? 9999;
|
|
750
|
+
if (!query) return score;
|
|
751
|
+
const path = item.path.toLowerCase();
|
|
752
|
+
const name = item.name.toLowerCase();
|
|
753
|
+
if (name === query) score -= 3000;
|
|
754
|
+
else if (name.startsWith(query)) score -= 2000;
|
|
755
|
+
else if (path.includes('/' + query)) score -= 1000;
|
|
756
|
+
else if (path.includes(query)) score -= 500;
|
|
757
|
+
if (item.recent) score -= 100;
|
|
758
|
+
return score;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function loadRecent() {
|
|
762
|
+
try {
|
|
763
|
+
const value = JSON.parse(localStorage.getItem(recentKey) || '[]');
|
|
764
|
+
return Array.isArray(value) ? value.filter((item) => item && typeof item.path === 'string') : [];
|
|
765
|
+
} catch {
|
|
766
|
+
return [];
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function rememberRecent(path, kind) {
|
|
771
|
+
if (!path) return;
|
|
772
|
+
const next = [{ path, kind }, ...loadRecent().filter((item) => item.path !== path)].slice(0, 30);
|
|
773
|
+
try {
|
|
774
|
+
localStorage.setItem(recentKey, JSON.stringify(next));
|
|
775
|
+
} catch {}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function baseName(path) {
|
|
779
|
+
return String(path).split('/').filter(Boolean).pop() || String(path);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// A tree row is navigable only when it is actually visible — i.e. not tucked inside a collapsed
|
|
783
|
+
// <details> folder. getClientRects alone is unreliable here: Chromium keeps collapsed <details>
|
|
784
|
+
// content laid out (content-visibility), so its descendants still report rects. Walk the ancestor
|
|
785
|
+
// <details> and treat anything inside a closed one (other than its own summary) as hidden.
|
|
786
|
+
function isTreeRowVisible(el) {
|
|
787
|
+
var node = el;
|
|
788
|
+
while (node) {
|
|
789
|
+
var parent = node.parentElement;
|
|
790
|
+
if (!parent || parent.classList.contains('tab-panel')) return true;
|
|
791
|
+
if (parent.tagName === 'DETAILS' && !parent.open && node.tagName !== 'SUMMARY') return false;
|
|
792
|
+
node = parent;
|
|
793
|
+
}
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
function treeRows() {
|
|
797
|
+
const panel = document.querySelector('.tab-panel:not(.hidden)');
|
|
798
|
+
if (!panel) return [];
|
|
799
|
+
return Array.from(panel.querySelectorAll('summary, .file-link')).filter((el) => el.getClientRects().length > 0 && isTreeRowVisible(el));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function focusTree(index) {
|
|
803
|
+
const rows = treeRows();
|
|
804
|
+
if (rows.length === 0) return;
|
|
805
|
+
treeFocusIndex = Math.max(0, Math.min(rows.length - 1, index));
|
|
806
|
+
rows.forEach((row, i) => row.classList.toggle('tree-focus', i === treeFocusIndex));
|
|
807
|
+
const el = rows[treeFocusIndex];
|
|
808
|
+
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function clearTreeFocus() {
|
|
812
|
+
treeFocusIndex = -1;
|
|
813
|
+
document.querySelectorAll('.tree-focus').forEach((el) => el.classList.remove('tree-focus'));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Focus the tree row for the currently open file (source openPath, else the active diff file);
|
|
817
|
+
// falls back to the first row when nothing is open or no matching row exists.
|
|
818
|
+
function focusOpenFileInTree() {
|
|
819
|
+
const rows = treeRows();
|
|
820
|
+
if (rows.length === 0) return;
|
|
821
|
+
let openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
|
|
822
|
+
if (!openPath && typeof diffActiveWrapper === 'function') {
|
|
823
|
+
const w = diffActiveWrapper();
|
|
824
|
+
const n = w && w.querySelector('.d2h-file-name');
|
|
825
|
+
if (n && n.textContent) openPath = n.textContent.trim();
|
|
826
|
+
}
|
|
827
|
+
let idx = 0;
|
|
828
|
+
if (openPath) {
|
|
829
|
+
for (let i = 0; i < rows.length; i++) {
|
|
830
|
+
const ds = rows[i].dataset || {};
|
|
831
|
+
if (ds.sourceFile === openPath || ds.file === openPath) { idx = i; break; }
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
focusTree(idx);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function treePageSize() {
|
|
838
|
+
var scroller = document.querySelector('.sidebar-scroll');
|
|
839
|
+
var h = scroller ? scroller.clientHeight : 320;
|
|
840
|
+
return Math.max(1, Math.floor(h / 20) - 1); // ~20px per tree row, minus one for overlap
|
|
841
|
+
}
|
|
842
|
+
function treeOpenKey() { return 'monacori-tree-open:' + location.pathname; }
|
|
843
|
+
function loadTreeOpen() { try { return new Set(JSON.parse(sessionStorage.getItem(treeOpenKey()) || '[]')); } catch (e) { return new Set(); } }
|
|
844
|
+
function saveTreeOpen(set) { try { sessionStorage.setItem(treeOpenKey(), JSON.stringify(Array.from(set))); } catch (e) {} }
|
|
845
|
+
// Folders start collapsed. Restore the folders the user manually opened, plus reveal the open file's
|
|
846
|
+
// path. Toggle listeners attach AFTER the initial state so the auto-revealed path is not mistaken for
|
|
847
|
+
// a user-opened folder (keeping "collapsed by default" intact on the next load).
|
|
848
|
+
var treeRevealing = false; // true while opening folders programmatically, so those opens are not persisted
|
|
849
|
+
function persistTreeToggle(d) {
|
|
850
|
+
var set = loadTreeOpen();
|
|
851
|
+
var dir = d.dataset.dir || '';
|
|
852
|
+
if (d.open) set.add(dir); else set.delete(dir);
|
|
853
|
+
saveTreeOpen(set);
|
|
854
|
+
}
|
|
855
|
+
function initSourceTreeFolds() {
|
|
856
|
+
var dirs = Array.prototype.slice.call(document.querySelectorAll('.source-dir'));
|
|
857
|
+
if (!dirs.length) return;
|
|
858
|
+
var saved = loadTreeOpen();
|
|
859
|
+
var openPath = (document.getElementById('source-viewer') && document.getElementById('source-viewer').dataset.openPath) || '';
|
|
860
|
+
// Only USER toggles persist; the initial state below is applied under treeRevealing so the open
|
|
861
|
+
// file's revealed path stays transient (folders stay "collapsed by default" on the next load).
|
|
862
|
+
dirs.forEach(function (d) {
|
|
863
|
+
d.addEventListener('toggle', function () { if (!treeRevealing) persistTreeToggle(d); });
|
|
864
|
+
});
|
|
865
|
+
treeRevealing = true;
|
|
866
|
+
dirs.forEach(function (d) {
|
|
867
|
+
var dir = d.dataset.dir || '';
|
|
868
|
+
var reveal = openPath && (openPath === dir || openPath.indexOf(dir + '/') === 0);
|
|
869
|
+
d.open = saved.has(dir) || !!reveal;
|
|
870
|
+
});
|
|
871
|
+
setTimeout(function () { treeRevealing = false; }, 0);
|
|
872
|
+
}
|
|
873
|
+
// Expand a file's ancestor folders so it is visible in the tree (transient — not persisted), then
|
|
874
|
+
// scroll its row into view. Called whenever a source file opens (tree click, go-to-definition, etc.).
|
|
875
|
+
function revealTreeFor(path) {
|
|
876
|
+
if (!path) return;
|
|
877
|
+
treeRevealing = true;
|
|
878
|
+
document.querySelectorAll('.source-dir').forEach(function (d) {
|
|
879
|
+
var dir = d.dataset.dir || '';
|
|
880
|
+
if (dir && (path === dir || path.indexOf(dir + '/') === 0) && !d.open) d.open = true;
|
|
881
|
+
});
|
|
882
|
+
setTimeout(function () { treeRevealing = false; }, 0);
|
|
883
|
+
var active = document.querySelector('.source-link.active');
|
|
884
|
+
if (active && active.scrollIntoView) active.scrollIntoView({ block: 'nearest' });
|
|
885
|
+
}
|
|
886
|
+
function handleTreeKey(event) {
|
|
887
|
+
const rows = treeRows();
|
|
888
|
+
if (rows.length === 0) return false;
|
|
889
|
+
if (treeFocusIndex >= rows.length) treeFocusIndex = rows.length - 1;
|
|
890
|
+
const row = rows[treeFocusIndex];
|
|
891
|
+
const isFolder = row && row.tagName === 'SUMMARY';
|
|
892
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); focusTree(treeFocusIndex + 1); return true; }
|
|
893
|
+
if (event.key === 'ArrowUp') { event.preventDefault(); focusTree(treeFocusIndex - 1); return true; }
|
|
894
|
+
if (event.key === 'PageDown') { event.preventDefault(); focusTree(treeFocusIndex + treePageSize()); return true; }
|
|
895
|
+
if (event.key === 'PageUp') { event.preventDefault(); focusTree(treeFocusIndex - treePageSize()); return true; }
|
|
896
|
+
if (event.key === 'Enter') {
|
|
897
|
+
event.preventDefault();
|
|
898
|
+
if (row && row.classList.contains('file-link')) { row.click(); clearTreeFocus(); }
|
|
899
|
+
else if (isFolder && row.parentElement) row.parentElement.open = !row.parentElement.open;
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
if (event.key === 'ArrowRight') {
|
|
903
|
+
event.preventDefault();
|
|
904
|
+
if (isFolder && row.parentElement && !row.parentElement.open) row.parentElement.open = true;
|
|
905
|
+
else focusTree(treeFocusIndex + 1);
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
if (event.key === 'ArrowLeft') {
|
|
909
|
+
event.preventDefault();
|
|
910
|
+
if (isFolder && row.parentElement && row.parentElement.open) row.parentElement.open = false;
|
|
911
|
+
else focusTree(treeFocusIndex - 1);
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
if (event.key === 'Escape') { event.preventDefault(); clearTreeFocus(); return true; }
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
document.addEventListener('keydown', (event) => {
|
|
919
|
+
if (!quickOpen?.classList.contains('hidden')) {
|
|
920
|
+
if (handleQuickOpenKey(event)) return;
|
|
921
|
+
}
|
|
922
|
+
var usagesBox = document.getElementById('usages');
|
|
923
|
+
if (usagesBox && !usagesBox.classList.contains('hidden')) {
|
|
924
|
+
if (handleUsagesKey(event)) return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if ((event.metaKey || event.ctrlKey) && event.key === '1') {
|
|
928
|
+
event.preventDefault();
|
|
929
|
+
setTab('files');
|
|
930
|
+
focusOpenFileInTree();
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if ((event.metaKey || event.ctrlKey) && event.key === '0') {
|
|
934
|
+
event.preventDefault();
|
|
935
|
+
setTab('changes');
|
|
936
|
+
focusOpenFileInTree();
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Tab / Shift+Tab move the "cursor" horizontally between the left sidebar and the right content pane.
|
|
941
|
+
if (event.key === 'Tab') {
|
|
942
|
+
const activeEl = document.activeElement;
|
|
943
|
+
const inField = activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT');
|
|
944
|
+
if (!inField) {
|
|
945
|
+
event.preventDefault();
|
|
946
|
+
if (event.shiftKey) {
|
|
947
|
+
// In the diff view, Shift+Tab toggles the caret between the old/new panes (this change owns
|
|
948
|
+
// Shift+Tab L/R; plain arrows stay in-pane and Cmd/Ctrl+Arrows also cross — see diff nav).
|
|
949
|
+
if (isDiffViewVisible() && diffCursor) {
|
|
950
|
+
const tabSide = diffCursor.side === 'new' ? 'old' : 'new';
|
|
951
|
+
const tabWrap = diffWrapperByPath(diffCursor.path);
|
|
952
|
+
const tabRow = tabWrap ? diffRowAt(tabWrap, tabSide, diffCursor.rowIndex) : null;
|
|
953
|
+
if (isDiffCodeRow(tabRow)) setDiffCursor(diffCursor.path, tabSide, diffCursor.rowIndex, 0, true);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
focusTree(treeFocusIndex >= 0 ? treeFocusIndex : 0); // ← left: focus sidebar tree
|
|
957
|
+
} else {
|
|
958
|
+
clearTreeFocus(); // → right: hand focus back to the content pane (source caret / diff nav)
|
|
959
|
+
const openPath = document.getElementById('source-viewer')?.dataset.openPath || '';
|
|
960
|
+
if (isSourceViewerVisible() && openPath && (!viewerCursor || viewerCursor.path !== openPath)) {
|
|
961
|
+
setSourceCursor(openPath, viewerCursor ? viewerCursor.lineIndex : 0, 0, false, -1);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Merged comment views — see every saved comment of one kind at once + copy-all to paste into a prompt:
|
|
969
|
+
// Cmd/Ctrl+Shift+/ ("?") = all questions, Cmd/Ctrl+Shift+. (">") = all change-requests.
|
|
970
|
+
// Match the PHYSICAL key (event.code) so macOS/IME/layout never swallows the combo; fires in any focus.
|
|
971
|
+
if ((event.metaKey || event.ctrlKey) && (event.code === 'Slash' || event.code === 'Period' || event.key === '?' || event.key === '>')) {
|
|
972
|
+
event.preventDefault();
|
|
973
|
+
openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
// "?" = question, ">" = change-request composer on the current line/selection (no modifier).
|
|
977
|
+
if (!event.altKey && !event.metaKey && !event.ctrlKey && (event.key === '?' || event.key === '>')) {
|
|
978
|
+
const ce = document.activeElement;
|
|
979
|
+
const inEditable = ce && (ce.tagName === 'INPUT' || ce.tagName === 'TEXTAREA' || ce.tagName === 'SELECT');
|
|
980
|
+
if (!inEditable) {
|
|
981
|
+
event.preventDefault();
|
|
982
|
+
openComposer(event.key === '?' ? 'q' : 'c');
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// "<" (Shift+,) toggles "viewed" for the current file (source openPath, else active diff file).
|
|
988
|
+
if (!event.altKey && !event.metaKey && !event.ctrlKey && event.key === '<') {
|
|
989
|
+
const ce2 = document.activeElement;
|
|
990
|
+
const inEditable2 = ce2 && (ce2.tagName === 'INPUT' || ce2.tagName === 'TEXTAREA' || ce2.tagName === 'SELECT');
|
|
991
|
+
if (!inEditable2) {
|
|
992
|
+
let vp = isSourceViewerVisible() ? (document.getElementById('source-viewer')?.dataset.openPath || '') : '';
|
|
993
|
+
if (!vp && typeof diffActiveWrapper === 'function') {
|
|
994
|
+
const vw = diffActiveWrapper();
|
|
995
|
+
const vn = vw && vw.querySelector('.d2h-file-name');
|
|
996
|
+
if (vn && vn.textContent) vp = vn.textContent.trim();
|
|
997
|
+
}
|
|
998
|
+
if (vp && currentFileSignature(vp)) {
|
|
999
|
+
event.preventDefault();
|
|
1000
|
+
setFileViewed(vp, !isFileViewed(vp));
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Opt/Alt + Left/Right: word-wise caret jump (source or diff view).
|
|
1007
|
+
if (event.altKey && !event.metaKey && !event.ctrlKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
|
|
1008
|
+
var wae = document.activeElement;
|
|
1009
|
+
var wInField = wae && (wae.tagName === 'INPUT' || wae.tagName === 'TEXTAREA' || wae.tagName === 'SELECT');
|
|
1010
|
+
if (!wInField && treeFocusIndex < 0) {
|
|
1011
|
+
var wdir = event.key === 'ArrowRight' ? 1 : -1;
|
|
1012
|
+
if (isSourceViewerVisible() && viewerCursor) { event.preventDefault(); moveSourceWord(wdir, event.shiftKey); return; }
|
|
1013
|
+
if (isDiffViewVisible() && diffCursor) { event.preventDefault(); moveDiffWord(wdir, event.shiftKey); return; }
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (treeFocusIndex >= 0 && handleTreeKey(event)) return;
|
|
1018
|
+
if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isSourceViewerVisible() && handleSourceCaretKey(event)) return;
|
|
1019
|
+
if (treeFocusIndex < 0 && !event.metaKey && !event.ctrlKey && !event.altKey && isDiffViewVisible() && handleDiffCaretKey(event)) return;
|
|
1020
|
+
|
|
1021
|
+
if (event.key === 'Shift' && !event.repeat) {
|
|
1022
|
+
const now = performance.now();
|
|
1023
|
+
// event.location: 1 = left Shift, 2 = right Shift, 0 = unspecified.
|
|
1024
|
+
// Require the SAME physical side twice (left+right never counts) within a
|
|
1025
|
+
// tight 300ms window so quick-open doesn't fire on accidental or mixed
|
|
1026
|
+
// Shift presses. The side !== 0 guard keeps an unknown location from ever
|
|
1027
|
+
// matching itself and triggering.
|
|
1028
|
+
const side = event.location;
|
|
1029
|
+
if (side !== 0 && side === lastShiftSide && now - lastShiftAt < 300) {
|
|
1030
|
+
event.preventDefault();
|
|
1031
|
+
lastShiftAt = 0;
|
|
1032
|
+
lastShiftSide = 0;
|
|
1033
|
+
openQuickOpen('all');
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
lastShiftAt = now;
|
|
1037
|
+
lastShiftSide = side;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'f') {
|
|
1041
|
+
event.preventDefault();
|
|
1042
|
+
openQuickOpen('content');
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'e') {
|
|
1046
|
+
event.preventDefault();
|
|
1047
|
+
openQuickOpen('recent');
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if ((event.metaKey || event.altKey) && event.key === 'Enter' && isSourceViewerVisible()) {
|
|
1052
|
+
const enterPath = document.getElementById('source-viewer')?.dataset.openPath || '';
|
|
1053
|
+
if (isHttpFile(enterPath)) {
|
|
1054
|
+
event.preventDefault();
|
|
1055
|
+
runHttpAtCaret();
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'ArrowDown') {
|
|
1061
|
+
event.preventDefault();
|
|
1062
|
+
if (isSourceViewerVisible()) goToSymbolUnderCursor();
|
|
1063
|
+
else openDiffFileAtCaret();
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
|
|
1068
|
+
var aeB = document.activeElement;
|
|
1069
|
+
if (aeB && (aeB.tagName === 'INPUT' || aeB.tagName === 'TEXTAREA' || aeB.tagName === 'SELECT')) return;
|
|
1070
|
+
event.preventDefault();
|
|
1071
|
+
if (isSourceViewerVisible()) goToSymbolUnderCursor();
|
|
1072
|
+
else if (isDiffViewVisible()) goToSymbolFromDiff();
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') && isSourceViewerVisible() && viewerCursor) {
|
|
1077
|
+
event.preventDefault();
|
|
1078
|
+
const lineEdgeFile = sourceByPath.get(viewerCursor.path);
|
|
1079
|
+
if (lineEdgeFile && lineEdgeFile.embedded) {
|
|
1080
|
+
const lineEdgeLines = lineEdgeFile.content.split(/\r?\n/);
|
|
1081
|
+
const lineEdgeCol = event.key === 'ArrowLeft' ? 0 : (lineEdgeLines[viewerCursor.lineIndex] || '').length;
|
|
1082
|
+
if (event.shiftKey) { if (!selectionAnchor) selectionAnchor = { lineIndex: viewerCursor.lineIndex, column: viewerCursor.column }; }
|
|
1083
|
+
else selectionAnchor = null;
|
|
1084
|
+
setSourceCursor(viewerCursor.path, viewerCursor.lineIndex, lineEdgeCol, true, -1);
|
|
1085
|
+
applySourceSelection();
|
|
1086
|
+
}
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Diff view: Cmd/Ctrl + Left/Right goes to the line start / end; pressing it again AT the
|
|
1091
|
+
// edge crosses to the adjacent pane (Left -> old, Right -> new). Plain arrows never cross.
|
|
1092
|
+
if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') && isDiffViewVisible() && diffCursor) {
|
|
1093
|
+
event.preventDefault();
|
|
1094
|
+
const edgeWrap = diffWrapperByPath(diffCursor.path);
|
|
1095
|
+
const edgeRow = edgeWrap ? diffRowAt(edgeWrap, diffCursor.side, diffCursor.rowIndex) : null;
|
|
1096
|
+
const edgeLen = edgeRow ? diffLineText(edgeRow).length : 0;
|
|
1097
|
+
if (event.key === 'ArrowLeft') {
|
|
1098
|
+
if (diffCursor.column > 0) {
|
|
1099
|
+
setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, 0, true); // -> line start
|
|
1100
|
+
} else if (diffCursor.side === 'new') { // already at start -> cross to old (left)
|
|
1101
|
+
const oldRow = edgeWrap ? diffRowAt(edgeWrap, 'old', diffCursor.rowIndex) : null;
|
|
1102
|
+
if (isDiffCodeRow(oldRow)) setDiffCursor(diffCursor.path, 'old', diffCursor.rowIndex, diffLineText(oldRow).length, true);
|
|
1103
|
+
}
|
|
1104
|
+
} else { // ArrowRight
|
|
1105
|
+
if (diffCursor.column < edgeLen) {
|
|
1106
|
+
setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, edgeLen, true); // -> line end
|
|
1107
|
+
} else if (diffCursor.side === 'old') { // already at end -> cross to new (right)
|
|
1108
|
+
const newRow = edgeWrap ? diffRowAt(edgeWrap, 'new', diffCursor.rowIndex) : null;
|
|
1109
|
+
if (isDiffCodeRow(newRow)) setDiffCursor(diffCursor.path, 'new', diffCursor.rowIndex, 0, true);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Cmd/Ctrl+[ / ] walk the cursor-position history (back / forward), like an editor's Go Back/Forward.
|
|
1116
|
+
if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && (event.key === '[' || event.key === ']' || event.key === '{' || event.key === '}')) {
|
|
1117
|
+
if (isSourceViewerVisible() && sourceTabs.length > 1) { event.preventDefault(); cycleSourceTab((event.key === '[' || event.key === '{') ? -1 : 1); return; }
|
|
1118
|
+
}
|
|
1119
|
+
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && (event.key === '[' || event.key === ']')) {
|
|
1120
|
+
var navEl = document.activeElement;
|
|
1121
|
+
var navInField = navEl && (navEl.tagName === 'INPUT' || navEl.tagName === 'TEXTAREA' || navEl.tagName === 'SELECT');
|
|
1122
|
+
if (!navInField) {
|
|
1123
|
+
event.preventDefault();
|
|
1124
|
+
if (event.key === '[') navBack(); else navForward();
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (event.key === 'F7') {
|
|
1130
|
+
event.preventDefault();
|
|
1131
|
+
if (!document.getElementById('source-viewer')?.classList.contains('hidden')) {
|
|
1132
|
+
const sourceHunk = firstHunkForPath(document.getElementById('source-viewer')?.dataset.openPath || '');
|
|
1133
|
+
if (sourceHunk >= 0) {
|
|
1134
|
+
setActive(sourceHunk);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
next(event.shiftKey ? -1 : 1);
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
quickInput?.addEventListener('input', () => renderQuickOpenResults());
|
|
1143
|
+
quickResults?.addEventListener('mousemove', (event) => {
|
|
1144
|
+
const item = event.target.closest?.('.quick-open-item');
|
|
1145
|
+
if (!item) return;
|
|
1146
|
+
quickActive = Number(item.dataset.index || 0);
|
|
1147
|
+
updateQuickActive();
|
|
1148
|
+
});
|
|
1149
|
+
quickResults?.addEventListener('click', (event) => {
|
|
1150
|
+
const item = event.target.closest?.('.quick-open-item');
|
|
1151
|
+
if (!item) return;
|
|
1152
|
+
const index = Number(item.dataset.index || 0);
|
|
1153
|
+
openQuickItem(quickItems[index]);
|
|
1154
|
+
});
|
|
1155
|
+
quickOpen?.addEventListener('click', (event) => {
|
|
1156
|
+
if (event.target === quickOpen) closeQuickOpen();
|
|
1157
|
+
});
|
|
1158
|
+
document.getElementById('usages-results')?.addEventListener('mousemove', function (event) {
|
|
1159
|
+
var it = event.target.closest && event.target.closest('.usage-item');
|
|
1160
|
+
if (!it) return;
|
|
1161
|
+
usageActive = Number(it.dataset.index || 0);
|
|
1162
|
+
updateUsageActive();
|
|
1163
|
+
});
|
|
1164
|
+
document.getElementById('usages-results')?.addEventListener('click', function (event) {
|
|
1165
|
+
var it = event.target.closest && event.target.closest('.usage-item');
|
|
1166
|
+
if (!it) return;
|
|
1167
|
+
openUsageItem(usageItems[Number(it.dataset.index || 0)]);
|
|
1168
|
+
});
|
|
1169
|
+
document.getElementById('usages')?.addEventListener('click', function (event) {
|
|
1170
|
+
if (event.target && event.target.id === 'usages') closeUsages();
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
links.forEach((link) => {
|
|
1174
|
+
link.addEventListener('click', (event) => {
|
|
1175
|
+
showDiffView(false);
|
|
1176
|
+
const target = Number(link.dataset.hunk);
|
|
1177
|
+
if (!Number.isNaN(target) && target >= 0 && target < hunkTotal()) {
|
|
1178
|
+
event.preventDefault();
|
|
1179
|
+
setActive(target);
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
// Delegated so it works whether the tree is inline (small repos) or materialized later (big repos).
|
|
1185
|
+
document.getElementById('files-panel')?.addEventListener('click', (event) => {
|
|
1186
|
+
const link = event.target && event.target.closest ? event.target.closest('.source-link') : null;
|
|
1187
|
+
if (link && link.dataset.sourceFile) openSourceFile(link.dataset.sourceFile);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
document.querySelectorAll('.tab').forEach((button) => {
|
|
1191
|
+
button.addEventListener('click', () => setTab(button.dataset.tab || 'changes'));
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
document.getElementById('back-to-diff')?.addEventListener('click', () => showDiffView(true));
|
|
1195
|
+
document.getElementById('source-tabs')?.addEventListener('click', function (event) {
|
|
1196
|
+
var closeBtn = event.target && event.target.closest && event.target.closest('.source-tab-close');
|
|
1197
|
+
if (closeBtn) { event.stopPropagation(); event.preventDefault(); closeSourceTab(closeBtn.getAttribute('data-close-path')); return; }
|
|
1198
|
+
var tab = event.target && event.target.closest && event.target.closest('.source-tab');
|
|
1199
|
+
if (tab) openSourceFile(tab.getAttribute('data-tab-path'));
|
|
1200
|
+
});
|
|
1201
|
+
document.getElementById('diff-viewed-toggle')?.addEventListener('click', function () {
|
|
1202
|
+
var btn = document.getElementById('diff-viewed-toggle');
|
|
1203
|
+
var path = btn ? (btn.dataset.file || '') : '';
|
|
1204
|
+
if (path) setFileViewed(path, !isFileViewed(path));
|
|
1205
|
+
});
|
|
1206
|
+
document.getElementById('source-body')?.addEventListener('click', handleSourceClick);
|
|
1207
|
+
document.getElementById('source-body')?.addEventListener('click', function (event) {
|
|
1208
|
+
var img = event.target && event.target.closest && event.target.closest('.image-preview');
|
|
1209
|
+
if (img) openLightbox(img.getAttribute('src'), img.getAttribute('alt'));
|
|
1210
|
+
});
|
|
1211
|
+
document.addEventListener('keydown', function (event) {
|
|
1212
|
+
if (event.key === 'Escape' && lightboxOpen()) { event.preventDefault(); event.stopPropagation(); closeLightbox(); }
|
|
1213
|
+
}, true);
|
|
1214
|
+
document.addEventListener('copy', handleSourceCopy);
|
|
1215
|
+
|
|
1216
|
+
applyI18n(); // first paint already shows English (inline); this swaps to the saved locale before the rest of init renders dynamic text
|
|
1217
|
+
populateHttpEnvSelect();
|
|
1218
|
+
if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0); // non-lazy indexes now; lazy-LOAD defers the (large) source blob + index to the first source-view open / go-to-def
|
|
1219
|
+
const restored = restoreUiState();
|
|
1220
|
+
if (!restored) {
|
|
1221
|
+
const initial = location.hash.match(/^#hunk-(\d+)$/);
|
|
1222
|
+
if (initial) setActive(Number(initial[1]), false);
|
|
1223
|
+
else if (REVIEW_LAZY_LOAD) showDiffView(false); // big repos: open to the diff (Changes); the source tree stays deferred until the Files tab is opened
|
|
1224
|
+
else openDefaultSourceFile();
|
|
1225
|
+
}
|
|
1226
|
+
initSourceTreeFolds();
|
|
1227
|
+
if (watchEnabled) setInterval(checkForLiveUpdate, 1500);
|
|
1228
|
+
window.addEventListener('beforeunload', saveUiState);
|
|
1229
|
+
|
|
1230
|
+
(function setupSidebarResize() {
|
|
1231
|
+
const resizer = document.querySelector('.sidebar-resizer');
|
|
1232
|
+
if (!resizer) return;
|
|
1233
|
+
const sidebarKey = 'monacori-sidebar-width:' + location.pathname;
|
|
1234
|
+
const saved = localStorage.getItem(sidebarKey);
|
|
1235
|
+
if (saved) document.documentElement.style.setProperty('--sidebar-width', saved);
|
|
1236
|
+
let resizing = false;
|
|
1237
|
+
resizer.addEventListener('mousedown', (event) => {
|
|
1238
|
+
resizing = true;
|
|
1239
|
+
resizer.classList.add('resizing');
|
|
1240
|
+
document.body.style.userSelect = 'none';
|
|
1241
|
+
event.preventDefault();
|
|
1242
|
+
});
|
|
1243
|
+
document.addEventListener('mousemove', (event) => {
|
|
1244
|
+
if (!resizing) return;
|
|
1245
|
+
const width = Math.min(640, Math.max(180, event.clientX));
|
|
1246
|
+
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
|
|
1247
|
+
});
|
|
1248
|
+
document.addEventListener('mouseup', () => {
|
|
1249
|
+
if (!resizing) return;
|
|
1250
|
+
resizing = false;
|
|
1251
|
+
resizer.classList.remove('resizing');
|
|
1252
|
+
document.body.style.userSelect = '';
|
|
1253
|
+
try { localStorage.setItem(sidebarKey, getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width').trim()); } catch (e) {}
|
|
1254
|
+
});
|
|
1255
|
+
})();
|
|
1256
|
+
|
|
1257
|
+
(function setupDiffCaret() {
|
|
1258
|
+
const container = document.getElementById('diff2html-container');
|
|
1259
|
+
if (!container) return;
|
|
1260
|
+
// No contenteditable: the diff caret is the JS diffCursor. A native contenteditable caret
|
|
1261
|
+
// would render a second blinking cursor alongside it. Text selection (for comment capture)
|
|
1262
|
+
// still works on non-editable content.
|
|
1263
|
+
container.setAttribute('aria-readonly', 'true');
|
|
1264
|
+
container.querySelectorAll('.d2h-code-side-linenumber, .d2h-code-linenumber, .d2h-code-line-prefix').forEach((el) => el.setAttribute('contenteditable', 'false'));
|
|
1265
|
+
const inComment = (event) => Boolean(event.target && event.target.closest && event.target.closest('.mc-comment-row'));
|
|
1266
|
+
const block = (event) => { if (inComment(event)) return; event.preventDefault(); };
|
|
1267
|
+
container.addEventListener('focusin', (event) => { if (!inComment(event)) clearTreeFocus(); });
|
|
1268
|
+
container.addEventListener('mousedown', (event) => { if (!inComment(event)) clearTreeFocus(); });
|
|
1269
|
+
container.addEventListener('beforeinput', block);
|
|
1270
|
+
container.addEventListener('paste', block);
|
|
1271
|
+
container.addEventListener('drop', block);
|
|
1272
|
+
container.addEventListener('dragstart', block);
|
|
1273
|
+
container.addEventListener('keydown', (event) => {
|
|
1274
|
+
if (inComment(event)) return;
|
|
1275
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
1276
|
+
if (event.key.length === 1 || event.key === 'Enter' || event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Tab') {
|
|
1277
|
+
event.preventDefault();
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
container.addEventListener('click', (event) => {
|
|
1281
|
+
if (inComment(event)) return;
|
|
1282
|
+
const info = diffRowInfoFromNode(event.target);
|
|
1283
|
+
if (info && info.path) setDiffCursor(info.path, info.side, info.rowIndex, 0, false);
|
|
1284
|
+
});
|
|
1285
|
+
ensureDiffCursor();
|
|
1286
|
+
})();
|
|
1287
|
+
|
|
1288
|
+
// ===== Side-by-side diff caret (keyboard navigation across the old/new panes) =====
|
|
1289
|
+
function isDiffViewVisible() {
|
|
1290
|
+
var d = document.getElementById('diff-view');
|
|
1291
|
+
return Boolean(d && !d.classList.contains('hidden'));
|
|
1292
|
+
}
|
|
1293
|
+
function diffActiveWrapper() {
|
|
1294
|
+
return document.querySelector('#diff2html-container .d2h-file-wrapper:not(.df-inactive)')
|
|
1295
|
+
|| document.querySelector('#diff2html-container .d2h-file-wrapper');
|
|
1296
|
+
}
|
|
1297
|
+
// path -> wrapper, O(1) after the first build. Rebuilt only on a miss/disconnect
|
|
1298
|
+
// (the wrapper set is stable; only bodies materialize). This is called several times
|
|
1299
|
+
// per F7 press, so the old O(files) querySelector scan made each keystroke cost scale
|
|
1300
|
+
// with the file count — the main source of cross-file nav stutter on big diffs.
|
|
1301
|
+
var wrapperPathMap = null;
|
|
1302
|
+
function diffWrapperPathKey(w) {
|
|
1303
|
+
return (w.dataset && w.dataset.path) || ((w.querySelector('.d2h-file-name') || {}).textContent || '').trim();
|
|
1304
|
+
}
|
|
1305
|
+
function diffWrapperByPath(path) {
|
|
1306
|
+
if (wrapperPathMap) {
|
|
1307
|
+
var hit = wrapperPathMap.get(path);
|
|
1308
|
+
if (hit && hit.isConnected) return hit;
|
|
1309
|
+
}
|
|
1310
|
+
wrapperPathMap = new Map();
|
|
1311
|
+
var ws = document.querySelectorAll('#diff2html-container .d2h-file-wrapper');
|
|
1312
|
+
for (var i = 0; i < ws.length; i++) {
|
|
1313
|
+
var key = diffWrapperPathKey(ws[i]);
|
|
1314
|
+
if (key) wrapperPathMap.set(key, ws[i]);
|
|
1315
|
+
}
|
|
1316
|
+
return wrapperPathMap.get(path) || null;
|
|
1317
|
+
}
|
|
1318
|
+
function diffSideTables(wrapper) {
|
|
1319
|
+
var sides = wrapper ? wrapper.querySelectorAll('.d2h-file-side-diff') : [];
|
|
1320
|
+
return { left: sides[0] || null, right: sides[sides.length - 1] || null };
|
|
1321
|
+
}
|
|
1322
|
+
function diffSideTable(wrapper, side) {
|
|
1323
|
+
var t = diffSideTables(wrapper);
|
|
1324
|
+
return side === 'old' ? t.left : t.right;
|
|
1325
|
+
}
|
|
1326
|
+
function diffRowsOf(sideTable) {
|
|
1327
|
+
if (!sideTable) return [];
|
|
1328
|
+
return Array.prototype.slice.call(sideTable.querySelectorAll('tr')).filter(function (r) {
|
|
1329
|
+
return !r.classList.contains('mc-comment-row') && !r.classList.contains('mc-spacer-row');
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
function diffRowAt(wrapper, side, rowIndex) {
|
|
1333
|
+
var rows = diffRowsOf(diffSideTable(wrapper, side));
|
|
1334
|
+
return rows[rowIndex] || null;
|
|
1335
|
+
}
|
|
1336
|
+
function diffCellCtn(row) {
|
|
1337
|
+
return row ? row.querySelector('.d2h-code-line-ctn') : null;
|
|
1338
|
+
}
|
|
1339
|
+
function diffLineText(row) {
|
|
1340
|
+
var ctn = diffCellCtn(row);
|
|
1341
|
+
return ctn ? (ctn.textContent || '') : '';
|
|
1342
|
+
}
|
|
1343
|
+
function diffLineNumber(row) {
|
|
1344
|
+
var n = row ? row.querySelector('.d2h-code-side-linenumber') : null;
|
|
1345
|
+
var v = n ? parseInt((n.textContent || '').trim(), 10) : NaN;
|
|
1346
|
+
return isFinite(v) ? v : null;
|
|
1347
|
+
}
|
|
1348
|
+
function diffRowInfoFromNode(node) {
|
|
1349
|
+
var el = node ? (node.nodeType === 1 ? node : node.parentElement) : null;
|
|
1350
|
+
if (!el || !el.closest) return null;
|
|
1351
|
+
var wrapper = el.closest('.d2h-file-wrapper');
|
|
1352
|
+
var sideEl = el.closest('.d2h-file-side-diff');
|
|
1353
|
+
var row = el.closest('tr');
|
|
1354
|
+
if (!wrapper || !sideEl || !row) return null;
|
|
1355
|
+
var nameEl = wrapper.querySelector('.d2h-file-name');
|
|
1356
|
+
var path = (nameEl && nameEl.textContent ? nameEl.textContent : '').trim();
|
|
1357
|
+
var t = diffSideTables(wrapper);
|
|
1358
|
+
var side = sideEl === t.left ? 'old' : 'new';
|
|
1359
|
+
if (!isDiffCodeRow(row)) return null;
|
|
1360
|
+
var rowIndex = diffRowsOf(sideEl).indexOf(row);
|
|
1361
|
+
if (!path || rowIndex < 0) return null;
|
|
1362
|
+
return { path: path, side: side, rowIndex: rowIndex };
|
|
1363
|
+
}
|
|
1364
|
+
function diffCaretDomPosition(ctn, column) {
|
|
1365
|
+
if (!ctn) return null;
|
|
1366
|
+
var remaining = column;
|
|
1367
|
+
var walker = document.createTreeWalker(ctn, NodeFilter.SHOW_TEXT);
|
|
1368
|
+
var node;
|
|
1369
|
+
while ((node = walker.nextNode())) {
|
|
1370
|
+
var len = node.textContent.length;
|
|
1371
|
+
if (remaining <= len) return { node: node, offset: remaining };
|
|
1372
|
+
remaining -= len;
|
|
1373
|
+
}
|
|
1374
|
+
return { node: ctn, offset: ctn.childNodes.length };
|
|
1375
|
+
}
|
|
1376
|
+
var diffCaretSpan = null;
|
|
1377
|
+
function clearDiffCaret() {
|
|
1378
|
+
var container = document.getElementById('diff2html-container');
|
|
1379
|
+
if (container) {
|
|
1380
|
+
container.querySelectorAll('.mc-diff-cursor-row').forEach(function (r) { r.classList.remove('mc-diff-cursor-row'); });
|
|
1381
|
+
// remove ALL caret spans (not just the tracked one) so a stray indicator never lingers
|
|
1382
|
+
container.querySelectorAll('.code-cursor').forEach(function (s) { var p = s.parentNode; if (p) { p.removeChild(s); if (p.normalize) p.normalize(); } });
|
|
1383
|
+
}
|
|
1384
|
+
diffCaretSpan = null;
|
|
1385
|
+
}
|
|
1386
|
+
function renderDiffCaret() {
|
|
1387
|
+
clearDiffCaret();
|
|
1388
|
+
if (!diffCursor) return;
|
|
1389
|
+
var wrapper = diffWrapperByPath(diffCursor.path);
|
|
1390
|
+
if (!wrapper) return;
|
|
1391
|
+
var row = diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex);
|
|
1392
|
+
if (!row) return;
|
|
1393
|
+
row.classList.add('mc-diff-cursor-row');
|
|
1394
|
+
var ctn = diffCellCtn(row);
|
|
1395
|
+
if (!ctn) return;
|
|
1396
|
+
// Empty line (ctn is just a <br>): the row highlight marks the caret. Inserting a caret span
|
|
1397
|
+
// next to the <br> would push it onto a second visual line and break the row's height.
|
|
1398
|
+
if ((ctn.textContent || '').length === 0) return;
|
|
1399
|
+
var pos = diffCaretDomPosition(ctn, diffCursor.column);
|
|
1400
|
+
if (!pos) return;
|
|
1401
|
+
var span = document.createElement('span');
|
|
1402
|
+
span.className = 'code-cursor';
|
|
1403
|
+
span.setAttribute('aria-hidden', 'true');
|
|
1404
|
+
try {
|
|
1405
|
+
var off = pos.node.nodeType === 3 ? Math.min(pos.offset, (pos.node.textContent || '').length) : pos.offset;
|
|
1406
|
+
var range = document.createRange();
|
|
1407
|
+
range.setStart(pos.node, off);
|
|
1408
|
+
range.collapse(true);
|
|
1409
|
+
range.insertNode(span);
|
|
1410
|
+
diffCaretSpan = span;
|
|
1411
|
+
} catch (e) { diffCaretSpan = null; }
|
|
1412
|
+
}
|
|
1413
|
+
function setDiffCursor(path, side, rowIndex, column, reveal) {
|
|
1414
|
+
markCaretBusy();
|
|
1415
|
+
var wrapper = diffWrapperByPath(path);
|
|
1416
|
+
if (!wrapper) return;
|
|
1417
|
+
var rows = diffRowsOf(diffSideTable(wrapper, side));
|
|
1418
|
+
if (!rows.length) return;
|
|
1419
|
+
var ri = Math.max(0, Math.min(rowIndex, rows.length - 1));
|
|
1420
|
+
var col = Math.max(0, Math.min(column, diffLineText(rows[ri]).length));
|
|
1421
|
+
diffCursor = { path: path, side: side, rowIndex: ri, column: col };
|
|
1422
|
+
diffSelectionAnchor = null; // any direct caret placement (click/F7/Cmd-arrow) drops the selection; Shift+Arrow re-sets it
|
|
1423
|
+
renderDiffCaret();
|
|
1424
|
+
applyDiffSelection();
|
|
1425
|
+
if (reveal) {
|
|
1426
|
+
var r = diffRowAt(wrapper, side, ri);
|
|
1427
|
+
if (r && r.scrollIntoView) requestAnimationFrame(function () { try { r.scrollIntoView({ block: 'nearest' }); } catch (e) {} });
|
|
1428
|
+
}
|
|
1429
|
+
recordNav(navEntryOf('diff'));
|
|
1430
|
+
}
|
|
1431
|
+
function navEntryOf(kind) {
|
|
1432
|
+
if (kind === 'diff') {
|
|
1433
|
+
if (!diffCursor) return null;
|
|
1434
|
+
return { kind: 'diff', path: diffCursor.path, side: diffCursor.side, rowIndex: diffCursor.rowIndex, column: diffCursor.column, line: diffCursor.rowIndex };
|
|
1435
|
+
}
|
|
1436
|
+
if (!viewerCursor) return null;
|
|
1437
|
+
return { kind: 'source', path: viewerCursor.path, lineIndex: viewerCursor.lineIndex, column: viewerCursor.column, line: viewerCursor.lineIndex };
|
|
1438
|
+
}
|
|
1439
|
+
function navSamePos(a, b) {
|
|
1440
|
+
return !!(a && b && a.kind === b.kind && a.path === b.path && a.line === b.line && (a.kind !== 'diff' || a.side === b.side));
|
|
1441
|
+
}
|
|
1442
|
+
// Record a caret placement into the back/forward history. Contiguous small moves refresh the
|
|
1443
|
+
// current entry (so arrowing around does not flood it); a jump (different file or a far line)
|
|
1444
|
+
// pushes a new entry and drops any forward history.
|
|
1445
|
+
function recordNav(entry) {
|
|
1446
|
+
if (navSuppress || !entry) return;
|
|
1447
|
+
var cur = navPos >= 0 ? navList[navPos] : null;
|
|
1448
|
+
if (navSamePos(cur, entry)) { navList[navPos] = entry; return; }
|
|
1449
|
+
var small = cur && cur.kind === entry.kind && cur.path === entry.path && Math.abs(cur.line - entry.line) < NAV_JUMP_LINES;
|
|
1450
|
+
if (small) { navList[navPos] = entry; return; }
|
|
1451
|
+
navList = navList.slice(0, navPos + 1);
|
|
1452
|
+
navList.push(entry);
|
|
1453
|
+
navPos = navList.length - 1;
|
|
1454
|
+
if (navList.length > NAV_MAX) { navList.shift(); navPos -= 1; }
|
|
1455
|
+
}
|
|
1456
|
+
function revealDiffFile(path) {
|
|
1457
|
+
document.getElementById('source-viewer')?.classList.add('hidden');
|
|
1458
|
+
document.getElementById('diff-view')?.classList.remove('hidden');
|
|
1459
|
+
setTab('changes');
|
|
1460
|
+
showOnlyFile(path);
|
|
1461
|
+
links.forEach(function (link) { link.classList.toggle('active', link.dataset.file === path); });
|
|
1462
|
+
renderBreadcrumb(document.getElementById('diff-breadcrumb'), path);
|
|
1463
|
+
}
|
|
1464
|
+
function restoreNav(entry) {
|
|
1465
|
+
if (!entry) return;
|
|
1466
|
+
navSuppress = true;
|
|
1467
|
+
try {
|
|
1468
|
+
if (entry.kind === 'diff') {
|
|
1469
|
+
revealDiffFile(entry.path);
|
|
1470
|
+
setDiffCursor(entry.path, entry.side, entry.rowIndex, entry.column, true);
|
|
1471
|
+
} else {
|
|
1472
|
+
setSourceCursor(entry.path, entry.lineIndex, entry.column, true, -1);
|
|
1473
|
+
}
|
|
1474
|
+
} finally {
|
|
1475
|
+
navSuppress = false;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
function navBack() {
|
|
1479
|
+
if (navPos < 0) return;
|
|
1480
|
+
// Change-nav (F7) does not record positions. If the caret has drifted past the last recorded
|
|
1481
|
+
// spot, the first Cmd+[ returns to it; the next steps further back through the cursor history.
|
|
1482
|
+
var live = navEntryOf(isSourceViewerVisible() ? 'source' : 'diff');
|
|
1483
|
+
if (live && !navSamePos(live, navList[navPos])) { restoreNav(navList[navPos]); return; }
|
|
1484
|
+
if (navPos > 0) { navPos -= 1; restoreNav(navList[navPos]); }
|
|
1485
|
+
}
|
|
1486
|
+
function navForward() {
|
|
1487
|
+
if (navPos < navList.length - 1) { navPos += 1; restoreNav(navList[navPos]); }
|
|
1488
|
+
}
|
|
1489
|
+
function applyDiffSelection() {
|
|
1490
|
+
var sel = window.getSelection();
|
|
1491
|
+
if (!sel) return;
|
|
1492
|
+
// Selection only makes sense within one pane and one file; otherwise clear it.
|
|
1493
|
+
if (!diffSelectionAnchor || !diffCursor || diffSelectionAnchor.side !== diffCursor.side) { try { sel.removeAllRanges(); } catch (e) {} return; }
|
|
1494
|
+
var wrapper = diffWrapperByPath(diffCursor.path);
|
|
1495
|
+
if (!wrapper) { try { sel.removeAllRanges(); } catch (e) {} return; }
|
|
1496
|
+
var aCtn = diffCellCtn(diffRowAt(wrapper, diffSelectionAnchor.side, diffSelectionAnchor.rowIndex));
|
|
1497
|
+
var cCtn = diffCellCtn(diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex));
|
|
1498
|
+
var a = aCtn ? diffCaretDomPosition(aCtn, diffSelectionAnchor.column) : null;
|
|
1499
|
+
var c = cCtn ? diffCaretDomPosition(cCtn, diffCursor.column) : null;
|
|
1500
|
+
if (a && c) { try { sel.setBaseAndExtent(a.node, a.offset, c.node, c.offset); } catch (e) {} }
|
|
1501
|
+
}
|
|
1502
|
+
function isDiffCodeRow(row) {
|
|
1503
|
+
if (!row) return false;
|
|
1504
|
+
if (row.querySelector('.d2h-emptyplaceholder, .d2h-code-side-emptyplaceholder')) return false; // added/removed counterpart — no real line
|
|
1505
|
+
if (!row.querySelector('.d2h-code-line-ctn')) return false;
|
|
1506
|
+
var num = row.querySelector('.d2h-code-side-linenumber');
|
|
1507
|
+
return !!num && (num.textContent || '').trim().length > 0; // real code line has a line number (excludes hunk-info rows)
|
|
1508
|
+
}
|
|
1509
|
+
function firstDiffCodeRow(wrapper, side) {
|
|
1510
|
+
var rows = diffRowsOf(diffSideTable(wrapper, side));
|
|
1511
|
+
for (var i = 0; i < rows.length; i++) { if (isDiffCodeRow(rows[i])) return i; }
|
|
1512
|
+
return -1;
|
|
1513
|
+
}
|
|
1514
|
+
function ensureDiffCursor() {
|
|
1515
|
+
if (!isDiffViewVisible()) return;
|
|
1516
|
+
var wrapper = diffActiveWrapper();
|
|
1517
|
+
if (!wrapper) return;
|
|
1518
|
+
whenFileReady(wrapper, function () {
|
|
1519
|
+
var nameEl = wrapper.querySelector('.d2h-file-name');
|
|
1520
|
+
var path = (nameEl && nameEl.textContent ? nameEl.textContent : '').trim();
|
|
1521
|
+
if (!path) return;
|
|
1522
|
+
if (diffCursor && diffCursor.path === path) { renderDiffCaret(); return; }
|
|
1523
|
+
var ri = firstDiffCodeRow(wrapper, 'new');
|
|
1524
|
+
if (ri < 0) return;
|
|
1525
|
+
setDiffCursor(path, 'new', ri, 0, false);
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
function moveDiffCursor(dLine, dColumn, extend) {
|
|
1529
|
+
if (!diffCursor) return;
|
|
1530
|
+
var wrapper = diffWrapperByPath(diffCursor.path);
|
|
1531
|
+
if (!wrapper) return;
|
|
1532
|
+
var side = diffCursor.side;
|
|
1533
|
+
var rows = diffRowsOf(diffSideTable(wrapper, side));
|
|
1534
|
+
var ri = diffCursor.rowIndex;
|
|
1535
|
+
var col = diffCursor.column;
|
|
1536
|
+
var text = diffLineText(rows[ri]);
|
|
1537
|
+
// Shift extends a text selection from where the caret sat before the first shifted move.
|
|
1538
|
+
var anchor = extend ? (diffSelectionAnchor || { side: diffCursor.side, rowIndex: diffCursor.rowIndex, column: diffCursor.column }) : null;
|
|
1539
|
+
// Plain arrows stay within the current pane (no auto pane-crossing — that is Cmd+Left/Right).
|
|
1540
|
+
if (dColumn < 0) {
|
|
1541
|
+
if (col > 0) { col -= 1; }
|
|
1542
|
+
else { // at line start: end of previous code line in the SAME pane
|
|
1543
|
+
var p = ri - 1; while (p >= 0 && !isDiffCodeRow(rows[p])) p -= 1;
|
|
1544
|
+
if (p >= 0) { ri = p; col = diffLineText(rows[p]).length; }
|
|
1545
|
+
}
|
|
1546
|
+
} else if (dColumn > 0) {
|
|
1547
|
+
if (col < text.length) { col += 1; }
|
|
1548
|
+
else { // at line end: start of next code line in the SAME pane
|
|
1549
|
+
var nx = ri + 1; while (nx < rows.length && !isDiffCodeRow(rows[nx])) nx += 1;
|
|
1550
|
+
if (nx < rows.length) { ri = nx; col = 0; }
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
if (dLine !== 0) {
|
|
1554
|
+
var rows2 = diffRowsOf(diffSideTable(wrapper, side));
|
|
1555
|
+
var step = dLine > 0 ? 1 : -1;
|
|
1556
|
+
var cand = ri + step;
|
|
1557
|
+
while (cand >= 0 && cand < rows2.length && !isDiffCodeRow(rows2[cand])) cand += step;
|
|
1558
|
+
if (cand >= 0 && cand < rows2.length) { ri = cand; col = Math.min(col, diffLineText(rows2[ri]).length); }
|
|
1559
|
+
}
|
|
1560
|
+
setDiffCursor(diffCursor.path, side, ri, col, true); // clears diffSelectionAnchor + native selection
|
|
1561
|
+
if (anchor) { diffSelectionAnchor = anchor; applyDiffSelection(); } // re-establish the Shift selection
|
|
1562
|
+
}
|
|
1563
|
+
function moveDiffWord(dir, extend) {
|
|
1564
|
+
if (!diffCursor) return;
|
|
1565
|
+
var wrapper = diffWrapperByPath(diffCursor.path);
|
|
1566
|
+
if (!wrapper) return;
|
|
1567
|
+
var row = diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex);
|
|
1568
|
+
var text = diffLineText(row);
|
|
1569
|
+
var ncol = nextWordBoundary(text, diffCursor.column, dir);
|
|
1570
|
+
if (ncol === diffCursor.column) return; // already at the line edge — plain arrows change lines
|
|
1571
|
+
var anchor = extend ? (diffSelectionAnchor || { side: diffCursor.side, rowIndex: diffCursor.rowIndex, column: diffCursor.column }) : null;
|
|
1572
|
+
setDiffCursor(diffCursor.path, diffCursor.side, diffCursor.rowIndex, ncol, true);
|
|
1573
|
+
if (anchor) { diffSelectionAnchor = anchor; applyDiffSelection(); }
|
|
1574
|
+
}
|
|
1575
|
+
function handleDiffCaretKey(event) {
|
|
1576
|
+
if (!isDiffViewVisible() || !diffCursor) return false;
|
|
1577
|
+
var ae = document.activeElement;
|
|
1578
|
+
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return false;
|
|
1579
|
+
var extend = event.shiftKey;
|
|
1580
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); moveDiffCursor(1, 0, extend); return true; }
|
|
1581
|
+
if (event.key === 'ArrowUp') { event.preventDefault(); moveDiffCursor(-1, 0, extend); return true; }
|
|
1582
|
+
if (event.key === 'ArrowLeft') { event.preventDefault(); moveDiffCursor(0, -1, extend); return true; }
|
|
1583
|
+
if (event.key === 'ArrowRight') { event.preventDefault(); moveDiffCursor(0, 1, extend); return true; }
|
|
1584
|
+
return false;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// ===== Review comments: questions ("?") and change-requests (">") =====
|
|
1588
|
+
// (COMMENTS_KEY / reviewComments / commentSeq / composerState are declared near the top of the script)
|
|
1589
|
+
function saveComments() {
|
|
1590
|
+
persistSave(COMMENTS_KEY, reviewComments);
|
|
1591
|
+
}
|
|
1592
|
+
function commentsAt(path, line) {
|
|
1593
|
+
return reviewComments.filter(function (c) { return c.path === path && c.line === line; });
|
|
1594
|
+
}
|
|
1595
|
+
function commentKindLabel(kind) {
|
|
1596
|
+
return kind === 'q' ? t('comment.kind.q') : t('comment.kind.c');
|
|
1597
|
+
}
|
|
1598
|
+
function relevantLines(path) {
|
|
1599
|
+
var set = {};
|
|
1600
|
+
reviewComments.forEach(function (c) { if (c.path === path) set[c.line] = true; });
|
|
1601
|
+
if (composerState && composerState.path === path) set[composerState.line] = true;
|
|
1602
|
+
return Object.keys(set).map(Number).sort(function (a, b) { return a - b; });
|
|
1603
|
+
}
|
|
1604
|
+
function addComment(kind, path, line, code, text) {
|
|
1605
|
+
var trimmed = String(text || '').trim();
|
|
1606
|
+
if (!trimmed) return;
|
|
1607
|
+
commentSeq += 1;
|
|
1608
|
+
reviewComments.push({ seq: commentSeq, kind: kind, path: path, line: line, code: String(code || ''), text: trimmed });
|
|
1609
|
+
saveComments();
|
|
1610
|
+
}
|
|
1611
|
+
function deleteComment(seq) {
|
|
1612
|
+
reviewComments = reviewComments.filter(function (c) { return c.seq !== seq; });
|
|
1613
|
+
saveComments();
|
|
1614
|
+
refreshComments();
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function sourceRowLineOf(node) {
|
|
1618
|
+
var el = node ? (node.nodeType === 1 ? node : node.parentElement) : null;
|
|
1619
|
+
var row = el && el.closest ? el.closest('.source-row') : null;
|
|
1620
|
+
if (!row) return null;
|
|
1621
|
+
var v = parseInt(row.dataset.lineIndex, 10);
|
|
1622
|
+
return isFinite(v) ? v : null;
|
|
1623
|
+
}
|
|
1624
|
+
function currentCommentTarget() {
|
|
1625
|
+
var sel = window.getSelection();
|
|
1626
|
+
var selText = (sel && sel.toString) ? sel.toString() : '';
|
|
1627
|
+
var hasSel = !!sel && !sel.isCollapsed && selText.trim().length > 0;
|
|
1628
|
+
// Source view: anchor BELOW the selection (its last line) so the box sits under the drag.
|
|
1629
|
+
// Derive the span from the actual DOM range so MOUSE drags work (they don't move the JS caret).
|
|
1630
|
+
if (isSourceViewerVisible() && viewerCursor) {
|
|
1631
|
+
if (hasSel) {
|
|
1632
|
+
var srng = sel.rangeCount ? sel.getRangeAt(0) : null;
|
|
1633
|
+
var sa = srng ? sourceRowLineOf(srng.startContainer) : null;
|
|
1634
|
+
var sb = srng ? sourceRowLineOf(srng.endContainer) : null;
|
|
1635
|
+
if (sa == null || sb == null) { sa = selectionAnchor ? selectionAnchor.lineIndex : viewerCursor.lineIndex; sb = viewerCursor.lineIndex; }
|
|
1636
|
+
var f = Math.min(sa, sb), t = Math.max(sa, sb);
|
|
1637
|
+
return { path: viewerCursor.path, line: t + 1, code: selText, from: f + 1, to: t + 1, side: null };
|
|
1638
|
+
}
|
|
1639
|
+
return { path: viewerCursor.path, line: viewerCursor.lineIndex + 1, code: '', from: null, to: null, side: null };
|
|
1640
|
+
}
|
|
1641
|
+
// Diff view: prefer the explicit diff caret when there is no text selection.
|
|
1642
|
+
if (!hasSel && diffCursor && isDiffViewVisible()) {
|
|
1643
|
+
var dwrap = diffWrapperByPath(diffCursor.path);
|
|
1644
|
+
var drow = dwrap ? diffRowAt(dwrap, diffCursor.side, diffCursor.rowIndex) : null;
|
|
1645
|
+
var dline = drow ? diffLineNumber(drow) : null;
|
|
1646
|
+
if (dline != null) return { path: diffCursor.path, line: dline, code: '', from: null, to: null, side: null };
|
|
1647
|
+
}
|
|
1648
|
+
// Diff view with a selection (or click): anchor at the LAST line so the composer drops BELOW the
|
|
1649
|
+
// drag; capture the selected code + line span (used to keep the drag highlighted via .mc-sel-line).
|
|
1650
|
+
var rng = (sel && sel.rangeCount) ? sel.getRangeAt(0) : null;
|
|
1651
|
+
var fromNode = rng ? rng.startContainer : (sel ? sel.anchorNode : null);
|
|
1652
|
+
var toNode = rng ? rng.endContainer : (sel ? sel.anchorNode : null);
|
|
1653
|
+
var fromEl = fromNode ? (fromNode.nodeType === 1 ? fromNode : fromNode.parentElement) : null;
|
|
1654
|
+
var toEl = toNode ? (toNode.nodeType === 1 ? toNode : toNode.parentElement) : null;
|
|
1655
|
+
var wrapper = (toEl && toEl.closest && toEl.closest('.d2h-file-wrapper')) || document.querySelector('#diff2html-container .d2h-file-wrapper:not(.df-inactive)');
|
|
1656
|
+
if (!wrapper) return null;
|
|
1657
|
+
var nameEl = wrapper.querySelector('.d2h-file-name');
|
|
1658
|
+
var path = (nameEl && nameEl.textContent ? nameEl.textContent : '').trim();
|
|
1659
|
+
if (!path) return null;
|
|
1660
|
+
var toRow = toEl && toEl.closest ? toEl.closest('tr') : null;
|
|
1661
|
+
if (!toRow || !toRow.querySelector('.d2h-code-side-linenumber')) {
|
|
1662
|
+
var sides0 = wrapper.querySelectorAll('.d2h-file-side-diff');
|
|
1663
|
+
var right0 = sides0[sides0.length - 1];
|
|
1664
|
+
var firstNum = right0 ? right0.querySelector('.d2h-code-side-linenumber') : null;
|
|
1665
|
+
toRow = firstNum ? firstNum.closest('tr') : null;
|
|
1666
|
+
}
|
|
1667
|
+
if (!toRow) return null;
|
|
1668
|
+
var toLine = diffLineNumber(toRow);
|
|
1669
|
+
if (toLine == null) return null;
|
|
1670
|
+
var fromRow = (hasSel && fromEl && fromEl.closest) ? fromEl.closest('tr') : null;
|
|
1671
|
+
var fromLine = fromRow ? diffLineNumber(fromRow) : null;
|
|
1672
|
+
if (fromLine == null) fromLine = toLine;
|
|
1673
|
+
var sideEl = toEl && toEl.closest ? toEl.closest('.d2h-file-side-diff') : null;
|
|
1674
|
+
var st = diffSideTables(wrapper);
|
|
1675
|
+
var side = (sideEl && sideEl === st.left) ? 'old' : 'new';
|
|
1676
|
+
return { path: path, line: toLine, code: hasSel ? selText : '', from: hasSel ? Math.min(fromLine, toLine) : null, to: hasSel ? Math.max(fromLine, toLine) : null, side: side };
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function threadHtml(path, line) {
|
|
1680
|
+
var html = '';
|
|
1681
|
+
commentsAt(path, line).forEach(function (c) {
|
|
1682
|
+
html += '<div class="mc-card mc-' + c.kind + '">'
|
|
1683
|
+
+ '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(c.kind) + '</span>'
|
|
1684
|
+
+ '<button type="button" class="mc-del" data-seq="' + c.seq + '" title="' + escapeHtml(t('composer.delete')) + '">×</button></div>'
|
|
1685
|
+
+ '<div class="mc-card-body">' + escapeHtml(c.text) + '</div></div>';
|
|
1686
|
+
});
|
|
1687
|
+
if (composerState && composerState.path === path && composerState.line === line) {
|
|
1688
|
+
var ph = composerState.kind === 'q' ? t('composer.question') : t('composer.changeRequest');
|
|
1689
|
+
html += '<div class="mc-card mc-' + composerState.kind + ' mc-composer">'
|
|
1690
|
+
+ '<div class="mc-card-head"><span class="mc-kind">' + commentKindLabel(composerState.kind) + '</span></div>'
|
|
1691
|
+
+ '<textarea class="mc-input" rows="3" placeholder="' + escapeHtml(ph) + '"></textarea>'
|
|
1692
|
+
+ '<div class="mc-actions"><button type="button" class="mc-btn mc-save">' + escapeHtml(t('composer.save')) + '</button>'
|
|
1693
|
+
+ '<button type="button" class="mc-btn mc-ghost mc-cancel">' + escapeHtml(t('composer.cancel')) + '</button>'
|
|
1694
|
+
+ '<span class="mc-hint">' + escapeHtml(t('composer.hint')) + '</span></div></div>';
|
|
1695
|
+
}
|
|
1696
|
+
return html;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function injectThreadRow(anchorRow, path, line) {
|
|
1700
|
+
if (!anchorRow || !anchorRow.parentNode) return;
|
|
1701
|
+
var tr = document.createElement('tr');
|
|
1702
|
+
tr.className = 'mc-comment-row';
|
|
1703
|
+
var td = document.createElement('td');
|
|
1704
|
+
// source/markdown/csv rows can have >2 cells (csv); span them all. diff (d2h) rows stay 2.
|
|
1705
|
+
td.colSpan = (anchorRow.classList && anchorRow.classList.contains('source-row')) ? (anchorRow.children.length || 2) : 2;
|
|
1706
|
+
td.className = 'mc-thread-cell';
|
|
1707
|
+
td.innerHTML = threadHtml(path, line);
|
|
1708
|
+
tr.appendChild(td);
|
|
1709
|
+
anchorRow.parentNode.insertBefore(tr, anchorRow.nextSibling);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function renderDiffComments() {
|
|
1713
|
+
var container = document.getElementById('diff2html-container');
|
|
1714
|
+
if (!container) return;
|
|
1715
|
+
container.querySelectorAll('.mc-comment-row').forEach(function (r) { r.remove(); });
|
|
1716
|
+
container.querySelectorAll('.d2h-file-wrapper').forEach(function (w) {
|
|
1717
|
+
var nameEl = w.querySelector('.d2h-file-name');
|
|
1718
|
+
var path = (nameEl && nameEl.textContent ? nameEl.textContent : '').trim();
|
|
1719
|
+
if (!path) return;
|
|
1720
|
+
var lines = relevantLines(path);
|
|
1721
|
+
if (!lines.length) return;
|
|
1722
|
+
var sides = w.querySelectorAll('.d2h-file-side-diff');
|
|
1723
|
+
var right = sides[sides.length - 1];
|
|
1724
|
+
if (!right) return;
|
|
1725
|
+
var rows = right.querySelectorAll('tr');
|
|
1726
|
+
lines.forEach(function (line) {
|
|
1727
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1728
|
+
var num = rows[i].querySelector('.d2h-code-side-linenumber');
|
|
1729
|
+
if (num && (num.textContent || '').trim() === String(line)) { injectThreadRow(rows[i], path, line); break; }
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function renderSourceComments() {
|
|
1736
|
+
var body = document.getElementById('source-body');
|
|
1737
|
+
if (!body) return;
|
|
1738
|
+
body.querySelectorAll('.mc-comment-row').forEach(function (r) { r.remove(); });
|
|
1739
|
+
var viewer = document.getElementById('source-viewer');
|
|
1740
|
+
var path = viewer ? (viewer.dataset.openPath || '') : '';
|
|
1741
|
+
if (!path) return;
|
|
1742
|
+
relevantLines(path).forEach(function (line) {
|
|
1743
|
+
var anchor = body.querySelector('.source-row[data-line-index="' + (line - 1) + '"]');
|
|
1744
|
+
if (anchor) injectThreadRow(anchor, path, line);
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// Per-file comment counts as small (no-emoji) badges in BOTH sidebars — the Changes list
|
|
1749
|
+
// (.change-row, before the diffstat) and the Files tree (.source-link, after the file name).
|
|
1750
|
+
function renderCommentBadges() {
|
|
1751
|
+
document.querySelectorAll('.mc-file-badge').forEach(function (b) { b.remove(); });
|
|
1752
|
+
var counts = {};
|
|
1753
|
+
reviewComments.forEach(function (x) {
|
|
1754
|
+
var k = counts[x.path] || (counts[x.path] = { q: 0, c: 0 });
|
|
1755
|
+
if (x.kind === 'q') k.q += 1; else k.c += 1;
|
|
1756
|
+
});
|
|
1757
|
+
function makeBadge(k) {
|
|
1758
|
+
var badge = document.createElement('span');
|
|
1759
|
+
badge.className = 'mc-file-badge';
|
|
1760
|
+
var html = '';
|
|
1761
|
+
if (k.q) html += '<span class="mc-fb mc-fb-q" title="' + k.q + ' ' + escapeHtml(t('badge.questions')) + '">' + k.q + '</span>';
|
|
1762
|
+
if (k.c) html += '<span class="mc-fb mc-fb-c" title="' + k.c + ' ' + escapeHtml(t('badge.changeRequests')) + '">' + k.c + '</span>';
|
|
1763
|
+
badge.innerHTML = html;
|
|
1764
|
+
return badge;
|
|
1765
|
+
}
|
|
1766
|
+
function inject(selector, keyAttr, refSelector) {
|
|
1767
|
+
document.querySelectorAll(selector).forEach(function (row) {
|
|
1768
|
+
var k = counts[row.dataset[keyAttr] || ''];
|
|
1769
|
+
if (!k) return;
|
|
1770
|
+
var ref = row.querySelector(refSelector);
|
|
1771
|
+
if (ref) row.insertBefore(makeBadge(k), ref); else row.appendChild(makeBadge(k));
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
inject('.change-row', 'file', '.diffstat');
|
|
1775
|
+
inject('.source-link', 'sourceFile', '.count');
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// While composing on a drag selection, keep those lines highlighted (.mc-sel-line) so the user
|
|
1779
|
+
// sees what they are commenting on even though the native selection was cleared.
|
|
1780
|
+
function applyCommentSelectionHighlight() {
|
|
1781
|
+
document.querySelectorAll('.mc-sel-line').forEach(function (r) { r.classList.remove('mc-sel-line'); });
|
|
1782
|
+
if (!composerState || composerState.from == null || composerState.to == null) return;
|
|
1783
|
+
var from = composerState.from, to = composerState.to;
|
|
1784
|
+
if (isDiffViewVisible()) {
|
|
1785
|
+
var wrap = diffWrapperByPath(composerState.path);
|
|
1786
|
+
if (!wrap) return;
|
|
1787
|
+
diffRowsOf(diffSideTable(wrap, composerState.side || 'new')).forEach(function (row) {
|
|
1788
|
+
var ln = diffLineNumber(row);
|
|
1789
|
+
if (ln != null && ln >= from && ln <= to) row.classList.add('mc-sel-line');
|
|
1790
|
+
});
|
|
1791
|
+
} else if (isSourceViewerVisible()) {
|
|
1792
|
+
for (var ln = from; ln <= to; ln++) {
|
|
1793
|
+
var sr = document.querySelector('.source-row[data-line-index="' + (ln - 1) + '"]');
|
|
1794
|
+
if (sr) sr.classList.add('mc-sel-line');
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
function refreshComments() {
|
|
1799
|
+
renderDiffComments();
|
|
1800
|
+
if (isSourceViewerVisible()) renderSourceComments();
|
|
1801
|
+
renderCommentBadges();
|
|
1802
|
+
applyCommentSelectionHighlight();
|
|
1803
|
+
if (composerState) {
|
|
1804
|
+
var composerFocusTries = 0;
|
|
1805
|
+
var tryFocusComposer = function () {
|
|
1806
|
+
var ta = document.querySelector('.mc-composer .mc-input');
|
|
1807
|
+
if (!ta) return true; // composer gone — stop retrying
|
|
1808
|
+
if (document.activeElement === ta) return true; // already focused — done
|
|
1809
|
+
try { ta.focus({ preventScroll: true }); } catch (e) { try { ta.focus(); } catch (e2) {} }
|
|
1810
|
+
try { ta.selectionStart = ta.selectionEnd = ta.value.length; } catch (e3) {}
|
|
1811
|
+
return document.activeElement === ta;
|
|
1812
|
+
};
|
|
1813
|
+
// A one-shot focus works in a plain browser, but Electron asynchronously restores focus to <body>
|
|
1814
|
+
// after the keydown, so the textarea loses that race. Retry on a short interval until it wins (or the
|
|
1815
|
+
// composer closes), capped at ~300ms so it never fights real user focus once they start typing.
|
|
1816
|
+
if (!tryFocusComposer()) {
|
|
1817
|
+
var composerFocusIv = setInterval(function () {
|
|
1818
|
+
if (tryFocusComposer() || ++composerFocusTries > 12) clearInterval(composerFocusIv);
|
|
1819
|
+
}, 25);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function openComposer(kind) {
|
|
1825
|
+
var target = currentCommentTarget();
|
|
1826
|
+
if (!target) return;
|
|
1827
|
+
composerState = { kind: kind, path: target.path, line: target.line, code: target.code, from: target.from, to: target.to, side: target.side };
|
|
1828
|
+
// Keep the dragged code visibly highlighted via the .mc-sel-line class (applyCommentSelectionHighlight),
|
|
1829
|
+
// and clear the native selection so its highlight doesn't bleed into the composer/cards below it.
|
|
1830
|
+
try { var psel = window.getSelection(); if (psel) psel.removeAllRanges(); } catch (e) {}
|
|
1831
|
+
refreshComments();
|
|
1832
|
+
}
|
|
1833
|
+
function closeComposer() {
|
|
1834
|
+
if (!composerState) return;
|
|
1835
|
+
composerState = null;
|
|
1836
|
+
refreshComments();
|
|
1837
|
+
}
|
|
1838
|
+
function saveComposer(ta) {
|
|
1839
|
+
if (!composerState) return;
|
|
1840
|
+
var box = ta || document.querySelector('.mc-composer .mc-input');
|
|
1841
|
+
if (!box) return;
|
|
1842
|
+
addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
|
|
1843
|
+
composerState = null;
|
|
1844
|
+
refreshComments();
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// Default merge-prompt headings, localized: a Korean user gets Korean defaults. Editable in
|
|
1848
|
+
// Settings → Merge prompts (stored per browser in localStorage); buildMergedText + the textarea
|
|
1849
|
+
// placeholders fall back to these when the stored value is empty.
|
|
1850
|
+
function defaultMergePrompt(kind) {
|
|
1851
|
+
return t(kind === 'q' ? 'mergePrompt.default.q' : 'mergePrompt.default.c');
|
|
1852
|
+
}
|
|
1853
|
+
var mergePromptsKey = 'monacori-merge-prompts';
|
|
1854
|
+
function loadMergePrompts() {
|
|
1855
|
+
var b = persistRead(mergePromptsKey); if (b && typeof b === 'object') return b; try { var v = JSON.parse(localStorage.getItem(mergePromptsKey) || '{}'); return (v && typeof v === 'object') ? v : {}; } catch (e) { return {}; }
|
|
1856
|
+
}
|
|
1857
|
+
function mergePromptFor(kind) {
|
|
1858
|
+
var v = loadMergePrompts()[kind];
|
|
1859
|
+
return (typeof v === 'string' && v.trim()) ? v : defaultMergePrompt(kind);
|
|
1860
|
+
}
|
|
1861
|
+
function saveMergePrompt(kind, text) {
|
|
1862
|
+
var saved = loadMergePrompts();
|
|
1863
|
+
if (text && text.trim()) saved[kind] = text; else delete saved[kind];
|
|
1864
|
+
persistSave(mergePromptsKey, saved);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function buildMergedText(kind) {
|
|
1868
|
+
var items = reviewComments.filter(function (c) { return c.kind === kind; });
|
|
1869
|
+
var nl = String.fromCharCode(10);
|
|
1870
|
+
var lines = [];
|
|
1871
|
+
// Per-kind agent contract heading (editable in Settings → Merge prompts; default otherwise).
|
|
1872
|
+
lines.push(mergePromptFor(kind));
|
|
1873
|
+
lines.push('');
|
|
1874
|
+
lines.push((kind === 'q' ? t('merged.qHeading') : t('merged.cHeading')) + ' (' + items.length + ')');
|
|
1875
|
+
lines.push('');
|
|
1876
|
+
items.forEach(function (c) {
|
|
1877
|
+
lines.push('### ' + c.path + ':' + c.line);
|
|
1878
|
+
if (c.code && c.code.trim()) lines.push('> ' + c.code.trim());
|
|
1879
|
+
lines.push(c.text);
|
|
1880
|
+
lines.push('');
|
|
1881
|
+
});
|
|
1882
|
+
return lines.join(nl);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function openMergedView(kind) {
|
|
1886
|
+
var existing = document.getElementById('mc-modal');
|
|
1887
|
+
if (existing) existing.remove();
|
|
1888
|
+
var modal = document.createElement('div');
|
|
1889
|
+
modal.id = 'mc-modal';
|
|
1890
|
+
modal.className = 'mc-modal';
|
|
1891
|
+
modal.dataset.kind = kind; // remembered so a live locale switch can re-render this same view
|
|
1892
|
+
var panel = document.createElement('div');
|
|
1893
|
+
panel.className = 'mc-modal-panel';
|
|
1894
|
+
var head = document.createElement('div');
|
|
1895
|
+
head.className = 'mc-modal-head';
|
|
1896
|
+
var title = document.createElement('span');
|
|
1897
|
+
title.textContent = kind === 'q' ? t('merged.qTitle') : t('merged.cTitle');
|
|
1898
|
+
var closeBtn = document.createElement('button');
|
|
1899
|
+
closeBtn.type = 'button';
|
|
1900
|
+
closeBtn.className = 'mc-btn mc-ghost';
|
|
1901
|
+
closeBtn.textContent = t('merged.close');
|
|
1902
|
+
var area = document.createElement('textarea');
|
|
1903
|
+
area.className = 'mc-modal-text';
|
|
1904
|
+
area.readOnly = true;
|
|
1905
|
+
area.value = buildMergedText(kind);
|
|
1906
|
+
closeBtn.addEventListener('click', function () { modal.remove(); });
|
|
1907
|
+
// Terminal send (Electron, terminal open): close the modal and hand off to pane-pick mode ON the
|
|
1908
|
+
// terminal — the chosen pane is highlighted, the rest dimmed, arrows change the choice, Enter sends.
|
|
1909
|
+
// One button here; the actual pick happens visually over the live claude/codex sessions.
|
|
1910
|
+
var sendBtn = null;
|
|
1911
|
+
if (window.__monacoriTerminal && typeof window.__monacoriTerminal.isOpen === 'function' && window.__monacoriTerminal.isOpen()) {
|
|
1912
|
+
sendBtn = document.createElement('button');
|
|
1913
|
+
sendBtn.type = 'button';
|
|
1914
|
+
sendBtn.className = 'mc-btn mc-send-term';
|
|
1915
|
+
sendBtn.textContent = t('merged.sendToTerminal');
|
|
1916
|
+
sendBtn.addEventListener('click', function () {
|
|
1917
|
+
var text = buildMergedText(kind);
|
|
1918
|
+
modal.remove();
|
|
1919
|
+
window.__monacoriTerminal.enterSendMode(text);
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
head.appendChild(title);
|
|
1923
|
+
if (sendBtn) head.appendChild(sendBtn);
|
|
1924
|
+
head.appendChild(closeBtn);
|
|
1925
|
+
panel.appendChild(head);
|
|
1926
|
+
panel.appendChild(area);
|
|
1927
|
+
modal.appendChild(panel);
|
|
1928
|
+
modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
|
|
1929
|
+
modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
|
|
1930
|
+
document.body.appendChild(modal);
|
|
1931
|
+
// Focus the send button (Enter starts pane-pick) when present, else the read-only text. Electron
|
|
1932
|
+
// async-restores focus to <body>, so retry briefly (same as the composer).
|
|
1933
|
+
var modalFocusTarget = sendBtn || area;
|
|
1934
|
+
var modalFocusTries = 0;
|
|
1935
|
+
var tryFocusModal = function () {
|
|
1936
|
+
if (!document.getElementById('mc-modal')) return true;
|
|
1937
|
+
if (document.activeElement === modalFocusTarget) return true;
|
|
1938
|
+
try { modalFocusTarget.focus(); if (modalFocusTarget === area) modalFocusTarget.select(); } catch (e) {}
|
|
1939
|
+
return document.activeElement === modalFocusTarget;
|
|
1940
|
+
};
|
|
1941
|
+
if (!tryFocusModal()) {
|
|
1942
|
+
var modalFocusIv = setInterval(function () { if (tryFocusModal() || ++modalFocusTries > 12) clearInterval(modalFocusIv); }, 25);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
document.addEventListener('click', function (event) {
|
|
1947
|
+
var t = event.target;
|
|
1948
|
+
if (!t || !t.closest) return;
|
|
1949
|
+
var del = t.closest('.mc-del');
|
|
1950
|
+
if (del) { event.preventDefault(); deleteComment(parseInt(del.dataset.seq, 10)); return; }
|
|
1951
|
+
if (t.closest('.mc-save')) { event.preventDefault(); saveComposer(); return; }
|
|
1952
|
+
if (t.closest('.mc-cancel')) { event.preventDefault(); closeComposer(); return; }
|
|
1953
|
+
});
|
|
1954
|
+
document.addEventListener('keydown', function (event) {
|
|
1955
|
+
var t = event.target;
|
|
1956
|
+
if (!t || !t.classList || !t.classList.contains('mc-input')) return;
|
|
1957
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeComposer(); return; }
|
|
1958
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); saveComposer(t); return; }
|
|
1959
|
+
}, true);
|
|
1960
|
+
|
|
1961
|
+
refreshComments();
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
// Integrated terminal (Electron only): xterm panes wired to node-pty sessions in the main process.
|
|
1965
|
+
// Toggle with Ctrl+` / Opt+F12 / the footer ⌗ button; Cmd/Ctrl+D splits the active pane (side by side,
|
|
1966
|
+
// no tabs); drag the top edge to resize. window.__monacoriTerminal pipes the merged prompt into the
|
|
1967
|
+
// active pane. Cmd combos are released back to the app so shortcuts like Cmd+1 don't get stuck typing.
|
|
1968
|
+
(function setupTerminal() {
|
|
1969
|
+
if (!window.monacoriPty) return; // xterm (window.Terminal) is loaded lazily on first open
|
|
1970
|
+
var panel = document.getElementById('terminal-panel');
|
|
1971
|
+
var host = document.getElementById('terminal-host');
|
|
1972
|
+
var toggleBtn = document.getElementById('terminal-toggle');
|
|
1973
|
+
var closeBtn = document.getElementById('terminal-close');
|
|
1974
|
+
var resizer = panel ? panel.querySelector('.terminal-resizer') : null;
|
|
1975
|
+
if (!panel || !host) return;
|
|
1976
|
+
if (toggleBtn) toggleBtn.classList.remove('hidden'); // reveal the footer toggle in Electron
|
|
1977
|
+
|
|
1978
|
+
// xterm ships as an inert island (id=xterm-code) so ~490KB isn't parsed at startup. Inject it on the
|
|
1979
|
+
// first open; returns false if unavailable (e.g. the island is absent), so callers can bail gracefully.
|
|
1980
|
+
function ensureXterm() {
|
|
1981
|
+
if (typeof window.Terminal === 'function') return true;
|
|
1982
|
+
var code = document.getElementById('xterm-code');
|
|
1983
|
+
if (!code) return false;
|
|
1984
|
+
try {
|
|
1985
|
+
var s = document.createElement('script');
|
|
1986
|
+
s.textContent = code.textContent;
|
|
1987
|
+
document.head.appendChild(s);
|
|
1988
|
+
code.remove(); // free the inert text once compiled
|
|
1989
|
+
} catch (e) { return false; }
|
|
1990
|
+
return typeof window.Terminal === 'function';
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
var panes = []; // { id, term, fit, el }
|
|
1994
|
+
var active = null;
|
|
1995
|
+
var MAX_PANES = 4;
|
|
1996
|
+
var heightKey = 'monacori-terminal-height';
|
|
1997
|
+
var openKey = 'monacori-terminal-open:' + location.pathname;
|
|
1998
|
+
|
|
1999
|
+
function applyHeight(px) {
|
|
2000
|
+
var h = Math.max(120, Math.min(px, window.innerHeight - 120));
|
|
2001
|
+
document.documentElement.style.setProperty('--terminal-height', h + 'px');
|
|
2002
|
+
}
|
|
2003
|
+
var savedH = parseInt(localStorage.getItem(heightKey) || '', 10);
|
|
2004
|
+
if (savedH) applyHeight(savedH);
|
|
2005
|
+
|
|
2006
|
+
function fitPane(p) {
|
|
2007
|
+
if (!p) return;
|
|
2008
|
+
try { p.fit.fit(); if (p.id != null) window.monacoriPty.resize({ id: p.id, cols: p.term.cols, rows: p.term.rows }); } catch (e) {}
|
|
2009
|
+
}
|
|
2010
|
+
function fitAll() { panes.forEach(fitPane); }
|
|
2011
|
+
|
|
2012
|
+
function setActive(p) {
|
|
2013
|
+
active = p;
|
|
2014
|
+
panes.forEach(function (q) { q.el.classList.toggle('is-active', q === p); });
|
|
2015
|
+
if (p) requestAnimationFrame(function () { try { p.term.focus(); } catch (e) {} });
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function makePane() {
|
|
2019
|
+
if (!ensureXterm()) return null; // xterm unavailable — leave the panel empty rather than throw
|
|
2020
|
+
var el = document.createElement('div');
|
|
2021
|
+
el.className = 'terminal-pane';
|
|
2022
|
+
var labelEl = document.createElement('div');
|
|
2023
|
+
labelEl.className = 'terminal-pane-label';
|
|
2024
|
+
var paneHost = document.createElement('div');
|
|
2025
|
+
paneHost.className = 'terminal-pane-host';
|
|
2026
|
+
el.appendChild(labelEl);
|
|
2027
|
+
el.appendChild(paneHost);
|
|
2028
|
+
host.appendChild(el);
|
|
2029
|
+
var term = new window.Terminal({
|
|
2030
|
+
fontSize: 12,
|
|
2031
|
+
fontFamily: 'Monaco, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
2032
|
+
theme: { background: '#161616', foreground: '#a9b7c6', cursor: '#a9b7c6', selectionBackground: '#214283' },
|
|
2033
|
+
cursorBlink: true,
|
|
2034
|
+
});
|
|
2035
|
+
var fit = new window.FitAddon.FitAddon();
|
|
2036
|
+
term.loadAddon(fit);
|
|
2037
|
+
term.open(paneHost);
|
|
2038
|
+
var pane = { id: null, term: term, fit: fit, el: el, labelEl: labelEl, name: 'Terminal ' + (panes.length + 1) };
|
|
2039
|
+
labelEl.textContent = pane.name;
|
|
2040
|
+
// Cmd combos are app shortcuts (Cmd+1/0 tab switch, Cmd+B go-to-def, …). Release the terminal and let
|
|
2041
|
+
// them bubble to the document handler instead of typing into the shell (fixes "Cmd+1 stuck in term").
|
|
2042
|
+
// Exception: keep focus for clipboard/selection combos (Cmd+C/V/X/A) so the terminal's own copy &
|
|
2043
|
+
// paste keep working — blurring on Cmd+V drops the textarea focus the paste event needs.
|
|
2044
|
+
term.attachCustomKeyEventHandler(function (e) {
|
|
2045
|
+
if (e.type === 'keydown' && e.metaKey) {
|
|
2046
|
+
var k = (e.key || '').toLowerCase();
|
|
2047
|
+
if (k === 'c' || k === 'v' || k === 'x' || k === 'a') return true;
|
|
2048
|
+
try { term.blur(); } catch (x) {}
|
|
2049
|
+
return false;
|
|
2050
|
+
}
|
|
2051
|
+
return true;
|
|
2052
|
+
});
|
|
2053
|
+
term.onData(function (d) { if (pane.id != null) window.monacoriPty.write({ id: pane.id, data: d }); });
|
|
2054
|
+
el.addEventListener('mousedown', function (e) { if (e.target !== labelEl) setActive(pane); });
|
|
2055
|
+
labelEl.addEventListener('dblclick', function () { renamePane(pane); });
|
|
2056
|
+
panes.push(pane);
|
|
2057
|
+
try { fit.fit(); } catch (e) {}
|
|
2058
|
+
window.monacoriPty.spawn({ cols: term.cols || 80, rows: term.rows || 24 }).then(function (r) { pane.id = r && r.id; });
|
|
2059
|
+
setActive(pane);
|
|
2060
|
+
return pane;
|
|
2061
|
+
}
|
|
2062
|
+
// Rename a pane inline: the label becomes editable, Enter commits, Esc/blur reverts to the last name.
|
|
2063
|
+
function renamePane(pane) {
|
|
2064
|
+
if (!pane) { pane = active; }
|
|
2065
|
+
if (!pane) return;
|
|
2066
|
+
var el = pane.labelEl;
|
|
2067
|
+
if (el.getAttribute('contenteditable') === 'true') return;
|
|
2068
|
+
setActive(pane);
|
|
2069
|
+
el.contentEditable = 'true';
|
|
2070
|
+
el.focus();
|
|
2071
|
+
try { var range = document.createRange(); range.selectNodeContents(el); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } catch (e) {}
|
|
2072
|
+
function finish(commit) {
|
|
2073
|
+
el.removeEventListener('keydown', onKey);
|
|
2074
|
+
el.removeEventListener('blur', onBlur);
|
|
2075
|
+
el.contentEditable = 'false';
|
|
2076
|
+
if (commit) pane.name = (el.textContent || '').trim() || pane.name;
|
|
2077
|
+
el.textContent = pane.name;
|
|
2078
|
+
try { if (pane.term) pane.term.focus(); } catch (e) {}
|
|
2079
|
+
}
|
|
2080
|
+
function onKey(e) {
|
|
2081
|
+
e.stopPropagation();
|
|
2082
|
+
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
|
|
2083
|
+
else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
|
|
2084
|
+
}
|
|
2085
|
+
function onBlur() { finish(true); }
|
|
2086
|
+
el.addEventListener('keydown', onKey);
|
|
2087
|
+
el.addEventListener('blur', onBlur);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function removePane(id) {
|
|
2091
|
+
var i = -1;
|
|
2092
|
+
for (var k = 0; k < panes.length; k++) { if (panes[k].id === id) { i = k; break; } }
|
|
2093
|
+
if (i < 0) return;
|
|
2094
|
+
var p = panes[i];
|
|
2095
|
+
try { p.term.dispose(); } catch (e) {}
|
|
2096
|
+
if (p.el.parentNode) p.el.parentNode.removeChild(p.el);
|
|
2097
|
+
panes.splice(i, 1);
|
|
2098
|
+
if (active === p) setActive(panes[panes.length - 1] || null);
|
|
2099
|
+
if (panes.length === 0) setOpen(false);
|
|
2100
|
+
else fitAll();
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function split() {
|
|
2104
|
+
if (panes.length >= MAX_PANES) return;
|
|
2105
|
+
makePane();
|
|
2106
|
+
fitAll();
|
|
2107
|
+
}
|
|
2108
|
+
// Move active focus between split panes (menu accelerators Cmd/Ctrl+Alt+[ and ]).
|
|
2109
|
+
function focusPaneByDelta(delta) {
|
|
2110
|
+
if (panes.length < 2) return;
|
|
2111
|
+
var i = panes.indexOf(active);
|
|
2112
|
+
if (i < 0) i = 0;
|
|
2113
|
+
setActive(panes[(i + delta + panes.length) % panes.length]);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// Route per-pane pty output / exit by id (registered once for the window).
|
|
2117
|
+
window.monacoriPty.onData(function (msg) {
|
|
2118
|
+
for (var k = 0; k < panes.length; k++) { if (panes[k].id === msg.id) { panes[k].term.write(msg.data); return; } }
|
|
2119
|
+
});
|
|
2120
|
+
window.monacoriPty.onExit(function (msg) { removePane(msg.id); });
|
|
2121
|
+
|
|
2122
|
+
function isOpen() { return !panel.classList.contains('hidden'); }
|
|
2123
|
+
function setOpen(open) {
|
|
2124
|
+
panel.classList.toggle('hidden', !open);
|
|
2125
|
+
document.body.classList.toggle('terminal-open', open);
|
|
2126
|
+
if (toggleBtn) toggleBtn.classList.toggle('is-active', open);
|
|
2127
|
+
try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch (e) {}
|
|
2128
|
+
if (open) {
|
|
2129
|
+
if (panes.length === 0) makePane();
|
|
2130
|
+
requestAnimationFrame(function () { fitAll(); if (active) try { active.term.focus(); } catch (e) {} });
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
function toggle() { setOpen(!isOpen()); }
|
|
2134
|
+
|
|
2135
|
+
if (toggleBtn) toggleBtn.addEventListener('click', toggle);
|
|
2136
|
+
if (closeBtn) closeBtn.addEventListener('click', function () { setOpen(false); });
|
|
2137
|
+
// Toggle (Ctrl+`/Alt+F12) and split (Cmd+D) arrive from the Terminal menu accelerators (app-main),
|
|
2138
|
+
// because Chromium swallows Cmd+D before a renderer keydown would ever see it.
|
|
2139
|
+
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalToggle === 'function') window.monacoriMenu.onTerminalToggle(toggle);
|
|
2140
|
+
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalSplit === 'function') window.monacoriMenu.onTerminalSplit(split);
|
|
2141
|
+
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneFocus === 'function') window.monacoriMenu.onTerminalPaneFocus(focusPaneByDelta);
|
|
2142
|
+
if (window.monacoriMenu && typeof window.monacoriMenu.onTerminalPaneRename === 'function') window.monacoriMenu.onTerminalPaneRename(function () { renamePane(active); });
|
|
2143
|
+
|
|
2144
|
+
var ro = (typeof ResizeObserver === 'function') ? new ResizeObserver(function () { if (isOpen()) fitAll(); }) : null;
|
|
2145
|
+
if (ro) ro.observe(host);
|
|
2146
|
+
window.addEventListener('resize', function () { if (isOpen()) fitAll(); });
|
|
2147
|
+
|
|
2148
|
+
if (resizer) {
|
|
2149
|
+
resizer.addEventListener('mousedown', function (e) {
|
|
2150
|
+
e.preventDefault();
|
|
2151
|
+
resizer.classList.add('resizing');
|
|
2152
|
+
function move(ev) { applyHeight(window.innerHeight - ev.clientY); }
|
|
2153
|
+
function up() {
|
|
2154
|
+
resizer.classList.remove('resizing');
|
|
2155
|
+
document.removeEventListener('mousemove', move);
|
|
2156
|
+
document.removeEventListener('mouseup', up);
|
|
2157
|
+
var cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--terminal-height'), 10);
|
|
2158
|
+
if (cur) { try { localStorage.setItem(heightKey, String(cur)); } catch (e) {} }
|
|
2159
|
+
fitAll();
|
|
2160
|
+
}
|
|
2161
|
+
document.addEventListener('mousemove', move);
|
|
2162
|
+
document.addEventListener('mouseup', up);
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// Kill this window's ptys on unload so a reload/close doesn't leak them in the main process.
|
|
2167
|
+
window.addEventListener('beforeunload', function () {
|
|
2168
|
+
panes.forEach(function (p) { if (p.id != null) { try { window.monacoriPty.kill({ id: p.id }); } catch (e) {} } });
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
// Hook for the merged-prompt modal: pipe the combined text into a chosen pane (no trailing Enter —
|
|
2172
|
+
// the user reviews in the live session, then presses Enter, so multiline prompts stay intact).
|
|
2173
|
+
function writeToPane(p, text) {
|
|
2174
|
+
if (!p) return;
|
|
2175
|
+
setOpen(true);
|
|
2176
|
+
if (p.id != null) window.monacoriPty.write({ id: p.id, data: text });
|
|
2177
|
+
setActive(p);
|
|
2178
|
+
requestAnimationFrame(function () { try { p.term.focus(); } catch (e) {} });
|
|
2179
|
+
}
|
|
2180
|
+
// Pane-pick mode: triggered from the merged modal's "Send to terminal". The chosen pane is highlighted,
|
|
2181
|
+
// the rest are dimmed; arrows change the pick, Enter sends, Esc cancels. Single pane → send at once.
|
|
2182
|
+
var sendModeText = null, sendModeIdx = 0;
|
|
2183
|
+
function paintSendMode() {
|
|
2184
|
+
panes.forEach(function (p, i) {
|
|
2185
|
+
p.el.classList.toggle('is-send-target', i === sendModeIdx);
|
|
2186
|
+
p.el.classList.toggle('is-dimmed', i !== sendModeIdx);
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
function exitSendMode() {
|
|
2190
|
+
if (sendModeText == null) return;
|
|
2191
|
+
sendModeText = null;
|
|
2192
|
+
panel.classList.remove('send-mode');
|
|
2193
|
+
document.body.classList.remove('terminal-send-mode'); // un-dim the rest of the app
|
|
2194
|
+
panes.forEach(function (p) { p.el.classList.remove('is-send-target', 'is-dimmed'); });
|
|
2195
|
+
}
|
|
2196
|
+
function enterSendMode(text) {
|
|
2197
|
+
if (panes.length === 0) return;
|
|
2198
|
+
setOpen(true);
|
|
2199
|
+
sendModeText = text;
|
|
2200
|
+
sendModeIdx = Math.max(0, panes.indexOf(active));
|
|
2201
|
+
panel.classList.add('send-mode');
|
|
2202
|
+
document.body.classList.add('terminal-send-mode'); // dim sidebar + file/diff view; only the terminal pops
|
|
2203
|
+
paintSendMode();
|
|
2204
|
+
}
|
|
2205
|
+
// Capture phase so the pick keys win over the focused xterm; while picking, every key is swallowed.
|
|
2206
|
+
document.addEventListener('keydown', function (e) {
|
|
2207
|
+
if (sendModeText == null) return;
|
|
2208
|
+
e.preventDefault(); e.stopPropagation();
|
|
2209
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
2210
|
+
var d = (e.key === 'ArrowRight' || e.key === 'ArrowDown') ? 1 : -1;
|
|
2211
|
+
sendModeIdx = (sendModeIdx + d + panes.length) % panes.length;
|
|
2212
|
+
paintSendMode();
|
|
2213
|
+
} else if (e.key === 'Enter') {
|
|
2214
|
+
var p = panes[sendModeIdx], text = sendModeText;
|
|
2215
|
+
exitSendMode();
|
|
2216
|
+
writeToPane(p, text);
|
|
2217
|
+
} else if (e.key === 'Escape') {
|
|
2218
|
+
exitSendMode();
|
|
2219
|
+
}
|
|
2220
|
+
}, true);
|
|
2221
|
+
window.__monacoriTerminal = {
|
|
2222
|
+
isOpen: isOpen,
|
|
2223
|
+
open: function () { setOpen(true); },
|
|
2224
|
+
paneCount: function () { return panes.length; },
|
|
2225
|
+
enterSendMode: enterSendMode,
|
|
2226
|
+
send: function (text) { writeToPane(active || panes[0], text); },
|
|
2227
|
+
sendToPane: function (i, text) { writeToPane(panes[i] || active || panes[0], text); },
|
|
2228
|
+
close: function () { setOpen(false); },
|
|
2229
|
+
};
|
|
2230
|
+
|
|
2231
|
+
// Restore the open state across reloads.
|
|
2232
|
+
try { if (sessionStorage.getItem(openKey) === '1') setOpen(true); } catch (e) {}
|
|
2233
|
+
})();
|
|
2234
|
+
|
|
2235
|
+
// In Electron, the Review menu's Cmd/Ctrl+Shift+/ and +. accelerators arrive here via IPC
|
|
2236
|
+
// (macOS reserves Cmd+? for its Help search, so the menu claims it and routes to these views).
|
|
2237
|
+
if (window.monacoriMenu && typeof window.monacoriMenu.onMergedView === 'function') {
|
|
2238
|
+
// Always open the merged-view modal; sending to a terminal pane is a button inside it (per-pane when
|
|
2239
|
+
// split), so the user can pick which claude/codex session receives the prompt.
|
|
2240
|
+
window.monacoriMenu.onMergedView(function (kind) { openMergedView(kind); });
|
|
2241
|
+
}
|
|
2242
|
+
if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function') {
|
|
2243
|
+
// Cmd/Ctrl+W: close the active Files-mode tab (no-op outside the source viewer).
|
|
2244
|
+
window.monacoriMenu.onCloseTab(function () {
|
|
2245
|
+
// Cmd/Ctrl+W closes the terminal panel first when it's open, otherwise the active Files-mode tab.
|
|
2246
|
+
if (window.__monacoriTerminal && window.__monacoriTerminal.isOpen()) { window.__monacoriTerminal.close(); return; }
|
|
2247
|
+
if (isSourceViewerVisible()) closeActiveSourceTab();
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
(function checkForUpdate() {
|
|
2252
|
+
var current = window.__MONACORI_VERSION__ || '';
|
|
2253
|
+
if (!current) return;
|
|
2254
|
+
var isNewer = function (a, b) {
|
|
2255
|
+
var pa = String(a).split('.'), pb = String(b).split('.');
|
|
2256
|
+
for (var i = 0; i < 3; i++) {
|
|
2257
|
+
var x = parseInt(pa[i], 10) || 0, y = parseInt(pb[i], 10) || 0;
|
|
2258
|
+
if (x > y) return true;
|
|
2259
|
+
if (x < y) return false;
|
|
2260
|
+
}
|
|
2261
|
+
return false;
|
|
2262
|
+
};
|
|
2263
|
+
var apply = function (latest) {
|
|
2264
|
+
if (!latest) return;
|
|
2265
|
+
var status = document.getElementById('app-info-status');
|
|
2266
|
+
if (isNewer(latest, current)) {
|
|
2267
|
+
var flag = document.getElementById('app-update-flag');
|
|
2268
|
+
if (flag) flag.classList.remove('hidden');
|
|
2269
|
+
// One-click auto-update needs the Electron main process (it spawns npm). When available, reveal the
|
|
2270
|
+
// button so a click installs + restarts; otherwise (browser/static export) name the command instead.
|
|
2271
|
+
var ub = document.getElementById('app-info-update');
|
|
2272
|
+
if (ub && window.monacoriUpdate && typeof window.monacoriUpdate.run === 'function') {
|
|
2273
|
+
ub.textContent = t('settings.updateRestart') + ' (v' + latest + ')';
|
|
2274
|
+
ub.classList.remove('hidden');
|
|
2275
|
+
if (status) { status.textContent = t('settings.updateAvailable') + ': v' + latest; status.classList.add('has-update'); }
|
|
2276
|
+
} else if (status) {
|
|
2277
|
+
status.textContent = t('settings.updateAvailable') + ': v' + latest + ' — npm i -g @happy-nut/monacori';
|
|
2278
|
+
status.classList.add('has-update');
|
|
2279
|
+
}
|
|
2280
|
+
} else if (status) {
|
|
2281
|
+
status.textContent = t('settings.upToDate') + ' (v' + current + ')';
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
// Cache the npm result for the session so watch-mode reloads reuse it instead of refetching.
|
|
2285
|
+
var cached = '';
|
|
2286
|
+
try { cached = sessionStorage.getItem('monacori-update-latest') || ''; } catch (e) {}
|
|
2287
|
+
if (cached) { apply(cached); return; }
|
|
2288
|
+
if (typeof fetch !== 'function') return;
|
|
2289
|
+
fetch('https://registry.npmjs.org/@happy-nut/monacori/latest', { cache: 'no-store' })
|
|
2290
|
+
.then(function (res) { return res && res.ok ? res.json() : null; })
|
|
2291
|
+
.then(function (data) {
|
|
2292
|
+
if (!data || !data.version) return;
|
|
2293
|
+
try { sessionStorage.setItem('monacori-update-latest', data.version); } catch (e) {}
|
|
2294
|
+
apply(data.version);
|
|
2295
|
+
})
|
|
2296
|
+
.catch(function () {});
|
|
2297
|
+
})();
|
|
2298
|
+
|
|
2299
|
+
// Unified settings modal: the sidebar-footer gear opens it (General category by default), with
|
|
2300
|
+
// About/update/shortcuts under General and the merge-prompt editor under Merge prompts.
|
|
2301
|
+
(function setupSettings() {
|
|
2302
|
+
var modal = document.getElementById('settings-modal');
|
|
2303
|
+
if (!modal) return;
|
|
2304
|
+
var gearBtn = document.getElementById('app-info-btn');
|
|
2305
|
+
var flag = document.getElementById('app-update-flag');
|
|
2306
|
+
var updateBtn = document.getElementById('app-info-update');
|
|
2307
|
+
var qta = document.getElementById('settings-prompt-q');
|
|
2308
|
+
var cta = document.getElementById('settings-prompt-c');
|
|
2309
|
+
var resetBtn = document.getElementById('settings-reset');
|
|
2310
|
+
var savedMsg = document.getElementById('settings-saved');
|
|
2311
|
+
var cats = Array.prototype.slice.call(modal.querySelectorAll('.settings-cat'));
|
|
2312
|
+
var secs = Array.prototype.slice.call(modal.querySelectorAll('.settings-section'));
|
|
2313
|
+
function showCat(cat) {
|
|
2314
|
+
cats.forEach(function (c) { c.classList.toggle('active', c.dataset.cat === cat); });
|
|
2315
|
+
secs.forEach(function (s) { s.classList.toggle('hidden', s.dataset.cat !== cat); });
|
|
2316
|
+
}
|
|
2317
|
+
function fill() {
|
|
2318
|
+
var s = loadMergePrompts();
|
|
2319
|
+
if (qta) { qta.value = typeof s.q === 'string' ? s.q : ''; qta.placeholder = defaultMergePrompt('q'); }
|
|
2320
|
+
if (cta) { cta.value = typeof s.c === 'string' ? s.c : ''; cta.placeholder = defaultMergePrompt('c'); }
|
|
2321
|
+
}
|
|
2322
|
+
function open(cat) { fill(); if (cat) showCat(cat); modal.classList.remove('hidden'); }
|
|
2323
|
+
function close() { modal.classList.add('hidden'); }
|
|
2324
|
+
var flashTimer = null;
|
|
2325
|
+
function flash() { if (!savedMsg) return; savedMsg.textContent = 'Saved'; if (flashTimer) clearTimeout(flashTimer); flashTimer = setTimeout(function () { savedMsg.textContent = ''; }, 1200); }
|
|
2326
|
+
if (gearBtn) gearBtn.addEventListener('click', function (e) { e.stopPropagation(); if (modal.classList.contains('hidden')) open('general'); else close(); });
|
|
2327
|
+
if (flag) flag.addEventListener('click', function (e) { e.stopPropagation(); open('general'); });
|
|
2328
|
+
cats.forEach(function (c) { c.addEventListener('click', function () { showCat(c.dataset.cat); }); });
|
|
2329
|
+
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
|
|
2330
|
+
// Capture so closing settings wins over other Escape handlers (lightbox / composer).
|
|
2331
|
+
document.addEventListener('keydown', function (e) {
|
|
2332
|
+
if (e.key === 'Escape' && !modal.classList.contains('hidden')) { e.stopPropagation(); e.preventDefault(); close(); return; }
|
|
2333
|
+
// Cmd/Ctrl+, (the standard "Preferences" accelerator) toggles the settings panel from anywhere.
|
|
2334
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === ',' || e.code === 'Comma')) {
|
|
2335
|
+
e.preventDefault(); e.stopPropagation();
|
|
2336
|
+
if (modal.classList.contains('hidden')) open('general'); else close();
|
|
2337
|
+
}
|
|
2338
|
+
}, true);
|
|
2339
|
+
// One-click self-update (Electron only): install latest globally via the main process, then relaunch.
|
|
2340
|
+
if (updateBtn && window.monacoriUpdate && typeof window.monacoriUpdate.run === 'function') {
|
|
2341
|
+
updateBtn.addEventListener('click', function () {
|
|
2342
|
+
if (updateBtn.disabled) return;
|
|
2343
|
+
updateBtn.disabled = true;
|
|
2344
|
+
var status = document.getElementById('app-info-status');
|
|
2345
|
+
if (status) { status.textContent = t('settings.updating'); status.classList.add('has-update'); }
|
|
2346
|
+
window.monacoriUpdate.run().then(function (r) {
|
|
2347
|
+
if (r && r.ok) { if (status) status.textContent = t('settings.updated'); }
|
|
2348
|
+
else { updateBtn.disabled = false; if (status) status.textContent = t('settings.updateFailed'); }
|
|
2349
|
+
}).catch(function () { updateBtn.disabled = false; if (status) status.textContent = t('settings.updateFailed'); });
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
if (qta) qta.addEventListener('input', function () { saveMergePrompt('q', qta.value); flash(); });
|
|
2353
|
+
if (cta) cta.addEventListener('input', function () { saveMergePrompt('c', cta.value); flash(); });
|
|
2354
|
+
if (resetBtn) resetBtn.addEventListener('click', function () { saveMergePrompt('q', ''); saveMergePrompt('c', ''); fill(); flash(); });
|
|
2355
|
+
// Language: live-switch the whole UI (no reload). Persist, re-apply the static chrome, then re-render
|
|
2356
|
+
// any currently-shown dynamic text (open composer / merged modal / index status) so it follows too.
|
|
2357
|
+
var langSel = document.getElementById('settings-language');
|
|
2358
|
+
if (langSel) {
|
|
2359
|
+
langSel.value = locale;
|
|
2360
|
+
langSel.addEventListener('change', function () {
|
|
2361
|
+
var next = langSel.value === 'ko' ? 'ko' : 'en';
|
|
2362
|
+
if (next === locale) return;
|
|
2363
|
+
locale = next;
|
|
2364
|
+
persistSave(LOCALE_KEY, locale);
|
|
2365
|
+
applyI18n();
|
|
2366
|
+
// Merge-prompt placeholders are locale-dependent defaults; refresh them while the panel is open.
|
|
2367
|
+
fill();
|
|
2368
|
+
// Re-render dynamic, currently-visible text in the new locale.
|
|
2369
|
+
try { if (typeof refreshComments === 'function') refreshComments(); } catch (e) {}
|
|
2370
|
+
var mergedModal = document.getElementById('mc-modal');
|
|
2371
|
+
if (mergedModal) { var mk = mergedModal.dataset.kind || 'q'; mergedModal.remove(); openMergedView(mk); }
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
})();
|
|
2375
|
+
|
|
2376
|
+
function setTab(name) {
|
|
2377
|
+
if (name === 'files') ensureTreeRendered();
|
|
2378
|
+
document.querySelectorAll('.tab').forEach((button) => {
|
|
2379
|
+
button.classList.toggle('active', button.dataset.tab === name);
|
|
2380
|
+
});
|
|
2381
|
+
document.getElementById('changes-panel')?.classList.toggle('hidden', name !== 'changes');
|
|
2382
|
+
document.getElementById('files-panel')?.classList.toggle('hidden', name !== 'files');
|
|
2383
|
+
}
|
|
2384
|
+
// Big repos ship the source tree as an inert island (see render.ts); build it the first time the Files
|
|
2385
|
+
// tab is opened so the (potentially huge) tree never blocks startup. No-op for inline (small) trees.
|
|
2386
|
+
function ensureTreeRendered() {
|
|
2387
|
+
var panel = document.getElementById('files-panel');
|
|
2388
|
+
var island = document.getElementById('files-tree-html');
|
|
2389
|
+
if (!panel || !island) return;
|
|
2390
|
+
var html = island.textContent || '';
|
|
2391
|
+
island.parentNode && island.parentNode.removeChild(island);
|
|
2392
|
+
panel.innerHTML = '<div class="empty-nav">' + escapeHtml(t('source.buildingTree')) + '</div>';
|
|
2393
|
+
setTimeout(function () { // let "Building…" paint before the heavy innerHTML
|
|
2394
|
+
panel.innerHTML = html;
|
|
2395
|
+
sourceLinks = Array.from(document.querySelectorAll('.source-link'));
|
|
2396
|
+
if (typeof refreshComments === 'function') { try { refreshComments(); } catch (e) {} } // re-render per-file badges
|
|
2397
|
+
}, 0);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
function showDiffView(shouldScroll) {
|
|
2401
|
+
document.getElementById('source-viewer')?.classList.add('hidden');
|
|
2402
|
+
document.getElementById('diff-view')?.classList.remove('hidden');
|
|
2403
|
+
setTab('changes');
|
|
2404
|
+
if (current < 0 && hunkTotal()) {
|
|
2405
|
+
setActive(0, shouldScroll);
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
if (current >= 0) {
|
|
2409
|
+
const cidx = current;
|
|
2410
|
+
whenFileReady(diffWrapperByPath(hunkPathAt(cidx)), function () {
|
|
2411
|
+
const curRow = document.getElementById('hunk-' + cidx);
|
|
2412
|
+
if (curRow) {
|
|
2413
|
+
showOnlyFile(hunkPathAt(cidx));
|
|
2414
|
+
if (shouldScroll) curRow.scrollIntoView({ block: 'start' });
|
|
2415
|
+
}
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
function showSourceView() {
|
|
2421
|
+
document.getElementById('diff-view')?.classList.add('hidden');
|
|
2422
|
+
document.getElementById('source-viewer')?.classList.remove('hidden');
|
|
2423
|
+
setTab('files');
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function saveUiState() {
|
|
2427
|
+
const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'changes';
|
|
2428
|
+
const sourcePath = document.getElementById('source-viewer')?.dataset.openPath || '';
|
|
2429
|
+
sessionStorage.setItem(uiStateKey, JSON.stringify({
|
|
2430
|
+
tab: activeTab,
|
|
2431
|
+
view: document.getElementById('source-viewer')?.classList.contains('hidden') ? 'diff' : 'source',
|
|
2432
|
+
sourcePath,
|
|
2433
|
+
hash: location.hash,
|
|
2434
|
+
// Preserve open tabs + the exact caret across watch reloads (otherwise the caret resets to the
|
|
2435
|
+
// hunk's first change / file top every time the working tree changes).
|
|
2436
|
+
tabs: sourceTabs,
|
|
2437
|
+
diffCursor: diffCursor,
|
|
2438
|
+
viewerCursor: viewerCursor,
|
|
2439
|
+
}));
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function restoreUiState() {
|
|
2443
|
+
const raw = sessionStorage.getItem(uiStateKey);
|
|
2444
|
+
if (!raw) return false;
|
|
2445
|
+
try {
|
|
2446
|
+
const state = JSON.parse(raw);
|
|
2447
|
+
// Restore Files-mode tabs first so a watch reload doesn't drop the open tabs.
|
|
2448
|
+
if (Array.isArray(state.tabs)) sourceTabs = state.tabs.filter(function (p) { return sourceByPath.has(p); });
|
|
2449
|
+
if (state.view === 'diff') {
|
|
2450
|
+
const match = String(state.hash || location.hash || '').match(/^#hunk-(\d+)$/);
|
|
2451
|
+
setActive(match ? Number(match[1]) : current >= 0 ? current : 0, false);
|
|
2452
|
+
// Restore the exact diff caret (setActive only lands on the hunk's first change).
|
|
2453
|
+
if (state.diffCursor && state.diffCursor.path) {
|
|
2454
|
+
var dc = state.diffCursor;
|
|
2455
|
+
setTimeout(function () { try { setDiffCursor(dc.path, dc.side, dc.rowIndex, dc.column, true); } catch (e) {} }, 60);
|
|
2456
|
+
}
|
|
2457
|
+
return true;
|
|
2458
|
+
}
|
|
2459
|
+
if (state.sourcePath && sourceByPath.has(state.sourcePath)) {
|
|
2460
|
+
openSourceFile(state.sourcePath);
|
|
2461
|
+
// Restore the exact source caret/scroll (openSourceFile alone resets it to the top).
|
|
2462
|
+
if (state.viewerCursor && state.viewerCursor.path === state.sourcePath) {
|
|
2463
|
+
var vc = state.viewerCursor;
|
|
2464
|
+
setTimeout(function () { try { setSourceCursor(state.sourcePath, vc.lineIndex, vc.column, true, -1); } catch (e) {} }, 60);
|
|
2465
|
+
}
|
|
2466
|
+
return true;
|
|
2467
|
+
}
|
|
2468
|
+
} catch {
|
|
2469
|
+
sessionStorage.removeItem(uiStateKey);
|
|
2470
|
+
}
|
|
2471
|
+
return false;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
async function checkForLiveUpdate() {
|
|
2475
|
+
if (checkingForUpdates) return;
|
|
2476
|
+
checkingForUpdates = true;
|
|
2477
|
+
const liveStatus = document.getElementById('live-status');
|
|
2478
|
+
try {
|
|
2479
|
+
const response = await fetch('/__ai_flow_state', { cache: 'no-store' });
|
|
2480
|
+
if (!response.ok) return;
|
|
2481
|
+
const state = await response.json();
|
|
2482
|
+
if (liveStatus && state.generatedAt) {
|
|
2483
|
+
liveStatus.textContent = t('status.live.updated') + ' ' + new Date(state.generatedAt).toLocaleTimeString();
|
|
2484
|
+
}
|
|
2485
|
+
if (state.signature && state.signature !== currentSignature) {
|
|
2486
|
+
saveUiState();
|
|
2487
|
+
location.reload();
|
|
2488
|
+
}
|
|
2489
|
+
} catch {
|
|
2490
|
+
if (liveStatus) liveStatus.textContent = t('status.live.waiting');
|
|
2491
|
+
} finally {
|
|
2492
|
+
checkingForUpdates = false;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
function filterNavigation(rawQuery) {
|
|
2497
|
+
const query = rawQuery.trim().toLowerCase();
|
|
2498
|
+
links.forEach((link) => {
|
|
2499
|
+
const path = link.dataset.file || '';
|
|
2500
|
+
const source = sourceByPath.get(path);
|
|
2501
|
+
const haystack = (path + '\n' + (source?.content || '')).toLowerCase();
|
|
2502
|
+
link.hidden = query.length > 0 && !haystack.includes(query);
|
|
2503
|
+
});
|
|
2504
|
+
sourceLinks.forEach((link) => {
|
|
2505
|
+
const path = link.dataset.sourceFile || '';
|
|
2506
|
+
const source = sourceByPath.get(path);
|
|
2507
|
+
const haystack = (path + '\n' + (source?.content || '')).toLowerCase();
|
|
2508
|
+
link.hidden = query.length > 0 && !haystack.includes(query);
|
|
2509
|
+
});
|
|
2510
|
+
updateTreeVisibility(document.getElementById('changes-panel'), query);
|
|
2511
|
+
updateTreeVisibility(document.getElementById('files-panel'), query);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
function updateTreeVisibility(root, query) {
|
|
2515
|
+
if (!root) return;
|
|
2516
|
+
Array.from(root.querySelectorAll('details')).reverse().forEach((details) => {
|
|
2517
|
+
const hasVisibleLeaf = Array.from(details.children).some((child) => {
|
|
2518
|
+
if (child.tagName === 'SUMMARY') return false;
|
|
2519
|
+
return !child.hidden;
|
|
2520
|
+
});
|
|
2521
|
+
details.hidden = query.length > 0 && !hasVisibleLeaf;
|
|
2522
|
+
if (query.length > 0 && hasVisibleLeaf) details.open = true;
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
function openDefaultSourceFile() {
|
|
2527
|
+
const file = sourceFiles.find((candidate) => candidate.changed && candidate.embedded)
|
|
2528
|
+
|| sourceFiles.find((candidate) => candidate.embedded)
|
|
2529
|
+
|| sourceFiles.find((candidate) => candidate.changed)
|
|
2530
|
+
|| sourceFiles[0];
|
|
2531
|
+
if (file) {
|
|
2532
|
+
openSourceFile(file.path);
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
if (hunkTotal() > 0) setActive(0, false);
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
function handleSourceCopy(event) {
|
|
2539
|
+
const selection = window.getSelection();
|
|
2540
|
+
const sourceBody = document.getElementById('source-body');
|
|
2541
|
+
const viewer = document.getElementById('source-viewer');
|
|
2542
|
+
if (!selection || selection.isCollapsed || !sourceBody || !viewer || viewer.classList.contains('hidden')) return;
|
|
2543
|
+
if (!selection.anchorNode || !selection.focusNode) return;
|
|
2544
|
+
if (!sourceBody.contains(selection.anchorNode) || !sourceBody.contains(selection.focusNode)) return;
|
|
2545
|
+
|
|
2546
|
+
const path = viewer.dataset.openPath || '';
|
|
2547
|
+
const file = sourceByPath.get(path);
|
|
2548
|
+
if (!file || !file.embedded) return;
|
|
2549
|
+
const rows = selectedSourceRows(selection);
|
|
2550
|
+
if (rows.length === 0) return;
|
|
2551
|
+
|
|
2552
|
+
const lineNumbers = rows
|
|
2553
|
+
.map((row) => Number(row.dataset.lineIndex || 0) + 1)
|
|
2554
|
+
.filter((line) => Number.isFinite(line))
|
|
2555
|
+
.sort((a, b) => a - b);
|
|
2556
|
+
const startLine = lineNumbers[0];
|
|
2557
|
+
const endLine = lineNumbers[lineNumbers.length - 1];
|
|
2558
|
+
if (!startLine || !endLine) return;
|
|
2559
|
+
|
|
2560
|
+
const selectedText = cleanSelectedSourceText(selection.toString(), rows);
|
|
2561
|
+
const code = selectedText || sourceLinesForRows(file, rows);
|
|
2562
|
+
if (!code.trim()) return;
|
|
2563
|
+
|
|
2564
|
+
const reference = path + ':' + (startLine === endLine ? String(startLine) : startLine + '-' + endLine);
|
|
2565
|
+
const language = file.language && file.language !== 'text' ? file.language : '';
|
|
2566
|
+
const fence = String.fromCharCode(96).repeat(3);
|
|
2567
|
+
const payload = reference + '\n\n' + fence + language + '\n' + code.replace(/\s+$/g, '') + '\n' + fence;
|
|
2568
|
+
event.clipboardData?.setData('text/plain', payload);
|
|
2569
|
+
event.preventDefault();
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
function selectedSourceRows(selection) {
|
|
2573
|
+
if (!selection.rangeCount) return [];
|
|
2574
|
+
const ranges = Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index));
|
|
2575
|
+
return Array.from(document.querySelectorAll('#source-body .source-row'))
|
|
2576
|
+
.filter((row) => ranges.some((range) => {
|
|
2577
|
+
try {
|
|
2578
|
+
return range.intersectsNode(row);
|
|
2579
|
+
} catch {
|
|
2580
|
+
return false;
|
|
2581
|
+
}
|
|
2582
|
+
}))
|
|
2583
|
+
.sort((a, b) => Number(a.dataset.lineIndex || 0) - Number(b.dataset.lineIndex || 0));
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
function cleanSelectedSourceText(text, rows) {
|
|
2587
|
+
const value = String(text || '').replace(/\r/g, '').replace(/\u200b/g, '');
|
|
2588
|
+
if (!value.trim()) return '';
|
|
2589
|
+
const lineNumbers = rows.map((row) => Number(row.dataset.lineIndex || 0) + 1);
|
|
2590
|
+
const lines = value.split('\n');
|
|
2591
|
+
if (lines.length >= lineNumbers.length) {
|
|
2592
|
+
return lines
|
|
2593
|
+
.map((line, index) => {
|
|
2594
|
+
const lineNumber = lineNumbers[index];
|
|
2595
|
+
return lineNumber ? line.replace(new RegExp('^\\s*' + lineNumber + '\\s+'), '') : line;
|
|
2596
|
+
})
|
|
2597
|
+
.join('\n')
|
|
2598
|
+
.trimEnd();
|
|
2599
|
+
}
|
|
2600
|
+
return value.trimEnd();
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
function sourceLinesForRows(file, rows) {
|
|
2604
|
+
const lines = file.content.split(/\r?\n/);
|
|
2605
|
+
return rows
|
|
2606
|
+
.map((row) => lines[Number(row.dataset.lineIndex || 0)] || '')
|
|
2607
|
+
.join('\n')
|
|
2608
|
+
.trimEnd();
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function handleSourceClick(event) {
|
|
2612
|
+
const target = event.target;
|
|
2613
|
+
const runBtn = target?.closest?.('.http-run');
|
|
2614
|
+
if (runBtn) {
|
|
2615
|
+
event.preventDefault();
|
|
2616
|
+
runHttpRequest(Number(runBtn.dataset.req));
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
const respToggle = target?.closest?.('.http-resp-toggle');
|
|
2620
|
+
if (respToggle) {
|
|
2621
|
+
event.preventDefault();
|
|
2622
|
+
const panel = respToggle.closest('.http-response')?.querySelector('.http-resp-headers');
|
|
2623
|
+
if (panel) panel.classList.toggle('hidden');
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
const row = target?.closest?.('.source-row');
|
|
2627
|
+
if (!row) return;
|
|
2628
|
+
clearTreeFocus();
|
|
2629
|
+
const viewer = document.getElementById('source-viewer');
|
|
2630
|
+
const path = viewer?.dataset.openPath || '';
|
|
2631
|
+
const file = sourceByPath.get(path);
|
|
2632
|
+
if (!file || !file.embedded) return;
|
|
2633
|
+
const lineIndex = Number(row.dataset.lineIndex || 0);
|
|
2634
|
+
const lines = file.content.split(/\r?\n/);
|
|
2635
|
+
const line = lines[lineIndex] || '';
|
|
2636
|
+
const codeCell = row.querySelector('.source-code');
|
|
2637
|
+
const column = estimateColumnFromClick(codeCell, event, line);
|
|
2638
|
+
setSourceCursor(path, lineIndex, column, false, -1);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
function estimateColumnFromClick(codeCell, event, line) {
|
|
2642
|
+
if (!codeCell) return 0;
|
|
2643
|
+
const rect = codeCell.getBoundingClientRect();
|
|
2644
|
+
const style = getComputedStyle(codeCell);
|
|
2645
|
+
const paddingLeft = Number.parseFloat(style.paddingLeft || '0') || 0;
|
|
2646
|
+
const x = event.clientX - rect.left - paddingLeft;
|
|
2647
|
+
const width = measuredCharWidth || measureCharWidth(codeCell);
|
|
2648
|
+
const column = Math.round(x / Math.max(width, 1));
|
|
2649
|
+
return Math.max(0, Math.min(line.length, column));
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
function measureCharWidth(element) {
|
|
2653
|
+
const probe = document.createElement('span');
|
|
2654
|
+
probe.textContent = 'mmmmmmmmmm';
|
|
2655
|
+
probe.style.position = 'absolute';
|
|
2656
|
+
probe.style.visibility = 'hidden';
|
|
2657
|
+
probe.style.whiteSpace = 'pre';
|
|
2658
|
+
probe.style.font = getComputedStyle(element).font;
|
|
2659
|
+
document.body.appendChild(probe);
|
|
2660
|
+
const width = probe.getBoundingClientRect().width / 10;
|
|
2661
|
+
probe.remove();
|
|
2662
|
+
measuredCharWidth = width || 7;
|
|
2663
|
+
return measuredCharWidth;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
var caretBusyTimer = null;
|
|
2667
|
+
// While the caret is actively moving (held arrow key, typing), keep it solid and only resume the
|
|
2668
|
+
// blink animation after a short idle. Otherwise key-repeat exposes the blink's "off" frames between
|
|
2669
|
+
// moves and the caret appears to vanish intermittently.
|
|
2670
|
+
function markCaretBusy() {
|
|
2671
|
+
document.body.classList.add('caret-busy');
|
|
2672
|
+
if (caretBusyTimer) clearTimeout(caretBusyTimer);
|
|
2673
|
+
caretBusyTimer = setTimeout(function () { document.body.classList.remove('caret-busy'); }, 650);
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
function setSourceCursor(path, lineIndex, column, shouldReveal = false, targetLine = -1) {
|
|
2677
|
+
markCaretBusy();
|
|
2678
|
+
selectedCommentRow = null; // any explicit caret placement (click/move) ends a comment-box selection
|
|
2679
|
+
const file = sourceByPath.get(path);
|
|
2680
|
+
if (!file || !file.embedded) return;
|
|
2681
|
+
const lines = file.content.split(/\r?\n/);
|
|
2682
|
+
const boundedLine = Math.max(0, Math.min(lineIndex, Math.max(lines.length - 1, 0)));
|
|
2683
|
+
const boundedColumn = Math.max(0, Math.min(column, (lines[boundedLine] || '').length));
|
|
2684
|
+
|
|
2685
|
+
const prev = viewerCursor;
|
|
2686
|
+
const viewer = document.getElementById('source-viewer');
|
|
2687
|
+
// Fast path: the file is already on screen and only the caret moved. Re-rendering the whole
|
|
2688
|
+
// file on every keystroke blocks the main thread on large files, so patch just the previous
|
|
2689
|
+
// and new caret lines in place instead.
|
|
2690
|
+
const sameFileOpen = Boolean(viewer && viewer.dataset.openPath === path && !viewer.classList.contains('hidden')
|
|
2691
|
+
&& prev && prev.path === path && !isHttpFile(path));
|
|
2692
|
+
|
|
2693
|
+
viewerCursor = { path, lineIndex: boundedLine, column: boundedColumn, targetLine };
|
|
2694
|
+
|
|
2695
|
+
if (sameFileOpen) {
|
|
2696
|
+
updateSourceCaret(prev, lines, file.language || 'text');
|
|
2697
|
+
} else {
|
|
2698
|
+
const shouldSwitch = !viewer || viewer.dataset.openPath !== path || viewer.classList.contains('hidden');
|
|
2699
|
+
openSourceFile(path, shouldSwitch);
|
|
2700
|
+
}
|
|
2701
|
+
if (shouldReveal) {
|
|
2702
|
+
requestAnimationFrame(() => {
|
|
2703
|
+
document.querySelector('.source-row.cursor-line')?.scrollIntoView({ block: 'center' });
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2706
|
+
recordNav(navEntryOf('source'));
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
// Move the caret by patching only the affected line cells, never the whole <table>. This keeps
|
|
2710
|
+
// large files responsive (no full re-highlight per keystroke) and, because the new caret line is
|
|
2711
|
+
// rebuilt with a fresh .code-cursor span, restarts the blink animation so the caret is solid the
|
|
2712
|
+
// instant it moves and only resumes blinking when idle.
|
|
2713
|
+
function updateSourceCaret(prev, lines, language) {
|
|
2714
|
+
const body = document.getElementById('source-body');
|
|
2715
|
+
if (!body) return;
|
|
2716
|
+
// Markdown/CSV render to HTML cells (.rendered-body): the caret is a whole-row highlight there,
|
|
2717
|
+
// so never rewrite a cell's innerHTML (that would replace the rendered block with raw text).
|
|
2718
|
+
const rendered = body.classList.contains('rendered-body');
|
|
2719
|
+
const rowFor = (idx) => body.querySelector('.source-row[data-line-index="' + idx + '"]');
|
|
2720
|
+
// Restore the line the caret left: drop the caret span, re-highlight the full line.
|
|
2721
|
+
if (prev && prev.lineIndex !== viewerCursor.lineIndex) {
|
|
2722
|
+
const prevRow = rowFor(prev.lineIndex);
|
|
2723
|
+
if (prevRow) {
|
|
2724
|
+
prevRow.classList.remove('cursor-line');
|
|
2725
|
+
if (!rendered) {
|
|
2726
|
+
const prevCell = prevRow.querySelector('.source-code');
|
|
2727
|
+
if (prevCell) prevCell.innerHTML = highlightLine(lines[prev.lineIndex] || '', language);
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
// Reconcile the go-to-definition highlight (set only on symbol jumps, cleared on plain moves).
|
|
2732
|
+
body.querySelectorAll('.source-row.symbol-target').forEach((r) => r.classList.remove('symbol-target'));
|
|
2733
|
+
if (viewerCursor.targetLine >= 0) rowFor(viewerCursor.targetLine)?.classList.add('symbol-target');
|
|
2734
|
+
// Rebuild the new caret line with the caret span.
|
|
2735
|
+
const row = rowFor(viewerCursor.lineIndex);
|
|
2736
|
+
if (!row) { if (!rendered) openSourceFile(viewerCursor.path, false); return; } // line not in the DOM — full re-render (eager source only)
|
|
2737
|
+
row.classList.add('cursor-line');
|
|
2738
|
+
if (!rendered) {
|
|
2739
|
+
const cell = row.querySelector('.source-code');
|
|
2740
|
+
if (cell) cell.innerHTML = renderLineWithCursor(lines[viewerCursor.lineIndex] || '', language, viewerCursor.column);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
function openSourceAt(path, lineIndex, column) {
|
|
2745
|
+
setSourceCursor(path, lineIndex, column, true, lineIndex);
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
function isSourceViewerVisible() {
|
|
2749
|
+
const viewer = document.getElementById('source-viewer');
|
|
2750
|
+
return Boolean(viewer && !viewer.classList.contains('hidden'));
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function openDiffFileAtCaret() {
|
|
2754
|
+
if (diffCursor && isDiffViewVisible()) {
|
|
2755
|
+
const dwrap = diffWrapperByPath(diffCursor.path);
|
|
2756
|
+
const drow = dwrap ? diffRowAt(dwrap, diffCursor.side, diffCursor.rowIndex) : null;
|
|
2757
|
+
const dline = drow ? diffLineNumber(drow) : null;
|
|
2758
|
+
if (sourceByPath.has(diffCursor.path)) { setSourceCursor(diffCursor.path, dline != null ? dline - 1 : 0, 0, true, -1); return; }
|
|
2759
|
+
openSourceFile(diffCursor.path); return;
|
|
2760
|
+
}
|
|
2761
|
+
const sel = window.getSelection();
|
|
2762
|
+
const node = sel && sel.anchorNode;
|
|
2763
|
+
const el = node ? (node.nodeType === 1 ? node : node.parentElement) : null;
|
|
2764
|
+
const wrapper = (el && el.closest && el.closest('.d2h-file-wrapper')) || document.querySelector('.d2h-file-wrapper:not(.df-inactive)');
|
|
2765
|
+
if (!wrapper) return;
|
|
2766
|
+
const fileName = (wrapper.querySelector('.d2h-file-name')?.textContent || '').trim();
|
|
2767
|
+
if (!fileName) return;
|
|
2768
|
+
if (!sourceByPath.has(fileName)) { openSourceFile(fileName); return; }
|
|
2769
|
+
let lineIndex = 0;
|
|
2770
|
+
const lineEl = el && el.closest && el.closest('.d2h-code-side-line');
|
|
2771
|
+
if (lineEl) {
|
|
2772
|
+
const row = lineEl.closest('tr');
|
|
2773
|
+
const numEl = row && row.querySelector('.d2h-code-side-linenumber');
|
|
2774
|
+
const num = numEl ? parseInt((numEl.textContent || '').trim(), 10) : NaN;
|
|
2775
|
+
if (Number.isFinite(num)) lineIndex = Math.max(0, num - 1);
|
|
2776
|
+
}
|
|
2777
|
+
setSourceCursor(fileName, lineIndex, 0, true, -1);
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
// ----- Comment-box navigation: a box attached to a line is a selectable stop while moving the caret -----
|
|
2781
|
+
function commentRowSiblingOf(lineIndex, dir) {
|
|
2782
|
+
var cur = document.querySelector('#source-body .source-row[data-line-index="' + lineIndex + '"]');
|
|
2783
|
+
if (!cur) return null;
|
|
2784
|
+
var sib = dir < 0 ? cur.previousElementSibling : cur.nextElementSibling;
|
|
2785
|
+
return (sib && sib.classList && sib.classList.contains('mc-comment-row')) ? sib : null;
|
|
2786
|
+
}
|
|
2787
|
+
function selectCommentRow(row) {
|
|
2788
|
+
if (selectedCommentRow && selectedCommentRow !== row) selectedCommentRow.classList.remove('mc-row-selected');
|
|
2789
|
+
selectedCommentRow = row || null;
|
|
2790
|
+
if (!selectedCommentRow) return;
|
|
2791
|
+
selectedCommentRow.classList.add('mc-row-selected');
|
|
2792
|
+
// hide the text caret while the box is "selected" (no re-render happens during plain selection)
|
|
2793
|
+
document.querySelectorAll('#source-body .source-row.cursor-line').forEach(function (r) { r.classList.remove('cursor-line'); });
|
|
2794
|
+
document.querySelectorAll('#source-body .code-cursor').forEach(function (s) { var p = s.parentNode; if (p) { p.removeChild(s); if (p.normalize) p.normalize(); } });
|
|
2795
|
+
}
|
|
2796
|
+
function deleteCommentsInRow(row) {
|
|
2797
|
+
if (!row) return;
|
|
2798
|
+
var seqs = Array.prototype.slice.call(row.querySelectorAll('.mc-del')).map(function (b) { return parseInt(b.dataset.seq, 10); });
|
|
2799
|
+
selectedCommentRow = null;
|
|
2800
|
+
if (seqs.length) {
|
|
2801
|
+
reviewComments = reviewComments.filter(function (c) { return seqs.indexOf(c.seq) < 0; });
|
|
2802
|
+
saveComments();
|
|
2803
|
+
}
|
|
2804
|
+
refreshComments(); // remaining comment rows re-injected; the caret stays hidden until the next arrow press
|
|
2805
|
+
}
|
|
2806
|
+
function handleSourceCaretKey(event) {
|
|
2807
|
+
if (!viewerCursor) return false;
|
|
2808
|
+
var ae = document.activeElement;
|
|
2809
|
+
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return false;
|
|
2810
|
+
const extend = event.shiftKey;
|
|
2811
|
+
// A comment box is selected (caret hidden): Backspace/Delete removes it; an arrow steps off it.
|
|
2812
|
+
if (selectedCommentRow) {
|
|
2813
|
+
if (event.key === 'Backspace' || event.key === 'Delete') { event.preventDefault(); deleteCommentsInRow(selectedCommentRow); return true; }
|
|
2814
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Escape') {
|
|
2815
|
+
var dir = event.key === 'ArrowUp' ? -1 : (event.key === 'ArrowDown' ? 1 : 0);
|
|
2816
|
+
var sib = dir < 0 ? selectedCommentRow.previousElementSibling : (dir > 0 ? selectedCommentRow.nextElementSibling : null);
|
|
2817
|
+
selectedCommentRow.classList.remove('mc-row-selected');
|
|
2818
|
+
selectedCommentRow = null;
|
|
2819
|
+
event.preventDefault();
|
|
2820
|
+
if (sib && sib.classList && sib.classList.contains('source-row')) {
|
|
2821
|
+
var li = parseInt(sib.dataset.lineIndex, 10);
|
|
2822
|
+
if (isFinite(li)) { setSourceCursor(viewerCursor.path, li, 0, true, -1); return true; }
|
|
2823
|
+
}
|
|
2824
|
+
setSourceCursor(viewerCursor.path, viewerCursor.lineIndex, viewerCursor.column, false, -1); // restore caret where it was
|
|
2825
|
+
return true;
|
|
2826
|
+
}
|
|
2827
|
+
return false;
|
|
2828
|
+
}
|
|
2829
|
+
// Plain Up/Down: a comment box between the caret line and the next line becomes a selectable stop.
|
|
2830
|
+
if (!extend && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
|
2831
|
+
var box = commentRowSiblingOf(viewerCursor.lineIndex, event.key === 'ArrowUp' ? -1 : 1);
|
|
2832
|
+
if (box) { event.preventDefault(); selectCommentRow(box); return true; }
|
|
2833
|
+
}
|
|
2834
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); moveSourceCursor(1, 0, extend); return true; }
|
|
2835
|
+
if (event.key === 'ArrowUp') { event.preventDefault(); moveSourceCursor(-1, 0, extend); return true; }
|
|
2836
|
+
if (event.key === 'ArrowLeft') { event.preventDefault(); moveSourceCursor(0, -1, extend); return true; }
|
|
2837
|
+
if (event.key === 'ArrowRight') { event.preventDefault(); moveSourceCursor(0, 1, extend); return true; }
|
|
2838
|
+
return false;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function moveSourceCursor(dLine, dColumn, extend) {
|
|
2842
|
+
if (!viewerCursor) return;
|
|
2843
|
+
const file = sourceByPath.get(viewerCursor.path);
|
|
2844
|
+
if (!file || !file.embedded) return;
|
|
2845
|
+
// Markdown/CSV rendered view: rows are blocks (sparse data-line-index), so any arrow steps to the
|
|
2846
|
+
// adjacent block row rather than into a (non-existent) raw line. No text column / selection there.
|
|
2847
|
+
const renderedBody = document.getElementById('source-body');
|
|
2848
|
+
if (renderedBody && renderedBody.classList.contains('rendered-body')) {
|
|
2849
|
+
const rows = Array.from(renderedBody.querySelectorAll('.source-row'));
|
|
2850
|
+
if (!rows.length) return;
|
|
2851
|
+
let ci = rows.indexOf(renderedBody.querySelector('.source-row[data-line-index="' + viewerCursor.lineIndex + '"]'));
|
|
2852
|
+
if (ci < 0) ci = 0;
|
|
2853
|
+
const step = (dLine || 0) + (dColumn > 0 ? 1 : dColumn < 0 ? -1 : 0);
|
|
2854
|
+
const ni = Math.max(0, Math.min(rows.length - 1, ci + (step || 0)));
|
|
2855
|
+
selectionAnchor = null;
|
|
2856
|
+
setSourceCursor(viewerCursor.path, Number(rows[ni].dataset.lineIndex) || 0, 0, true, -1);
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
const lines = file.content.split(/\r?\n/);
|
|
2860
|
+
let line = viewerCursor.lineIndex;
|
|
2861
|
+
let col = viewerCursor.column;
|
|
2862
|
+
if (dColumn < 0) {
|
|
2863
|
+
if (col > 0) col -= 1;
|
|
2864
|
+
else if (line > 0) { line -= 1; col = (lines[line] || '').length; }
|
|
2865
|
+
} else if (dColumn > 0) {
|
|
2866
|
+
if (col < (lines[line] || '').length) col += 1;
|
|
2867
|
+
else if (line < lines.length - 1) { line += 1; col = 0; }
|
|
2868
|
+
}
|
|
2869
|
+
if (dLine !== 0) {
|
|
2870
|
+
line = Math.max(0, Math.min(lines.length - 1, line + dLine));
|
|
2871
|
+
col = Math.min(col, (lines[line] || '').length);
|
|
2872
|
+
}
|
|
2873
|
+
if (extend) {
|
|
2874
|
+
if (!selectionAnchor) selectionAnchor = { lineIndex: viewerCursor.lineIndex, column: viewerCursor.column };
|
|
2875
|
+
} else {
|
|
2876
|
+
selectionAnchor = null;
|
|
2877
|
+
}
|
|
2878
|
+
setSourceCursor(viewerCursor.path, line, col, true, -1);
|
|
2879
|
+
applySourceSelection();
|
|
2880
|
+
}
|
|
2881
|
+
// Word boundary in text from col in direction dir (+1 next, -1 prev): skip non-word, then word.
|
|
2882
|
+
function nextWordBoundary(text, col, dir) {
|
|
2883
|
+
// Classify like vim's word motions: 0 = whitespace, 1 = word char, 2 = punctuation.
|
|
2884
|
+
// A run of word chars and a run of punctuation are each their own "word", so the
|
|
2885
|
+
// caret lands on the START of the next word/punctuation run (vim 'w'), or the start
|
|
2886
|
+
// of the previous one (vim 'b') -- never stranded in the middle of whitespace.
|
|
2887
|
+
var classOf = function (ch) {
|
|
2888
|
+
if (ch === '' || /\s/.test(ch)) return 0;
|
|
2889
|
+
if (/[A-Za-z0-9_$]/.test(ch)) return 1;
|
|
2890
|
+
return 2;
|
|
2891
|
+
};
|
|
2892
|
+
var i = col;
|
|
2893
|
+
if (dir > 0) {
|
|
2894
|
+
var cf = classOf(text.charAt(i));
|
|
2895
|
+
if (cf !== 0) { while (i < text.length && classOf(text.charAt(i)) === cf) i++; }
|
|
2896
|
+
while (i < text.length && classOf(text.charAt(i)) === 0) i++;
|
|
2897
|
+
} else {
|
|
2898
|
+
i--;
|
|
2899
|
+
while (i > 0 && classOf(text.charAt(i)) === 0) i--;
|
|
2900
|
+
var cb = classOf(text.charAt(i));
|
|
2901
|
+
while (i > 0 && classOf(text.charAt(i - 1)) === cb) i--;
|
|
2902
|
+
if (i < 0) i = 0;
|
|
2903
|
+
}
|
|
2904
|
+
return i;
|
|
2905
|
+
}
|
|
2906
|
+
function moveSourceWord(dir, extend) {
|
|
2907
|
+
if (!viewerCursor) return;
|
|
2908
|
+
var file = sourceByPath.get(viewerCursor.path);
|
|
2909
|
+
if (!file || !file.embedded) return;
|
|
2910
|
+
var lines = file.content.split(/\r?\n/);
|
|
2911
|
+
var line = viewerCursor.lineIndex, col = viewerCursor.column;
|
|
2912
|
+
var text = lines[line] || '';
|
|
2913
|
+
if (dir > 0) {
|
|
2914
|
+
var fwd = nextWordBoundary(text, col, 1);
|
|
2915
|
+
if (fwd < text.length || line >= lines.length - 1) { col = fwd; }
|
|
2916
|
+
else { line += 1; var nt = lines[line] || ''; var m = nt.search(/\S/); col = m < 0 ? 0 : m; }
|
|
2917
|
+
} else {
|
|
2918
|
+
var back = nextWordBoundary(text, col, -1);
|
|
2919
|
+
if (back < col && /\S/.test(text.charAt(back))) { col = back; }
|
|
2920
|
+
else if (line > 0) { line -= 1; var pt = lines[line] || ''; col = pt.length > 0 ? nextWordBoundary(pt, pt.length, -1) : 0; }
|
|
2921
|
+
else { col = back; }
|
|
2922
|
+
}
|
|
2923
|
+
if (extend) { if (!selectionAnchor) selectionAnchor = { lineIndex: viewerCursor.lineIndex, column: viewerCursor.column }; }
|
|
2924
|
+
else selectionAnchor = null;
|
|
2925
|
+
setSourceCursor(viewerCursor.path, line, col, true, -1);
|
|
2926
|
+
applySourceSelection();
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
function applySourceSelection() {
|
|
2930
|
+
const sel = window.getSelection();
|
|
2931
|
+
if (!sel) return;
|
|
2932
|
+
if (!selectionAnchor || !viewerCursor) { sel.removeAllRanges(); return; }
|
|
2933
|
+
const a = caretDomPosition(selectionAnchor.lineIndex, selectionAnchor.column);
|
|
2934
|
+
const c = caretDomPosition(viewerCursor.lineIndex, viewerCursor.column);
|
|
2935
|
+
if (a && c) {
|
|
2936
|
+
try { sel.setBaseAndExtent(a.node, a.offset, c.node, c.offset); } catch (e) {}
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
function caretDomPosition(lineIndex, column) {
|
|
2941
|
+
const cell = document.querySelector('.source-row[data-line-index="' + lineIndex + '"] .source-code');
|
|
2942
|
+
if (!cell) return null;
|
|
2943
|
+
let remaining = column;
|
|
2944
|
+
const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT);
|
|
2945
|
+
let node;
|
|
2946
|
+
while ((node = walker.nextNode())) {
|
|
2947
|
+
const len = node.textContent.length;
|
|
2948
|
+
if (remaining <= len) return { node, offset: remaining };
|
|
2949
|
+
remaining -= len;
|
|
2950
|
+
}
|
|
2951
|
+
return { node: cell, offset: cell.childNodes.length };
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
function wordAtCursor() {
|
|
2955
|
+
if (!viewerCursor) return null;
|
|
2956
|
+
const file = sourceByPath.get(viewerCursor.path);
|
|
2957
|
+
if (!file || !file.embedded) return null;
|
|
2958
|
+
const line = file.content.split(/\r?\n/)[viewerCursor.lineIndex] || '';
|
|
2959
|
+
const column = Math.max(0, Math.min(viewerCursor.column, line.length));
|
|
2960
|
+
const identifier = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
2961
|
+
let match = null;
|
|
2962
|
+
while ((match = identifier.exec(line))) {
|
|
2963
|
+
const start = match.index;
|
|
2964
|
+
const end = start + match[0].length;
|
|
2965
|
+
if (column >= start && column <= end) {
|
|
2966
|
+
return { name: match[0], path: viewerCursor.path, lineIndex: viewerCursor.lineIndex, column: start };
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
return null;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
function goToSymbolUnderCursor() {
|
|
2973
|
+
const symbol = wordAtCursor();
|
|
2974
|
+
if (symbol) goToDefOrUsages(symbol.name);
|
|
2975
|
+
}
|
|
2976
|
+
// Cmd+B: on a declaration, show its usages (navigate if there's only one); elsewhere, go to the definition.
|
|
2977
|
+
function goToDefOrUsages(name) {
|
|
2978
|
+
if (!name) return;
|
|
2979
|
+
if (REVIEW_LAZY_LOAD && !sourceLoaded) { pendingSymbol = name; loadSourceData(); return; } // load source+index on first use
|
|
2980
|
+
var def = findSymbolDefinition(name);
|
|
2981
|
+
var loc = caretSourceLoc();
|
|
2982
|
+
if (def && loc && def.path === loc.path && def.lineIndex === loc.lineIndex) {
|
|
2983
|
+
openUsages(name, def);
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2986
|
+
if (def) openSourceAt(def.path, def.lineIndex, def.column);
|
|
2987
|
+
}
|
|
2988
|
+
// Where the caret sits, mapped to a source (path, lineIndex). In the diff, only the new side maps cleanly.
|
|
2989
|
+
function caretSourceLoc() {
|
|
2990
|
+
if (isSourceViewerVisible() && viewerCursor) return { path: viewerCursor.path, lineIndex: viewerCursor.lineIndex };
|
|
2991
|
+
if (isDiffViewVisible() && diffCursor && diffCursor.side === 'new') {
|
|
2992
|
+
var wrap = diffWrapperByPath(diffCursor.path);
|
|
2993
|
+
var row = wrap ? diffRowAt(wrap, diffCursor.side, diffCursor.rowIndex) : null;
|
|
2994
|
+
var ln = row ? diffLineNumber(row) : null;
|
|
2995
|
+
if (ln != null) return { path: diffCursor.path, lineIndex: ln - 1 };
|
|
2996
|
+
}
|
|
2997
|
+
return null;
|
|
2998
|
+
}
|
|
2999
|
+
// All word-boundary occurrences of name across embedded files, excluding the declaration line itself.
|
|
3000
|
+
function findUsages(name, defPath, defLine) {
|
|
3001
|
+
var re;
|
|
3002
|
+
try { re = new RegExp('(^|[^A-Za-z0-9_$])' + escapeRegExp(name) + '(?![A-Za-z0-9_$])'); } catch (e) { return []; }
|
|
3003
|
+
var out = [];
|
|
3004
|
+
for (var fi = 0; fi < sourceFiles.length; fi++) {
|
|
3005
|
+
var f = sourceFiles[fi];
|
|
3006
|
+
if (!f.embedded) continue;
|
|
3007
|
+
var lines = String(f.content).split(/\r?\n/);
|
|
3008
|
+
for (var li = 0; li < lines.length; li++) {
|
|
3009
|
+
if (f.path === defPath && li === defLine) continue;
|
|
3010
|
+
var m = re.exec(lines[li]);
|
|
3011
|
+
if (m) {
|
|
3012
|
+
out.push({ path: f.path, lineIndex: li, column: m.index + (m[1] ? m[1].length : 0), text: lines[li] });
|
|
3013
|
+
if (out.length >= 500) return out;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
return out;
|
|
3018
|
+
}
|
|
3019
|
+
function openUsages(name, def) {
|
|
3020
|
+
var items = findUsages(name, def.path, def.lineIndex);
|
|
3021
|
+
if (items.length === 1) { openSourceAt(items[0].path, items[0].lineIndex, items[0].column); return; }
|
|
3022
|
+
usageItems = items;
|
|
3023
|
+
usageActive = 0;
|
|
3024
|
+
showUsages(name, items.length);
|
|
3025
|
+
}
|
|
3026
|
+
function showUsages(name, count) {
|
|
3027
|
+
var box = document.getElementById('usages');
|
|
3028
|
+
var title = document.getElementById('usages-title');
|
|
3029
|
+
if (!box) return;
|
|
3030
|
+
if (title) title.textContent = count + ' usage' + (count === 1 ? '' : 's') + ' of ' + name;
|
|
3031
|
+
renderUsages();
|
|
3032
|
+
box.classList.remove('hidden');
|
|
3033
|
+
}
|
|
3034
|
+
function renderUsages() {
|
|
3035
|
+
var results = document.getElementById('usages-results');
|
|
3036
|
+
if (!results) return;
|
|
3037
|
+
if (!usageItems.length) { results.innerHTML = '<div class="quick-open-empty">No usages found.</div>'; return; }
|
|
3038
|
+
results.innerHTML = usageItems.map(function (item, index) {
|
|
3039
|
+
var fname = item.path.split('/').pop();
|
|
3040
|
+
return '<button type="button" class="quick-open-item usage-item' + (index === usageActive ? ' active' : '') + '" data-index="' + index + '">'
|
|
3041
|
+
+ '<span class="usage-loc">' + escapeHtml(fname) + ':' + (item.lineIndex + 1) + '</span>'
|
|
3042
|
+
+ '<span class="usage-code">' + escapeHtml(item.text.replace(/^\s+/, '').slice(0, 160)) + '</span>'
|
|
3043
|
+
+ '</button>';
|
|
3044
|
+
}).join('');
|
|
3045
|
+
updateUsageActive();
|
|
3046
|
+
}
|
|
3047
|
+
function updateUsageActive() {
|
|
3048
|
+
var results = document.getElementById('usages-results');
|
|
3049
|
+
if (!results) return;
|
|
3050
|
+
var items = results.querySelectorAll('.usage-item');
|
|
3051
|
+
for (var i = 0; i < items.length; i++) {
|
|
3052
|
+
var on = i === usageActive;
|
|
3053
|
+
items[i].classList.toggle('active', on);
|
|
3054
|
+
if (on && items[i].scrollIntoView) items[i].scrollIntoView({ block: 'nearest' });
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
function handleUsagesKey(event) {
|
|
3058
|
+
if (event.key === 'Escape') { event.preventDefault(); closeUsages(); return true; }
|
|
3059
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); usageActive = Math.min(usageActive + 1, usageItems.length - 1); updateUsageActive(); return true; }
|
|
3060
|
+
if (event.key === 'ArrowUp') { event.preventDefault(); usageActive = Math.max(usageActive - 1, 0); updateUsageActive(); return true; }
|
|
3061
|
+
if (event.key === 'Enter') { event.preventDefault(); openUsageItem(usageItems[usageActive]); return true; }
|
|
3062
|
+
return false;
|
|
3063
|
+
}
|
|
3064
|
+
function openUsageItem(item) {
|
|
3065
|
+
if (!item) return;
|
|
3066
|
+
closeUsages();
|
|
3067
|
+
openSourceAt(item.path, item.lineIndex, item.column);
|
|
3068
|
+
}
|
|
3069
|
+
function closeUsages() {
|
|
3070
|
+
document.getElementById('usages')?.classList.add('hidden');
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
var symbolIndex = null; // Map<name, [{path,lineIndex,column}]>; built off-thread by a Web Worker, null until ready
|
|
3074
|
+
function symbolIndexWorker() {
|
|
3075
|
+
self.onmessage = function (e) {
|
|
3076
|
+
var files = e.data || [];
|
|
3077
|
+
var patterns = [
|
|
3078
|
+
/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
|
|
3079
|
+
/^\s*(?:(?:public|private|protected|internal|abstract|final|open|sealed|data|inner|annotation|static|export|default|expect|actual|value)\s+)*(?:class|interface|object|enum|trait|struct)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
|
|
3080
|
+
/^\s*(?:export\s+)?(?:interface|type|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
|
|
3081
|
+
/^\s*(?:export\s+)?(?:const|let|var|val)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
|
|
3082
|
+
/^\s*(?:(?:public|private|protected|internal|abstract|final|open|override|suspend|inline|operator|static|async)\s+)*(?:fun|def|fn|func)\s+([A-Za-z_$][A-Za-z0-9_$]*)/
|
|
3083
|
+
];
|
|
3084
|
+
var index = new Map();
|
|
3085
|
+
var total = files.length;
|
|
3086
|
+
var step = Math.max(1, Math.floor(total / 20)); // ~20 progress ticks regardless of repo size
|
|
3087
|
+
for (var fi = 0; fi < total; fi++) {
|
|
3088
|
+
var p = files[fi].path;
|
|
3089
|
+
var lines = String(files[fi].content || '').split(/\r?\n/);
|
|
3090
|
+
for (var li = 0; li < lines.length; li++) {
|
|
3091
|
+
var line = lines[li];
|
|
3092
|
+
for (var pi = 0; pi < patterns.length; pi++) {
|
|
3093
|
+
var m = patterns[pi].exec(line);
|
|
3094
|
+
if (m && m[1]) {
|
|
3095
|
+
var arr = index.get(m[1]);
|
|
3096
|
+
if (!arr) { arr = []; index.set(m[1], arr); }
|
|
3097
|
+
arr.push({ path: p, lineIndex: li, column: Math.max(0, line.indexOf(m[1])) });
|
|
3098
|
+
break;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
if ((fi + 1) % step === 0 && fi + 1 < total) self.postMessage({ done: fi + 1, total: total });
|
|
3103
|
+
}
|
|
3104
|
+
self.postMessage({ index: index, total: total });
|
|
3105
|
+
};
|
|
3106
|
+
}
|
|
3107
|
+
function startSymbolIndex() {
|
|
3108
|
+
try {
|
|
3109
|
+
if (typeof Worker === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) return;
|
|
3110
|
+
var src = '(' + symbolIndexWorker.toString() + ')()';
|
|
3111
|
+
var url = URL.createObjectURL(new Blob([src], { type: 'application/javascript' }));
|
|
3112
|
+
var worker = new Worker(url);
|
|
3113
|
+
worker.onmessage = function (e) {
|
|
3114
|
+
var msg = e.data;
|
|
3115
|
+
if (msg && msg.index) { // final index
|
|
3116
|
+
symbolIndex = msg.index;
|
|
3117
|
+
setIndexProgress(msg.total, msg.total);
|
|
3118
|
+
try { worker.terminate(); } catch (x) {}
|
|
3119
|
+
try { URL.revokeObjectURL(url); } catch (x) {}
|
|
3120
|
+
} else if (msg && typeof msg.done === 'number') { // progress tick
|
|
3121
|
+
setIndexProgress(msg.done, msg.total);
|
|
3122
|
+
}
|
|
3123
|
+
};
|
|
3124
|
+
worker.onerror = function () { setIndexProgress(1, 1); try { worker.terminate(); } catch (x) {} };
|
|
3125
|
+
var payload = [];
|
|
3126
|
+
for (var i = 0; i < sourceFiles.length; i++) {
|
|
3127
|
+
if (sourceFiles[i].embedded) payload.push({ path: sourceFiles[i].path, content: sourceFiles[i].content });
|
|
3128
|
+
}
|
|
3129
|
+
setIndexProgress(0, payload.length);
|
|
3130
|
+
worker.postMessage(payload);
|
|
3131
|
+
} catch (err) { /* Worker unavailable -> scan fallback remains in effect */ }
|
|
3132
|
+
}
|
|
3133
|
+
// Drive the go-to-definition indexing progress bar in the toolbar status. Hidden when done / not running.
|
|
3134
|
+
function setIndexProgress(done, total) {
|
|
3135
|
+
var el = document.getElementById('index-status');
|
|
3136
|
+
var bar = document.getElementById('index-progress');
|
|
3137
|
+
if (!el) return;
|
|
3138
|
+
if (!total || done >= total) {
|
|
3139
|
+
el.textContent = (total || 0) + ' ' + t('status.indexed');
|
|
3140
|
+
if (bar) bar.classList.add('hidden');
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
el.textContent = t('status.indexing') + ' ' + done + '/' + total + '…';
|
|
3144
|
+
if (bar) {
|
|
3145
|
+
bar.classList.remove('hidden');
|
|
3146
|
+
var fill = bar.firstElementChild;
|
|
3147
|
+
if (fill) fill.style.width = Math.round(done / total * 100) + '%';
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
function wordAtDiffCaret() {
|
|
3151
|
+
if (!diffCursor) return null;
|
|
3152
|
+
var wrapper = diffWrapperByPath(diffCursor.path);
|
|
3153
|
+
if (!wrapper) return null;
|
|
3154
|
+
var text = diffLineText(diffRowAt(wrapper, diffCursor.side, diffCursor.rowIndex));
|
|
3155
|
+
var column = Math.max(0, Math.min(diffCursor.column, text.length));
|
|
3156
|
+
var identifier = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
3157
|
+
var match = null;
|
|
3158
|
+
while ((match = identifier.exec(text))) {
|
|
3159
|
+
if (column >= match.index && column <= match.index + match[0].length) return match[0];
|
|
3160
|
+
}
|
|
3161
|
+
return null;
|
|
3162
|
+
}
|
|
3163
|
+
function goToSymbolFromDiff() {
|
|
3164
|
+
goToDefOrUsages(wordAtDiffCaret());
|
|
3165
|
+
}
|
|
3166
|
+
function findSymbolDefinition(name) {
|
|
3167
|
+
if (symbolIndex) {
|
|
3168
|
+
var hits = symbolIndex.get(name);
|
|
3169
|
+
if (hits && hits.length) {
|
|
3170
|
+
var cur = (viewerCursor && viewerCursor.path) || (diffCursor && diffCursor.path) || '';
|
|
3171
|
+
for (var i = 0; i < hits.length; i++) { if (hits[i].path === cur) return hits[i]; }
|
|
3172
|
+
return hits[0];
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
const matchers = definitionMatchers(name);
|
|
3176
|
+
const currentPath = viewerCursor?.path || '';
|
|
3177
|
+
const orderedFiles = [
|
|
3178
|
+
...sourceFiles.filter((file) => file.path === currentPath),
|
|
3179
|
+
...sourceFiles.filter((file) => file.path !== currentPath),
|
|
3180
|
+
].filter((file) => file.embedded);
|
|
3181
|
+
|
|
3182
|
+
for (const file of orderedFiles) {
|
|
3183
|
+
const lines = file.content.split(/\r?\n/);
|
|
3184
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
3185
|
+
const line = lines[lineIndex];
|
|
3186
|
+
if (matchers.some((matcher) => matcher.test(line))) {
|
|
3187
|
+
return { path: file.path, lineIndex, column: Math.max(0, line.indexOf(name)) };
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
return null;
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
function definitionMatchers(name) {
|
|
3195
|
+
const escaped = escapeRegExp(name);
|
|
3196
|
+
const mod = '(?:(?:public|private|protected|internal|abstract|final|open|sealed|data|inner|enum|annotation|static|export|default|expect|actual|value)\\s+)*';
|
|
3197
|
+
const funMod = '(?:(?:public|private|protected|internal|abstract|final|open|override|suspend|inline|operator|static|async)\\s+)*';
|
|
3198
|
+
return [
|
|
3199
|
+
new RegExp('^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s+' + escaped + '\\b'),
|
|
3200
|
+
new RegExp('^\\s*' + mod + '(?:class|interface|object|enum|trait|struct)\\s+' + escaped + '\\b'),
|
|
3201
|
+
new RegExp('^\\s*(?:export\\s+)?(?:interface|type|enum)\\s+' + escaped + '\\b'),
|
|
3202
|
+
new RegExp('^\\s*(?:export\\s+)?(?:const|let|var|val)\\s+' + escaped + '\\b'),
|
|
3203
|
+
new RegExp('^\\s*' + funMod + '(?:fun|def|fn|func)\\s+' + escaped + '\\b'),
|
|
3204
|
+
new RegExp('^\\s*' + funMod + escaped + '\\s*\\([^)]*\\)\\s*(?::\\s*[^=]+)?\\s*(?:\\{|=>)'),
|
|
3205
|
+
new RegExp('^\\s*' + escaped + '\\s*[:=]\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*=>)'),
|
|
3206
|
+
];
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
function escapeRegExp(value) {
|
|
3210
|
+
return String(value).replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
function setSourceTypeIcon(path) {
|
|
3214
|
+
var holder = document.getElementById('source-type-icon');
|
|
3215
|
+
if (!holder) return;
|
|
3216
|
+
var link = sourceLinks.find(function (l) { return l.dataset.sourceFile === path; });
|
|
3217
|
+
var icon = link ? link.querySelector('.ftype') : null;
|
|
3218
|
+
holder.innerHTML = icon ? icon.outerHTML : '';
|
|
3219
|
+
}
|
|
3220
|
+
// Files-mode tabs: each distinct file opened in the source viewer becomes a tab (session-only).
|
|
3221
|
+
// Cmd/Ctrl+W closes the active tab; Cmd/Ctrl+Shift+[ / ] cycle tabs; the × button closes one.
|
|
3222
|
+
// (sourceTabs is declared near the other source state up top so early restore-state openSourceFile
|
|
3223
|
+
// calls run before this block don't see an undefined array.)
|
|
3224
|
+
function addSourceTab(path) { if (path && sourceTabs.indexOf(path) < 0) sourceTabs.push(path); }
|
|
3225
|
+
function sourceTabLabel(path) { var p = String(path || ''); var s = p.lastIndexOf('/'); return s >= 0 ? p.slice(s + 1) : p; }
|
|
3226
|
+
function currentSourceTabPath() { var v = document.getElementById('source-viewer'); return (v && v.dataset.openPath) || ''; }
|
|
3227
|
+
function renderSourceTabs(activePath) {
|
|
3228
|
+
var bar = document.getElementById('source-tabs');
|
|
3229
|
+
if (!bar) return;
|
|
3230
|
+
if (!sourceTabs.length) { bar.classList.add('hidden'); bar.innerHTML = ''; return; }
|
|
3231
|
+
bar.classList.remove('hidden');
|
|
3232
|
+
bar.innerHTML = sourceTabs.map(function (p) {
|
|
3233
|
+
var active = p === activePath;
|
|
3234
|
+
return '<div class="source-tab' + (active ? ' active' : '') + '" data-tab-path="' + escapeHtml(p) + '" title="' + escapeHtml(p) + '">'
|
|
3235
|
+
+ '<span class="source-tab-name">' + escapeHtml(sourceTabLabel(p)) + '</span>'
|
|
3236
|
+
+ '<button type="button" class="source-tab-close" data-close-path="' + escapeHtml(p) + '" aria-label="Close tab" title="Close (Cmd/Ctrl+W)">×</button>'
|
|
3237
|
+
+ '</div>';
|
|
3238
|
+
}).join('');
|
|
3239
|
+
var act = bar.querySelector('.source-tab.active');
|
|
3240
|
+
if (act && act.scrollIntoView) act.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
3241
|
+
}
|
|
3242
|
+
function closeSourceTab(path) {
|
|
3243
|
+
var idx = sourceTabs.indexOf(path);
|
|
3244
|
+
if (idx < 0) return;
|
|
3245
|
+
var wasActive = path === currentSourceTabPath();
|
|
3246
|
+
sourceTabs.splice(idx, 1);
|
|
3247
|
+
if (!wasActive) { renderSourceTabs(currentSourceTabPath()); return; }
|
|
3248
|
+
var nextPath = sourceTabs[idx] || sourceTabs[idx - 1] || '';
|
|
3249
|
+
if (nextPath) { openSourceFile(nextPath); return; }
|
|
3250
|
+
// No tabs left: reset the source view to its empty state.
|
|
3251
|
+
var v = document.getElementById('source-viewer'); if (v) v.dataset.openPath = '';
|
|
3252
|
+
var body = document.getElementById('source-body');
|
|
3253
|
+
if (body) { body.className = 'source-body empty'; body.textContent = t('source.selectFile'); }
|
|
3254
|
+
sourceLinks.forEach(function (l) { l.classList.remove('active'); });
|
|
3255
|
+
renderSourceTabs('');
|
|
3256
|
+
}
|
|
3257
|
+
function closeActiveSourceTab() { var p = currentSourceTabPath(); if (p) { closeSourceTab(p); return true; } return false; }
|
|
3258
|
+
function cycleSourceTab(dir) {
|
|
3259
|
+
if (sourceTabs.length < 2) return;
|
|
3260
|
+
var cur = sourceTabs.indexOf(currentSourceTabPath());
|
|
3261
|
+
if (cur < 0) cur = 0;
|
|
3262
|
+
openSourceFile(sourceTabs[(cur + dir + sourceTabs.length) % sourceTabs.length]);
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
function openSourceFile(path, shouldSwitch = true) {
|
|
3266
|
+
const file = sourceByPath.get(path);
|
|
3267
|
+
if (!file) return;
|
|
3268
|
+
addSourceTab(path);
|
|
3269
|
+
renderSourceTabs(path);
|
|
3270
|
+
// lazy-LOAD: source content not fetched yet -> show a loading state; loadSourceData re-opens it.
|
|
3271
|
+
if (REVIEW_LAZY_LOAD && !sourceLoaded && file.embedded) {
|
|
3272
|
+
pendingSourceOpen = { path: path, shouldSwitch: shouldSwitch };
|
|
3273
|
+
loadSourceData();
|
|
3274
|
+
document.getElementById('source-viewer').dataset.openPath = path;
|
|
3275
|
+
sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
|
|
3276
|
+
renderBreadcrumb(document.getElementById('source-title'), path);
|
|
3277
|
+
setSourceTypeIcon(path);
|
|
3278
|
+
revealTreeFor(path);
|
|
3279
|
+
var lb = document.getElementById('source-body');
|
|
3280
|
+
lb.className = 'source-body empty';
|
|
3281
|
+
lb.textContent = t('source.loading');
|
|
3282
|
+
if (shouldSwitch) showSourceView();
|
|
3283
|
+
return;
|
|
3284
|
+
}
|
|
3285
|
+
rememberRecent(path, 'source');
|
|
3286
|
+
document.getElementById('source-viewer').dataset.openPath = path;
|
|
3287
|
+
sourceLinks.forEach((link) => link.classList.toggle('active', link.dataset.sourceFile === path));
|
|
3288
|
+
renderBreadcrumb(document.getElementById('source-title'), path);
|
|
3289
|
+
setSourceTypeIcon(path);
|
|
3290
|
+
revealTreeFor(path);
|
|
3291
|
+
const meta = file.embedded
|
|
3292
|
+
? formatBytes(file.size || 0)
|
|
3293
|
+
: formatBytes(file.size || 0) + ' · ' + (file.skippedReason || 'not embedded');
|
|
3294
|
+
document.getElementById('source-meta').textContent = meta;
|
|
3295
|
+
const body = document.getElementById('source-body');
|
|
3296
|
+
// Image files carry a data: URI preview instead of text — render inline (click to zoom).
|
|
3297
|
+
if (file.image) {
|
|
3298
|
+
body.className = 'source-body image-body';
|
|
3299
|
+
body.innerHTML = renderImageView(file);
|
|
3300
|
+
document.getElementById('http-env-select')?.classList.add('hidden');
|
|
3301
|
+
updateRenderToggle(path);
|
|
3302
|
+
if (shouldSwitch) showSourceView();
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
if (!file.embedded) {
|
|
3306
|
+
body.className = 'source-body empty';
|
|
3307
|
+
body.textContent = file.skippedReason ? t('source.previewUnavailable').replace(/\.$/, '') + ': ' + file.skippedReason + '.' : t('source.previewUnavailable');
|
|
3308
|
+
document.getElementById('http-env-select')?.classList.add('hidden');
|
|
3309
|
+
updateRenderToggle(path);
|
|
3310
|
+
if (shouldSwitch) showSourceView();
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
if (!viewerCursor || viewerCursor.path !== path) {
|
|
3314
|
+
viewerCursor = { path, lineIndex: 0, column: 0, targetLine: -1 };
|
|
3315
|
+
}
|
|
3316
|
+
body.className = 'source-body';
|
|
3317
|
+
const httpEnvSelect = document.getElementById('http-env-select');
|
|
3318
|
+
// Markdown/CSV render to HTML but stay a line-numbered .source-table: each block (md) or record (csv)
|
|
3319
|
+
// is a .source-row keyed by its start line, so the gutter shows line numbers and line/block comments
|
|
3320
|
+
// work exactly as in the plain source view (renderSourceComments anchors on .source-row[data-line-index]).
|
|
3321
|
+
if (isMarkdownPath(path)) {
|
|
3322
|
+
if (renderRawMode) {
|
|
3323
|
+
body.innerHTML = renderSourceTable(file, '');
|
|
3324
|
+
} else {
|
|
3325
|
+
body.classList.add('rendered-body');
|
|
3326
|
+
body.innerHTML = renderMarkdownRows(file.content);
|
|
3327
|
+
}
|
|
3328
|
+
if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
|
|
3329
|
+
updateRenderToggle(path);
|
|
3330
|
+
renderSourceComments();
|
|
3331
|
+
if (shouldSwitch) showSourceView();
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
if (isCsvPath(path)) {
|
|
3335
|
+
if (renderRawMode) {
|
|
3336
|
+
body.innerHTML = renderSourceTable(file, '');
|
|
3337
|
+
} else {
|
|
3338
|
+
body.classList.add('rendered-body');
|
|
3339
|
+
body.innerHTML = renderCsvRows(file.content, path);
|
|
3340
|
+
}
|
|
3341
|
+
if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
|
|
3342
|
+
updateRenderToggle(path);
|
|
3343
|
+
renderSourceComments();
|
|
3344
|
+
if (shouldSwitch) showSourceView();
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
if (isHttpFile(path)) {
|
|
3348
|
+
body.innerHTML = renderHttpTable(file);
|
|
3349
|
+
if (httpEnvSelect) httpEnvSelect.classList.toggle('hidden', httpEnvNames.length === 0);
|
|
3350
|
+
} else {
|
|
3351
|
+
body.innerHTML = renderSourceTable(file, '');
|
|
3352
|
+
if (httpEnvSelect) httpEnvSelect.classList.add('hidden');
|
|
3353
|
+
}
|
|
3354
|
+
updateRenderToggle(path);
|
|
3355
|
+
renderSourceComments();
|
|
3356
|
+
if (shouldSwitch) showSourceView();
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
function isMarkdownPath(p) { return /\.(md|mdx|markdown)$/i.test(p || ''); }
|
|
3360
|
+
function isCsvPath(p) { return /\.(csv|tsv)$/i.test(p || ''); }
|
|
3361
|
+
function isRenderToggleable(p) { return isMarkdownPath(p) || isCsvPath(p); }
|
|
3362
|
+
|
|
3363
|
+
// Markdown/CSV open rendered by default; this flips the open file to raw line-numbered text and back.
|
|
3364
|
+
// Session-global so the choice carries across files. The toolbar button + Cmd/Ctrl+Shift+M both call it.
|
|
3365
|
+
var renderRawMode = false;
|
|
3366
|
+
function updateRenderToggle(path) {
|
|
3367
|
+
var btn = document.getElementById('render-toggle');
|
|
3368
|
+
if (!btn) return;
|
|
3369
|
+
var on = isRenderToggleable(path);
|
|
3370
|
+
btn.classList.toggle('hidden', !on);
|
|
3371
|
+
if (!on) return;
|
|
3372
|
+
btn.textContent = renderRawMode ? t('source.viewRendered') : t('source.viewRaw'); // label = the mode you switch TO
|
|
3373
|
+
btn.setAttribute('aria-pressed', renderRawMode ? 'true' : 'false');
|
|
3374
|
+
}
|
|
3375
|
+
function toggleRenderMode() {
|
|
3376
|
+
var sv = document.getElementById('source-viewer');
|
|
3377
|
+
var open = sv && sv.dataset.openPath;
|
|
3378
|
+
if (!open || !isRenderToggleable(open)) return;
|
|
3379
|
+
renderRawMode = !renderRawMode;
|
|
3380
|
+
openSourceFile(open, false); // re-render the current file in the new mode
|
|
3381
|
+
}
|
|
3382
|
+
(function wireRenderToggle() {
|
|
3383
|
+
var btn = document.getElementById('render-toggle');
|
|
3384
|
+
if (btn) btn.addEventListener('click', function () { toggleRenderMode(); });
|
|
3385
|
+
document.addEventListener('keydown', function (e) {
|
|
3386
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && !e.altKey && (e.key === 'M' || e.key === 'm' || e.code === 'KeyM')) {
|
|
3387
|
+
var sv = document.getElementById('source-viewer');
|
|
3388
|
+
var open = sv && sv.dataset.openPath;
|
|
3389
|
+
if (open && isRenderToggleable(open) && isSourceViewerVisible()) { e.preventDefault(); toggleRenderMode(); }
|
|
3390
|
+
}
|
|
3391
|
+
});
|
|
3392
|
+
})();
|
|
3393
|
+
|
|
3394
|
+
function renderImageView(file) {
|
|
3395
|
+
return '<div class="image-view">'
|
|
3396
|
+
+ '<img class="image-preview" src="' + file.image + '" alt="' + escapeHtml(file.name) + '" data-zoomable="1">'
|
|
3397
|
+
+ '<div class="image-cap">' + escapeHtml(file.name) + ' · ' + formatBytes(file.size || 0) + ' · click to zoom</div>'
|
|
3398
|
+
+ '</div>';
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
function openLightbox(src, alt) {
|
|
3402
|
+
if (!src) return;
|
|
3403
|
+
var lb = document.getElementById('mc-lightbox');
|
|
3404
|
+
if (!lb) {
|
|
3405
|
+
lb = document.createElement('div');
|
|
3406
|
+
lb.id = 'mc-lightbox';
|
|
3407
|
+
lb.className = 'mc-lightbox hidden';
|
|
3408
|
+
lb.innerHTML = '<img class="mc-lightbox-img" alt="">';
|
|
3409
|
+
document.body.appendChild(lb);
|
|
3410
|
+
lb.addEventListener('click', closeLightbox);
|
|
3411
|
+
}
|
|
3412
|
+
var img = lb.querySelector('img');
|
|
3413
|
+
img.src = src;
|
|
3414
|
+
img.alt = alt || '';
|
|
3415
|
+
lb.classList.remove('hidden');
|
|
3416
|
+
}
|
|
3417
|
+
function closeLightbox() {
|
|
3418
|
+
var lb = document.getElementById('mc-lightbox');
|
|
3419
|
+
if (lb) lb.classList.add('hidden');
|
|
3420
|
+
}
|
|
3421
|
+
function lightboxOpen() {
|
|
3422
|
+
var lb = document.getElementById('mc-lightbox');
|
|
3423
|
+
return !!(lb && !lb.classList.contains('hidden'));
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
// Minimal, dependency-free Markdown -> HTML for the preview pane. Input is escaped before any
|
|
3427
|
+
// markup is applied; links/images are restricted to http(s)/data/mailto/anchor targets.
|
|
3428
|
+
function renderInlineMd(text) {
|
|
3429
|
+
var s = escapeHtml(text);
|
|
3430
|
+
s = s.replace(/`([^`]+)`/g, function (m, code) { return '<code>' + code + '</code>'; });
|
|
3431
|
+
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/g, function (m, alt, url) {
|
|
3432
|
+
return /^(https?:|data:)/i.test(url) ? '<img class="md-img" src="' + url + '" alt="' + alt + '">' : m;
|
|
3433
|
+
});
|
|
3434
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/g, function (m, label, url) {
|
|
3435
|
+
return /^(https?:|mailto:|#)/i.test(url) ? '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a>' : label;
|
|
3436
|
+
});
|
|
3437
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
|
3438
|
+
s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>').replace(/(^|[^_\w])_([^_\s][^_]*)_/g, '$1<em>$2</em>');
|
|
3439
|
+
s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
|
3440
|
+
return s;
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
function mdFenceLang(lang) {
|
|
3444
|
+
var l = (lang || '').toLowerCase();
|
|
3445
|
+
if (l === 'js' || l === 'jsx' || l === 'ts' || l === 'tsx') return 'typescript';
|
|
3446
|
+
if (l === 'sh' || l === 'bash' || l === 'zsh') return 'shell';
|
|
3447
|
+
if (l === 'yml') return 'yaml';
|
|
3448
|
+
return l || 'text';
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
function splitTableRow(line) {
|
|
3452
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return c.trim(); });
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// Parse markdown into block objects { line, html } where line is the 0-based start line in the source.
|
|
3456
|
+
// Each block becomes one .source-row so the rendered view keeps a real line gutter + line comments.
|
|
3457
|
+
function renderMarkdownBlocks(content) {
|
|
3458
|
+
var lines = String(content).split(/\r?\n/);
|
|
3459
|
+
var blocks = [];
|
|
3460
|
+
var i = 0;
|
|
3461
|
+
var m;
|
|
3462
|
+
while (i < lines.length) {
|
|
3463
|
+
var start = i;
|
|
3464
|
+
var line = lines[i];
|
|
3465
|
+
var fence = line.match(/^(\s*)(```+|~~~+)\s*([\w+#-]*)\s*$/);
|
|
3466
|
+
if (fence) {
|
|
3467
|
+
var marker = fence[2].charAt(0);
|
|
3468
|
+
var closeRe = new RegExp('^\\s*' + (marker === '`' ? '`' : '~') + '{3,}\\s*$');
|
|
3469
|
+
var lang = mdFenceLang(fence[3]);
|
|
3470
|
+
var buf = [];
|
|
3471
|
+
i++;
|
|
3472
|
+
while (i < lines.length && !closeRe.test(lines[i])) { buf.push(lines[i]); i++; }
|
|
3473
|
+
i++;
|
|
3474
|
+
blocks.push({ line: start, html: '<pre class="md-code"><code>' + buf.map(function (l) { return highlightLine(l, lang); }).join('\n') + '</code></pre>' });
|
|
3475
|
+
continue;
|
|
3476
|
+
}
|
|
3477
|
+
if (/^\s*$/.test(line)) { i++; continue; }
|
|
3478
|
+
var h = line.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
|
|
3479
|
+
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; }
|
|
3480
|
+
if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) { blocks.push({ line: start, html: '<hr class="md-hr">' }); i++; continue; }
|
|
3481
|
+
if (/^\s*>\s?/.test(line)) {
|
|
3482
|
+
var qbuf = [];
|
|
3483
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { qbuf.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
|
|
3484
|
+
blocks.push({ line: start, html: '<blockquote class="md-quote">' + qbuf.map(function (l) { return l.trim() ? '<p>' + renderInlineMd(l) + '</p>' : ''; }).join('') + '</blockquote>' });
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
if (/\|/.test(line) && i + 1 < lines.length && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1])) {
|
|
3488
|
+
var header = splitTableRow(line);
|
|
3489
|
+
i += 2;
|
|
3490
|
+
var rowsHtml = '';
|
|
3491
|
+
while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) {
|
|
3492
|
+
var cells = splitTableRow(lines[i]);
|
|
3493
|
+
rowsHtml += '<tr>' + header.map(function (_h, ci) { return '<td>' + renderInlineMd(cells[ci] || '') + '</td>'; }).join('') + '</tr>';
|
|
3494
|
+
i++;
|
|
3495
|
+
}
|
|
3496
|
+
blocks.push({ line: start, html: '<table class="md-table"><thead><tr>' + header.map(function (c) { return '<th>' + renderInlineMd(c) + '</th>'; }).join('') + '</tr></thead><tbody>' + rowsHtml + '</tbody></table>' });
|
|
3497
|
+
continue;
|
|
3498
|
+
}
|
|
3499
|
+
if ((m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/))) {
|
|
3500
|
+
var type = /\d/.test(m[2]) ? 'ol' : 'ul';
|
|
3501
|
+
var items = '';
|
|
3502
|
+
while (i < lines.length && (m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/))) { items += '<li>' + renderInlineMd(m[3]) + '</li>'; i++; }
|
|
3503
|
+
blocks.push({ line: start, html: '<' + type + ' class="md-list">' + items + '</' + type + '>' });
|
|
3504
|
+
continue;
|
|
3505
|
+
}
|
|
3506
|
+
var pbuf = [line];
|
|
3507
|
+
i++;
|
|
3508
|
+
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(\s{0,3}#{1,6}\s|\s*>|\s*([-*+]|\d+[.)])\s|\s*(```|~~~))/.test(lines[i])) { pbuf.push(lines[i]); i++; }
|
|
3509
|
+
blocks.push({ line: start, html: '<p class="md-p">' + renderInlineMd(pbuf.join('\n')).replace(/\n/g, '<br>') + '</p>' });
|
|
3510
|
+
}
|
|
3511
|
+
return blocks;
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
function renderMarkdownRows(content) {
|
|
3515
|
+
var blocks = renderMarkdownBlocks(content);
|
|
3516
|
+
if (!blocks.length) return '<table class="source-table md-doc"><tbody></tbody></table>';
|
|
3517
|
+
var rows = blocks.map(function (b) {
|
|
3518
|
+
return '<tr class="source-row md-row" data-line-index="' + b.line + '"><td class="num">' + (b.line + 1) + '</td><td class="source-code md-cell">' + b.html + '</td></tr>';
|
|
3519
|
+
}).join('');
|
|
3520
|
+
return '<table class="source-table md-doc"><tbody>' + rows + '</tbody></table>';
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
// RFC-4180-ish delimited parser: handles quoted fields with embedded delimiters, newlines, and "" escapes.
|
|
3524
|
+
function parseDelimited(content, delim) {
|
|
3525
|
+
var rows = [];
|
|
3526
|
+
var row = [];
|
|
3527
|
+
var field = '';
|
|
3528
|
+
var inQuotes = false;
|
|
3529
|
+
var s = String(content);
|
|
3530
|
+
for (var i = 0; i < s.length; i++) {
|
|
3531
|
+
var ch = s[i];
|
|
3532
|
+
if (inQuotes) {
|
|
3533
|
+
if (ch === '"') {
|
|
3534
|
+
if (s[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
|
|
3535
|
+
} else field += ch;
|
|
3536
|
+
} else if (ch === '"') {
|
|
3537
|
+
inQuotes = true;
|
|
3538
|
+
} else if (ch === delim) {
|
|
3539
|
+
row.push(field); field = '';
|
|
3540
|
+
} else if (ch === '\n') {
|
|
3541
|
+
row.push(field); rows.push(row); row = []; field = '';
|
|
3542
|
+
} else if (ch !== '\r') {
|
|
3543
|
+
field += ch;
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
|
|
3547
|
+
return rows;
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
// CSV/TSV renders to an aligned table that is still a .source-table: each record is a .source-row keyed
|
|
3551
|
+
// by its record index (data-line-index) so line numbers show in the gutter and comments anchor per row.
|
|
3552
|
+
function renderCsvRows(content, path) {
|
|
3553
|
+
var delim = /\.tsv$/i.test(path || '') ? '\t' : ',';
|
|
3554
|
+
var records = parseDelimited(content, delim).filter(function (r) { return !(r.length === 1 && r[0] === ''); });
|
|
3555
|
+
if (!records.length) return '<table class="source-table csv-doc"><tbody></tbody></table>';
|
|
3556
|
+
var cols = records.reduce(function (max, r) { return Math.max(max, r.length); }, 0);
|
|
3557
|
+
var rows = records.map(function (rec, idx) {
|
|
3558
|
+
var head = idx === 0;
|
|
3559
|
+
var cells = '';
|
|
3560
|
+
for (var c = 0; c < cols; c++) {
|
|
3561
|
+
var v = escapeHtml(rec[c] == null ? '' : rec[c]);
|
|
3562
|
+
cells += head ? '<th class="csv-cell">' + v + '</th>' : '<td class="csv-cell">' + v + '</td>';
|
|
3563
|
+
}
|
|
3564
|
+
return '<tr class="source-row csv-row' + (head ? ' csv-head' : '') + '" data-line-index="' + idx + '"><td class="num">' + (idx + 1) + '</td>' + cells + '</tr>';
|
|
3565
|
+
}).join('');
|
|
3566
|
+
return '<table class="source-table csv-doc"><tbody>' + rows + '</tbody></table>';
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
function isHttpFile(path) {
|
|
3570
|
+
return /\.(http|rest)$/i.test(path || '');
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
function currentHttpEnv() {
|
|
3574
|
+
return httpEnvironments[currentHttpEnvName] || {};
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
function applyHttpVars(text, env) {
|
|
3578
|
+
return String(text == null ? '' : text).replace(/\{\{\s*([\w.$-]+)\s*\}\}/g, function (whole, name) {
|
|
3579
|
+
if (env && Object.prototype.hasOwnProperty.call(env, name)) return env[name];
|
|
3580
|
+
return whole;
|
|
3581
|
+
});
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
// Parses an IntelliJ-style .http file into a list of requests. Each request
|
|
3585
|
+
// tracks the line of its request line (for the gutter Run button) and the line
|
|
3586
|
+
// span it covers (for placing the inline response and for Cmd/Alt+Enter).
|
|
3587
|
+
function parseHttpRequests(content) {
|
|
3588
|
+
const methods = { GET: 1, POST: 1, PUT: 1, PATCH: 1, DELETE: 1, HEAD: 1, OPTIONS: 1, TRACE: 1, CONNECT: 1 };
|
|
3589
|
+
const lines = String(content).split(/\r?\n/);
|
|
3590
|
+
const requests = [];
|
|
3591
|
+
const vars = {};
|
|
3592
|
+
let curr = null;
|
|
3593
|
+
let phase = 'pre';
|
|
3594
|
+
function flush() {
|
|
3595
|
+
if (curr && curr.url) {
|
|
3596
|
+
curr.body = curr.bodyLines.join('\n').replace(/\s+$/, '');
|
|
3597
|
+
requests.push(curr);
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
function start(boundaryLine, name, index) {
|
|
3601
|
+
return { name: name, method: '', url: '', headers: [], bodyLines: [], startLine: -1, endLine: index, boundaryLine: boundaryLine };
|
|
3602
|
+
}
|
|
3603
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3604
|
+
const rawLine = lines[i];
|
|
3605
|
+
const trimmed = rawLine.trim();
|
|
3606
|
+
if (trimmed.indexOf('###') === 0) {
|
|
3607
|
+
flush();
|
|
3608
|
+
curr = start(i, trimmed.replace(/^#+/, '').trim(), i);
|
|
3609
|
+
phase = 'pre';
|
|
3610
|
+
continue;
|
|
3611
|
+
}
|
|
3612
|
+
if (!curr) {
|
|
3613
|
+
curr = start(-1, '', i);
|
|
3614
|
+
phase = 'pre';
|
|
3615
|
+
}
|
|
3616
|
+
curr.endLine = i;
|
|
3617
|
+
if (phase === 'pre') {
|
|
3618
|
+
if (trimmed === '') continue;
|
|
3619
|
+
if (trimmed.indexOf('#') === 0 || trimmed.indexOf('//') === 0) continue;
|
|
3620
|
+
const varMatch = /^@([\w.$-]+)\s*=\s*(.*)$/.exec(trimmed);
|
|
3621
|
+
if (varMatch) { vars[varMatch[1]] = varMatch[2].trim(); continue; }
|
|
3622
|
+
const sp = trimmed.indexOf(' ');
|
|
3623
|
+
const firstToken = sp >= 0 ? trimmed.slice(0, sp) : trimmed;
|
|
3624
|
+
if (sp >= 0 && methods[firstToken.toUpperCase()]) {
|
|
3625
|
+
curr.method = firstToken.toUpperCase();
|
|
3626
|
+
curr.url = trimmed.slice(sp + 1).replace(/\s+HTTP\/[\d.]+\s*$/i, '').trim();
|
|
3627
|
+
} else {
|
|
3628
|
+
curr.method = 'GET';
|
|
3629
|
+
curr.url = trimmed.replace(/\s+HTTP\/[\d.]+\s*$/i, '').trim();
|
|
3630
|
+
}
|
|
3631
|
+
curr.startLine = i;
|
|
3632
|
+
phase = 'headers';
|
|
3633
|
+
continue;
|
|
3634
|
+
}
|
|
3635
|
+
if (phase === 'headers') {
|
|
3636
|
+
if (trimmed === '') { phase = 'body'; continue; }
|
|
3637
|
+
if (trimmed.indexOf('#') === 0 || trimmed.indexOf('//') === 0) continue;
|
|
3638
|
+
const colon = rawLine.indexOf(':');
|
|
3639
|
+
if (colon > 0) curr.headers.push({ name: rawLine.slice(0, colon).trim(), value: rawLine.slice(colon + 1).trim() });
|
|
3640
|
+
continue;
|
|
3641
|
+
}
|
|
3642
|
+
curr.bodyLines.push(rawLine);
|
|
3643
|
+
}
|
|
3644
|
+
flush();
|
|
3645
|
+
return { requests: requests, vars: vars };
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
function renderHttpTable(file) {
|
|
3649
|
+
const parsed = parseHttpRequests(file.content);
|
|
3650
|
+
const requests = parsed.requests;
|
|
3651
|
+
httpRequestsByPath.set(file.path, requests);
|
|
3652
|
+
httpVarsByPath.set(file.path, parsed.vars);
|
|
3653
|
+
const env = Object.assign({}, parsed.vars, currentHttpEnv());
|
|
3654
|
+
const lines = String(file.content).split(/\r?\n/);
|
|
3655
|
+
const cursor = viewerCursor && viewerCursor.path === file.path ? viewerCursor : null;
|
|
3656
|
+
const runAtLine = {};
|
|
3657
|
+
const respAfterLine = {};
|
|
3658
|
+
requests.forEach(function (req, idx) {
|
|
3659
|
+
if (req.startLine >= 0) runAtLine[req.startLine] = idx;
|
|
3660
|
+
respAfterLine[req.endLine] = idx;
|
|
3661
|
+
});
|
|
3662
|
+
let rows = '';
|
|
3663
|
+
lines.forEach(function (line, index) {
|
|
3664
|
+
const hasRun = Object.prototype.hasOwnProperty.call(runAtLine, index);
|
|
3665
|
+
const reqIdx = hasRun ? runAtLine[index] : -1;
|
|
3666
|
+
const isCursorLine = Boolean(cursor && cursor.lineIndex === index);
|
|
3667
|
+
const gutter = hasRun
|
|
3668
|
+
? '<button type="button" class="http-run" data-req="' + reqIdx + '" title="Run request (Cmd/Alt+Enter)" aria-label="Run request">▶</button>'
|
|
3669
|
+
: '';
|
|
3670
|
+
rows += '<tr class="source-row http-row' + (hasRun ? ' http-request-line' : '') + (isCursorLine ? ' cursor-line' : '') + '" data-line-index="' + index + '">'
|
|
3671
|
+
+ '<td class="num http-gutter">' + gutter + '<span class="num-text">' + (index + 1) + '</span></td>'
|
|
3672
|
+
+ '<td class="source-code">' + (isCursorLine ? renderHttpLineWithCursor(line, env, cursor.column) : highlightHttpLine(line, env)) + '</td>'
|
|
3673
|
+
+ '</tr>';
|
|
3674
|
+
if (Object.prototype.hasOwnProperty.call(respAfterLine, index)) {
|
|
3675
|
+
const rIdx = respAfterLine[index];
|
|
3676
|
+
rows += '<tr class="http-response-row"><td class="num"></td><td class="source-code"><div class="http-response hidden" id="http-resp-' + rIdx + '"></div></td></tr>';
|
|
3677
|
+
}
|
|
3678
|
+
});
|
|
3679
|
+
return '<table class="source-table http-table"><tbody>' + rows + '</tbody></table>';
|
|
3680
|
+
}
|
|
3681
|
+
function renderHttpLineWithCursor(text, env, column) {
|
|
3682
|
+
var col = Math.max(0, Math.min(column, text.length));
|
|
3683
|
+
return highlightHttpLine(text.slice(0, col), env) + '<span class="code-cursor" aria-hidden="true"></span>' + highlightHttpLine(text.slice(col), env);
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
function highlightHttpLine(line, env) {
|
|
3687
|
+
const trimmed = line.trim();
|
|
3688
|
+
if (trimmed.indexOf('###') === 0) return '<span class="http-sep">' + escapeHtml(line) + '</span>';
|
|
3689
|
+
if (trimmed.indexOf('#') === 0 || trimmed.indexOf('//') === 0) return '<span class="tok-comment">' + escapeHtml(line) + '</span>';
|
|
3690
|
+
let html = escapeHtml(line);
|
|
3691
|
+
html = html.replace(/^(\s*)(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)(\s)/, function (whole, pre, method, post) {
|
|
3692
|
+
return pre + '<span class="http-method">' + method + '</span>' + post;
|
|
3693
|
+
});
|
|
3694
|
+
html = html.replace(/\{\{\s*([\w.$-]+)\s*\}\}/g, function (whole, name) {
|
|
3695
|
+
const known = env && Object.prototype.hasOwnProperty.call(env, name);
|
|
3696
|
+
const title = known ? String(env[name]) : 'Undefined variable';
|
|
3697
|
+
return '<span class="http-var ' + (known ? 'known' : 'unknown') + '" title="' + escapeHtml(title) + '">' + escapeHtml(whole) + '</span>';
|
|
3698
|
+
});
|
|
3699
|
+
return html;
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
function sendHttp(request) {
|
|
3703
|
+
if (window.monacoriHttp && typeof window.monacoriHttp.send === 'function') {
|
|
3704
|
+
return Promise.resolve(window.monacoriHttp.send(request));
|
|
3705
|
+
}
|
|
3706
|
+
return fetch('/__http_send', {
|
|
3707
|
+
method: 'POST',
|
|
3708
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3709
|
+
body: JSON.stringify(request),
|
|
3710
|
+
}).then(function (response) { return response.json(); });
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
function runHttpRequest(reqIndex) {
|
|
3714
|
+
const path = document.getElementById('source-viewer')?.dataset.openPath || '';
|
|
3715
|
+
const requests = httpRequestsByPath.get(path);
|
|
3716
|
+
if (!requests || !requests[reqIndex]) return;
|
|
3717
|
+
const req = requests[reqIndex];
|
|
3718
|
+
const env = Object.assign({}, httpVarsByPath.get(path) || {}, currentHttpEnv());
|
|
3719
|
+
const headers = {};
|
|
3720
|
+
req.headers.forEach(function (header) {
|
|
3721
|
+
const key = applyHttpVars(header.name, env);
|
|
3722
|
+
if (key) headers[key] = applyHttpVars(header.value, env);
|
|
3723
|
+
});
|
|
3724
|
+
const resolved = {
|
|
3725
|
+
method: req.method || 'GET',
|
|
3726
|
+
url: applyHttpVars(req.url, env),
|
|
3727
|
+
headers: headers,
|
|
3728
|
+
body: req.body ? applyHttpVars(req.body, env) : undefined,
|
|
3729
|
+
};
|
|
3730
|
+
const target = document.getElementById('http-resp-' + reqIndex);
|
|
3731
|
+
if (target) {
|
|
3732
|
+
target.className = 'http-response loading';
|
|
3733
|
+
target.textContent = resolved.method + ' ' + resolved.url;
|
|
3734
|
+
}
|
|
3735
|
+
sendHttp(resolved).then(function (result) {
|
|
3736
|
+
if (target) renderHttpResponse(target, result);
|
|
3737
|
+
}).catch(function (error) {
|
|
3738
|
+
if (target) {
|
|
3739
|
+
target.className = 'http-response error';
|
|
3740
|
+
target.innerHTML = '<div class="http-resp-head"><span class="http-status bad">Failed</span></div><pre class="http-resp-body">' + escapeHtml(String(error && error.message ? error.message : error)) + '</pre>';
|
|
3741
|
+
}
|
|
3742
|
+
});
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
function runHttpAtCaret() {
|
|
3746
|
+
const path = document.getElementById('source-viewer')?.dataset.openPath || '';
|
|
3747
|
+
const requests = httpRequestsByPath.get(path);
|
|
3748
|
+
if (!requests || !requests.length) return;
|
|
3749
|
+
const caretLine = viewerCursor && viewerCursor.path === path ? viewerCursor.lineIndex : 0;
|
|
3750
|
+
let chosen = -1;
|
|
3751
|
+
for (let i = 0; i < requests.length; i++) {
|
|
3752
|
+
const req = requests[i];
|
|
3753
|
+
const from = req.boundaryLine >= 0 ? req.boundaryLine : req.startLine;
|
|
3754
|
+
if (from <= caretLine && caretLine <= req.endLine) { chosen = i; break; }
|
|
3755
|
+
if (from <= caretLine) chosen = i;
|
|
3756
|
+
}
|
|
3757
|
+
if (chosen < 0) chosen = 0;
|
|
3758
|
+
runHttpRequest(chosen);
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
function renderHttpResponse(target, result) {
|
|
3762
|
+
if (!result || !result.ok) {
|
|
3763
|
+
target.className = 'http-response error';
|
|
3764
|
+
const message = result && result.error ? result.error : 'Request failed';
|
|
3765
|
+
target.innerHTML = '<div class="http-resp-head"><span class="http-status bad">Failed</span></div><pre class="http-resp-body">' + escapeHtml(message) + '</pre>';
|
|
3766
|
+
return;
|
|
3767
|
+
}
|
|
3768
|
+
target.className = 'http-response';
|
|
3769
|
+
const status = Number(result.status) || 0;
|
|
3770
|
+
const statusClass = status >= 200 && status < 300 ? 'ok' : (status >= 400 ? 'bad' : 'warn');
|
|
3771
|
+
const headers = result.headers || {};
|
|
3772
|
+
const headerKeys = Object.keys(headers).sort();
|
|
3773
|
+
const headerHtml = headerKeys.map(function (key) {
|
|
3774
|
+
return '<div class="http-h"><span class="http-h-k">' + escapeHtml(key) + '</span><span class="http-h-v">' + escapeHtml(String(headers[key])) + '</span></div>';
|
|
3775
|
+
}).join('');
|
|
3776
|
+
let contentType = '';
|
|
3777
|
+
for (let i = 0; i < headerKeys.length; i++) {
|
|
3778
|
+
if (headerKeys[i].toLowerCase() === 'content-type') { contentType = String(headers[headerKeys[i]]); break; }
|
|
3779
|
+
}
|
|
3780
|
+
const bodyText = result.body == null ? '' : String(result.body);
|
|
3781
|
+
const bodyHtml = formatHttpBody(bodyText, contentType);
|
|
3782
|
+
target.innerHTML =
|
|
3783
|
+
'<div class="http-resp-head">'
|
|
3784
|
+
+ '<span class="http-status ' + statusClass + '">' + status + (result.statusText ? ' ' + escapeHtml(result.statusText) : '') + '</span>'
|
|
3785
|
+
+ '<span class="http-resp-meta">' + (Number(result.durationMs) || 0) + ' ms</span>'
|
|
3786
|
+
+ '<span class="http-resp-meta">' + formatBytes(bodyText.length) + '</span>'
|
|
3787
|
+
+ (headerKeys.length ? '<button type="button" class="http-resp-toggle">Headers (' + headerKeys.length + ')</button>' : '')
|
|
3788
|
+
+ '</div>'
|
|
3789
|
+
+ '<div class="http-resp-headers hidden">' + headerHtml + '</div>'
|
|
3790
|
+
+ '<pre class="http-resp-body">' + bodyHtml + '</pre>';
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
function formatHttpBody(text, contentType) {
|
|
3794
|
+
if (!text) return '<span class="http-resp-empty">(empty body)</span>';
|
|
3795
|
+
const looksJson = /json/i.test(contentType) || /^[\[{]/.test(text.trim());
|
|
3796
|
+
if (looksJson) {
|
|
3797
|
+
try {
|
|
3798
|
+
const pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
3799
|
+
return pretty.split(/\r?\n/).map(function (line) { return highlightLine(line, 'json'); }).join('\n');
|
|
3800
|
+
} catch (error) {}
|
|
3801
|
+
}
|
|
3802
|
+
return escapeHtml(text);
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
function populateHttpEnvSelect() {
|
|
3806
|
+
const select = document.getElementById('http-env-select');
|
|
3807
|
+
if (!select) return;
|
|
3808
|
+
let opts = '<option value="">No environment</option>';
|
|
3809
|
+
httpEnvNames.forEach(function (name) {
|
|
3810
|
+
opts += '<option value="' + escapeHtml(name) + '"' + (name === currentHttpEnvName ? ' selected' : '') + '>' + escapeHtml(name) + '</option>';
|
|
3811
|
+
});
|
|
3812
|
+
select.innerHTML = opts;
|
|
3813
|
+
select.addEventListener('change', function () {
|
|
3814
|
+
currentHttpEnvName = select.value;
|
|
3815
|
+
try { localStorage.setItem(httpEnvKey, currentHttpEnvName); } catch (error) {}
|
|
3816
|
+
const path = document.getElementById('source-viewer')?.dataset.openPath || '';
|
|
3817
|
+
if (path && isHttpFile(path)) {
|
|
3818
|
+
const file = sourceByPath.get(path);
|
|
3819
|
+
const body = document.getElementById('source-body');
|
|
3820
|
+
if (file && body) body.innerHTML = renderHttpTable(file);
|
|
3821
|
+
}
|
|
3822
|
+
});
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
function renderSourceTable(file, query) {
|
|
3826
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
3827
|
+
const lines = file.content.split(/\r?\n/);
|
|
3828
|
+
const cursor = viewerCursor && viewerCursor.path === file.path ? viewerCursor : null;
|
|
3829
|
+
const changedSet = new Set(file.changedLines || []);
|
|
3830
|
+
const rows = lines.map((line, index) => {
|
|
3831
|
+
const hit = normalizedQuery.length > 0 && line.toLowerCase().includes(normalizedQuery);
|
|
3832
|
+
const isCursorLine = Boolean(cursor && cursor.lineIndex === index);
|
|
3833
|
+
const isSymbolTarget = Boolean(cursor && cursor.targetLine === index);
|
|
3834
|
+
const isChanged = changedSet.has(index + 1);
|
|
3835
|
+
const classes = [
|
|
3836
|
+
'source-row',
|
|
3837
|
+
hit ? 'search-hit' : '',
|
|
3838
|
+
isChanged ? 'changed-line' : '',
|
|
3839
|
+
isCursorLine ? 'cursor-line' : '',
|
|
3840
|
+
isSymbolTarget ? 'symbol-target' : '',
|
|
3841
|
+
].filter(Boolean).join(' ');
|
|
3842
|
+
return [
|
|
3843
|
+
'<tr class="' + classes + '" data-line-index="' + index + '">',
|
|
3844
|
+
'<td class="num">' + String(index + 1) + '</td>',
|
|
3845
|
+
'<td class="source-code">' + (isCursorLine ? renderLineWithCursor(line, file.language || 'text', cursor.column) : highlightLine(line, file.language || 'text')) + '</td>',
|
|
3846
|
+
'</tr>',
|
|
3847
|
+
].join('');
|
|
3848
|
+
}).join('');
|
|
3849
|
+
return '<table class="source-table"><tbody>' + rows + '</tbody></table>';
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
function renderLineWithCursor(text, language, column) {
|
|
3853
|
+
const boundedColumn = Math.max(0, Math.min(column, text.length));
|
|
3854
|
+
const before = text.slice(0, boundedColumn);
|
|
3855
|
+
const after = text.slice(boundedColumn);
|
|
3856
|
+
return highlightLine(before, language) + '<span class="code-cursor" aria-hidden="true"></span>' + highlightLine(after, language);
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
function highlightLine(text, language) {
|
|
3860
|
+
if (language === 'text') return escapeHtml(text);
|
|
3861
|
+
if (language === 'markup') {
|
|
3862
|
+
return escapeHtml(text).replace(/(<\/?)([\w:-]+)([^&]*?)(\/?>)/g, '$1<span class="tok-tag">$2</span>$3$4');
|
|
3863
|
+
}
|
|
3864
|
+
if (language === 'markdown') {
|
|
3865
|
+
const escaped = escapeHtml(text);
|
|
3866
|
+
if (/^\s{0,3}#{1,6}\s/.test(text)) return '<span class="tok-keyword">' + escaped + '</span>';
|
|
3867
|
+
return escaped.replace(new RegExp(String.fromCharCode(96) + '[^' + String.fromCharCode(96) + ']+' + String.fromCharCode(96), 'g'), '<span class="tok-string">$&</span>');
|
|
3868
|
+
}
|
|
3869
|
+
const keywords = new Set(['as','async','await','break','case','catch','class','const','continue','def','default','defer','do','else','enum','export','extends','final','finally','fn','for','from','func','function','go','if','impl','import','in','interface','let','match','module','new','package','private','protected','public','return','select','static','struct','switch','throw','try','type','val','var','while','yield']);
|
|
3870
|
+
const literals = new Set(['False','None','True','false','nil','null','self','this','true','undefined']);
|
|
3871
|
+
const commentPrefixes = ['python','ruby','shell','yaml','toml'].includes(language) ? ['#'] : ['//'];
|
|
3872
|
+
let output = '';
|
|
3873
|
+
let index = 0;
|
|
3874
|
+
while (index < text.length) {
|
|
3875
|
+
const rest = text.slice(index);
|
|
3876
|
+
const commentPrefix = commentPrefixes.find((prefix) => rest.startsWith(prefix));
|
|
3877
|
+
if (commentPrefix) {
|
|
3878
|
+
output += '<span class="tok-comment">' + escapeHtml(rest) + '</span>';
|
|
3879
|
+
break;
|
|
3880
|
+
}
|
|
3881
|
+
const char = text[index];
|
|
3882
|
+
if (char === '"' || char === "'" || char === String.fromCharCode(96)) {
|
|
3883
|
+
const quote = char;
|
|
3884
|
+
let end = index + 1;
|
|
3885
|
+
let escaped = false;
|
|
3886
|
+
while (end < text.length) {
|
|
3887
|
+
const currentChar = text[end];
|
|
3888
|
+
if (currentChar === quote && !escaped) {
|
|
3889
|
+
end += 1;
|
|
3890
|
+
break;
|
|
3891
|
+
}
|
|
3892
|
+
escaped = currentChar === '\\' && !escaped;
|
|
3893
|
+
if (currentChar !== '\\') escaped = false;
|
|
3894
|
+
end += 1;
|
|
3895
|
+
}
|
|
3896
|
+
output += '<span class="tok-string">' + escapeHtml(text.slice(index, end)) + '</span>';
|
|
3897
|
+
index = end;
|
|
3898
|
+
continue;
|
|
3899
|
+
}
|
|
3900
|
+
const number = rest.match(/^\b\d+(?:\.\d+)?\b/);
|
|
3901
|
+
if (number) {
|
|
3902
|
+
output += '<span class="tok-number">' + escapeHtml(number[0]) + '</span>';
|
|
3903
|
+
index += number[0].length;
|
|
3904
|
+
continue;
|
|
3905
|
+
}
|
|
3906
|
+
const identifier = rest.match(/^[A-Za-z_$][\w$-]*/);
|
|
3907
|
+
if (identifier) {
|
|
3908
|
+
const value = identifier[0];
|
|
3909
|
+
if (keywords.has(value)) output += '<span class="tok-keyword">' + escapeHtml(value) + '</span>';
|
|
3910
|
+
else if (literals.has(value)) output += '<span class="tok-literal">' + escapeHtml(value) + '</span>';
|
|
3911
|
+
else output += escapeHtml(value);
|
|
3912
|
+
index += value.length;
|
|
3913
|
+
continue;
|
|
3914
|
+
}
|
|
3915
|
+
output += escapeHtml(char);
|
|
3916
|
+
index += 1;
|
|
3917
|
+
}
|
|
3918
|
+
return output;
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
function escapeHtml(value) {
|
|
3922
|
+
return String(value)
|
|
3923
|
+
.replace(/&/g, '&')
|
|
3924
|
+
.replace(/</g, '<')
|
|
3925
|
+
.replace(/>/g, '>')
|
|
3926
|
+
.replace(/"/g, '"')
|
|
3927
|
+
.replace(/'/g, ''');
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
function formatBytes(bytes) {
|
|
3931
|
+
if (bytes < 1024) return bytes + ' B';
|
|
3932
|
+
const kib = bytes / 1024;
|
|
3933
|
+
if (kib < 1024) return kib.toFixed(1) + ' KiB';
|
|
3934
|
+
return (kib / 1024).toFixed(1) + ' MiB';
|
|
3935
|
+
}
|