@youtyan/code-viewer 0.1.15 → 0.1.16
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/package.json +1 -1
- package/web/app.js +514 -5
- package/web/style.css +44 -3
- package/web-src/server/git.ts +23 -0
- package/web-src/server/preview.ts +192 -17
- package/web-src/server/range.ts +228 -0
- package/web-src/server/runtime.d.ts +10 -0
- package/web-src/types.ts +6 -0
package/package.json
CHANGED
package/web/app.js
CHANGED
|
@@ -378,6 +378,18 @@
|
|
|
378
378
|
{ action: "scroll-main-up", key: "k", scope: "main" },
|
|
379
379
|
{ action: "scroll-main-page-down", key: "d", scope: "main", ctrl: true },
|
|
380
380
|
{ action: "scroll-main-page-up", key: "u", scope: "main", ctrl: true },
|
|
381
|
+
{ action: "scroll-main-page-down", key: "pagedown", scope: "main" },
|
|
382
|
+
{ action: "scroll-main-page-up", key: "pageup", scope: "main" },
|
|
383
|
+
{ action: "scroll-main-page-down", key: "pagedown", scope: "global" },
|
|
384
|
+
{ action: "scroll-main-page-up", key: "pageup", scope: "global" },
|
|
385
|
+
{ action: "scroll-main-page-down", key: "pagedown", scope: "sidebar" },
|
|
386
|
+
{ action: "scroll-main-page-up", key: "pageup", scope: "sidebar" },
|
|
387
|
+
{ action: "scroll-main-page-down", key: "arrowdown", scope: "main", ctrl: true },
|
|
388
|
+
{ action: "scroll-main-page-up", key: "arrowup", scope: "main", ctrl: true },
|
|
389
|
+
{ action: "scroll-main-page-down", key: "arrowdown", scope: "global", ctrl: true },
|
|
390
|
+
{ action: "scroll-main-page-up", key: "arrowup", scope: "global", ctrl: true },
|
|
391
|
+
{ action: "scroll-main-page-down", key: "arrowdown", scope: "sidebar", ctrl: true },
|
|
392
|
+
{ action: "scroll-main-page-up", key: "arrowup", scope: "sidebar", ctrl: true },
|
|
381
393
|
{ action: "tab-preview", key: "p", scope: "main", pendingG: true },
|
|
382
394
|
{ action: "tab-code", key: "c", scope: "main", pendingG: true },
|
|
383
395
|
{ action: "goto-top", key: "g", pendingG: true },
|
|
@@ -6772,6 +6784,7 @@
|
|
|
6772
6784
|
const DEFAULT_RANGE = { from: "HEAD", to: "worktree" };
|
|
6773
6785
|
const VIRTUAL_SOURCE_LINE_THRESHOLD = 3000;
|
|
6774
6786
|
const VIRTUAL_SOURCE_SIZE_THRESHOLD = 1024 * 1024;
|
|
6787
|
+
const VIRTUAL_SOURCE_PAGE_SIZE = 2000;
|
|
6775
6788
|
const VIRTUAL_SOURCE_ROW_HEIGHT = 20;
|
|
6776
6789
|
const VIRTUAL_SOURCE_HIGHLIGHT_MAX_LINE_LENGTH = 2000;
|
|
6777
6790
|
let highlightLoadPromise = null;
|
|
@@ -6912,6 +6925,8 @@
|
|
|
6912
6925
|
const before = scroller.scrollTop;
|
|
6913
6926
|
if (edge === "center")
|
|
6914
6927
|
scroller.scrollTop = Math.max(0, top - Math.round(scroller.clientHeight / 2));
|
|
6928
|
+
else if (edge === "start")
|
|
6929
|
+
scroller.scrollTop = top;
|
|
6915
6930
|
else if (top < scroller.scrollTop)
|
|
6916
6931
|
scroller.scrollTop = top;
|
|
6917
6932
|
else if (bottom > scroller.scrollTop + scroller.clientHeight)
|
|
@@ -6944,7 +6959,7 @@
|
|
|
6944
6959
|
const delta = unit === "page" ? pageRows : 1;
|
|
6945
6960
|
cursor.line = Math.max(1, Math.min(total, cursor.line + direction * delta));
|
|
6946
6961
|
syncSourceCursorRows(target);
|
|
6947
|
-
scrollSourceCursorIntoView(cursor);
|
|
6962
|
+
scrollSourceCursorIntoView(cursor, unit === "page" ? "start" : "nearest");
|
|
6948
6963
|
return true;
|
|
6949
6964
|
}
|
|
6950
6965
|
function scrollMainPanel(direction, repeated = false, unit = "line") {
|
|
@@ -6959,6 +6974,31 @@
|
|
|
6959
6974
|
else
|
|
6960
6975
|
window.scrollBy({ top, behavior });
|
|
6961
6976
|
}
|
|
6977
|
+
let MAIN_SURFACE_FOCUS_SEQ = 0;
|
|
6978
|
+
function focusMainSurface() {
|
|
6979
|
+
const target = findMainScrollTarget();
|
|
6980
|
+
if (target?.matches("#content .gdp-source-virtual-scroller")) {
|
|
6981
|
+
target.focus({ preventScroll: true });
|
|
6982
|
+
setPanelFocusScope("main");
|
|
6983
|
+
return;
|
|
6984
|
+
}
|
|
6985
|
+
focusMainPanel();
|
|
6986
|
+
}
|
|
6987
|
+
function scheduleMainSurfaceFocus() {
|
|
6988
|
+
const seq = ++MAIN_SURFACE_FOCUS_SEQ;
|
|
6989
|
+
const apply = () => {
|
|
6990
|
+
if (seq !== MAIN_SURFACE_FOCUS_SEQ || PALETTE)
|
|
6991
|
+
return;
|
|
6992
|
+
if (isEditableKeyTarget(document.activeElement))
|
|
6993
|
+
return;
|
|
6994
|
+
focusMainSurface();
|
|
6995
|
+
};
|
|
6996
|
+
focusMainPanel();
|
|
6997
|
+
queueMicrotask(apply);
|
|
6998
|
+
requestAnimationFrame(apply);
|
|
6999
|
+
setTimeout(apply, 100);
|
|
7000
|
+
setTimeout(apply, 300);
|
|
7001
|
+
}
|
|
6962
7002
|
function scrollMainToEdge(edge) {
|
|
6963
7003
|
if (moveSourceCursor(edge === "bottom" ? 1 : -1, "edge", edge))
|
|
6964
7004
|
return;
|
|
@@ -7586,7 +7626,7 @@
|
|
|
7586
7626
|
children_omitted: dir.children_omitted,
|
|
7587
7627
|
children_omitted_reason: dir.children_omitted_reason
|
|
7588
7628
|
});
|
|
7589
|
-
|
|
7629
|
+
scheduleMainSurfaceFocus();
|
|
7590
7630
|
});
|
|
7591
7631
|
} else {
|
|
7592
7632
|
li.addEventListener("click", toggleDir);
|
|
@@ -7622,7 +7662,7 @@
|
|
|
7622
7662
|
onFileClick(f2);
|
|
7623
7663
|
else
|
|
7624
7664
|
scrollToFile(f2.path);
|
|
7625
|
-
|
|
7665
|
+
scheduleMainSurfaceFocus();
|
|
7626
7666
|
});
|
|
7627
7667
|
if (!onFileClick)
|
|
7628
7668
|
li.addEventListener("mouseenter", () => prefetchByPath(f2.path), { passive: true });
|
|
@@ -7655,7 +7695,7 @@
|
|
|
7655
7695
|
onFileClick(f2);
|
|
7656
7696
|
else
|
|
7657
7697
|
scrollToFile(f2.path);
|
|
7658
|
-
|
|
7698
|
+
scheduleMainSurfaceFocus();
|
|
7659
7699
|
});
|
|
7660
7700
|
if (!onFileClick)
|
|
7661
7701
|
li.addEventListener("mouseenter", () => prefetchByPath(f2.path), { passive: true });
|
|
@@ -9875,6 +9915,9 @@
|
|
|
9875
9915
|
url.searchParams.delete("virtual");
|
|
9876
9916
|
return url.pathname + url.search;
|
|
9877
9917
|
}
|
|
9918
|
+
function buildFileRangeUrl(target, start, end) {
|
|
9919
|
+
return "/file_range?path=" + encodeURIComponent(target.path) + "&ref=" + encodeURIComponent(target.ref || "worktree") + "&start=" + encodeURIComponent(String(start)) + "&end=" + encodeURIComponent(String(end));
|
|
9920
|
+
}
|
|
9878
9921
|
function currentSourceLineTarget(target) {
|
|
9879
9922
|
const routeTarget = sourceTargetFromRoute();
|
|
9880
9923
|
return sourceTargetsEqual(routeTarget, target) && STATE.route.screen === "file" ? STATE.route.line : undefined;
|
|
@@ -9938,6 +9981,161 @@
|
|
|
9938
9981
|
document.addEventListener("mouseup", () => {
|
|
9939
9982
|
SOURCE_LINE_DRAG = null;
|
|
9940
9983
|
});
|
|
9984
|
+
function virtualSourceSearchRanges(line, query) {
|
|
9985
|
+
const needle = query.toLowerCase();
|
|
9986
|
+
if (!needle)
|
|
9987
|
+
return [];
|
|
9988
|
+
const haystack = line.toLowerCase();
|
|
9989
|
+
const ranges = [];
|
|
9990
|
+
let cursor = 0;
|
|
9991
|
+
while (cursor <= haystack.length) {
|
|
9992
|
+
const index = haystack.indexOf(needle, cursor);
|
|
9993
|
+
if (index < 0)
|
|
9994
|
+
break;
|
|
9995
|
+
ranges.push({ start: index, end: index + query.length });
|
|
9996
|
+
cursor = Math.max(index + query.length, index + 1);
|
|
9997
|
+
}
|
|
9998
|
+
return ranges;
|
|
9999
|
+
}
|
|
10000
|
+
function collectVirtualSourceSearchMatches(lines, query, max = 5000) {
|
|
10001
|
+
const matches = [];
|
|
10002
|
+
for (let index = 0;index < lines.length && matches.length < max; index++) {
|
|
10003
|
+
for (const range of virtualSourceSearchRanges(lines[index] || "", query)) {
|
|
10004
|
+
matches.push({ line: index + 1, start: range.start, end: range.end });
|
|
10005
|
+
if (matches.length >= max)
|
|
10006
|
+
break;
|
|
10007
|
+
}
|
|
10008
|
+
}
|
|
10009
|
+
return matches;
|
|
10010
|
+
}
|
|
10011
|
+
function appendVirtualSourceLineCode(code2, line, query, activeRange, lineNumber) {
|
|
10012
|
+
const ranges = virtualSourceSearchRanges(line, query);
|
|
10013
|
+
if (!ranges.length)
|
|
10014
|
+
return false;
|
|
10015
|
+
let cursor = 0;
|
|
10016
|
+
for (const range of ranges) {
|
|
10017
|
+
if (range.start > cursor)
|
|
10018
|
+
code2.appendChild(document.createTextNode(line.slice(cursor, range.start)));
|
|
10019
|
+
const mark = document.createElement("mark");
|
|
10020
|
+
const active = !!activeRange && activeRange.line === lineNumber && activeRange.start === range.start && activeRange.end === range.end;
|
|
10021
|
+
mark.className = active ? "gdp-source-virtual-search-hit active" : "gdp-source-virtual-search-hit";
|
|
10022
|
+
mark.textContent = line.slice(range.start, range.end);
|
|
10023
|
+
code2.appendChild(mark);
|
|
10024
|
+
cursor = range.end;
|
|
10025
|
+
}
|
|
10026
|
+
if (cursor < line.length)
|
|
10027
|
+
code2.appendChild(document.createTextNode(line.slice(cursor)));
|
|
10028
|
+
return true;
|
|
10029
|
+
}
|
|
10030
|
+
function createVirtualSourceSearch(wrap, scroller, findMatches, renderFn) {
|
|
10031
|
+
const bar = document.createElement("div");
|
|
10032
|
+
bar.className = "gdp-source-virtual-search";
|
|
10033
|
+
const input = document.createElement("input");
|
|
10034
|
+
input.type = "search";
|
|
10035
|
+
input.placeholder = "Find in file";
|
|
10036
|
+
input.autocomplete = "off";
|
|
10037
|
+
input.spellcheck = false;
|
|
10038
|
+
const count = document.createElement("span");
|
|
10039
|
+
count.className = "gdp-source-virtual-search-count";
|
|
10040
|
+
const previous = document.createElement("button");
|
|
10041
|
+
previous.type = "button";
|
|
10042
|
+
previous.textContent = "Prev";
|
|
10043
|
+
const next = document.createElement("button");
|
|
10044
|
+
next.type = "button";
|
|
10045
|
+
next.textContent = "Next";
|
|
10046
|
+
const close = document.createElement("button");
|
|
10047
|
+
close.type = "button";
|
|
10048
|
+
close.textContent = "Close";
|
|
10049
|
+
bar.append(input, count, previous, next, close);
|
|
10050
|
+
wrap.querySelector(".gdp-source-virtual-info")?.appendChild(bar);
|
|
10051
|
+
bar.hidden = true;
|
|
10052
|
+
let matches = [];
|
|
10053
|
+
let active = -1;
|
|
10054
|
+
let debounce = 0;
|
|
10055
|
+
let searchVersion = 0;
|
|
10056
|
+
const hide = () => {
|
|
10057
|
+
bar.hidden = true;
|
|
10058
|
+
renderFn();
|
|
10059
|
+
scroller.focus({ preventScroll: true });
|
|
10060
|
+
};
|
|
10061
|
+
const sync = () => {
|
|
10062
|
+
const query = input.value;
|
|
10063
|
+
const version = ++searchVersion;
|
|
10064
|
+
if (!query) {
|
|
10065
|
+
matches = [];
|
|
10066
|
+
active = -1;
|
|
10067
|
+
count.textContent = "";
|
|
10068
|
+
renderFn();
|
|
10069
|
+
return;
|
|
10070
|
+
}
|
|
10071
|
+
count.textContent = "Searching...";
|
|
10072
|
+
findMatches(query).then((nextMatches) => {
|
|
10073
|
+
if (version !== searchVersion)
|
|
10074
|
+
return;
|
|
10075
|
+
matches = nextMatches;
|
|
10076
|
+
active = matches.length ? Math.max(0, Math.min(active, matches.length - 1)) : -1;
|
|
10077
|
+
count.textContent = matches.length ? active + 1 + " / " + matches.length : "0 / 0";
|
|
10078
|
+
if (active >= 0)
|
|
10079
|
+
scroller.scrollTop = Math.max(0, (matches[active].line - 1) * VIRTUAL_SOURCE_ROW_HEIGHT - VIRTUAL_SOURCE_ROW_HEIGHT * 3);
|
|
10080
|
+
renderFn();
|
|
10081
|
+
}).catch(() => {
|
|
10082
|
+
if (version !== searchVersion)
|
|
10083
|
+
return;
|
|
10084
|
+
matches = [];
|
|
10085
|
+
active = -1;
|
|
10086
|
+
count.textContent = "Search failed";
|
|
10087
|
+
renderFn();
|
|
10088
|
+
});
|
|
10089
|
+
};
|
|
10090
|
+
const scheduleSync = () => {
|
|
10091
|
+
if (debounce)
|
|
10092
|
+
window.clearTimeout(debounce);
|
|
10093
|
+
debounce = window.setTimeout(sync, 120);
|
|
10094
|
+
};
|
|
10095
|
+
const move = (direction) => {
|
|
10096
|
+
if (!matches.length)
|
|
10097
|
+
return;
|
|
10098
|
+
active = (active + direction + matches.length) % matches.length;
|
|
10099
|
+
count.textContent = active + 1 + " / " + matches.length;
|
|
10100
|
+
scroller.scrollTop = Math.max(0, (matches[active].line - 1) * VIRTUAL_SOURCE_ROW_HEIGHT - VIRTUAL_SOURCE_ROW_HEIGHT * 3);
|
|
10101
|
+
renderFn();
|
|
10102
|
+
};
|
|
10103
|
+
input.addEventListener("input", () => {
|
|
10104
|
+
active = 0;
|
|
10105
|
+
scheduleSync();
|
|
10106
|
+
});
|
|
10107
|
+
input.addEventListener("keydown", (e2) => {
|
|
10108
|
+
if (e2.key === "Escape") {
|
|
10109
|
+
e2.preventDefault();
|
|
10110
|
+
hide();
|
|
10111
|
+
} else if (e2.key === "Enter") {
|
|
10112
|
+
e2.preventDefault();
|
|
10113
|
+
move(e2.shiftKey ? -1 : 1);
|
|
10114
|
+
}
|
|
10115
|
+
});
|
|
10116
|
+
previous.addEventListener("click", () => move(-1));
|
|
10117
|
+
next.addEventListener("click", () => move(1));
|
|
10118
|
+
close.addEventListener("click", hide);
|
|
10119
|
+
return {
|
|
10120
|
+
open: () => {
|
|
10121
|
+
bar.hidden = false;
|
|
10122
|
+
input.focus();
|
|
10123
|
+
input.select();
|
|
10124
|
+
sync();
|
|
10125
|
+
},
|
|
10126
|
+
query: () => bar.hidden ? "" : input.value,
|
|
10127
|
+
activeRange: () => active >= 0 ? matches[active] || null : null
|
|
10128
|
+
};
|
|
10129
|
+
}
|
|
10130
|
+
function openVirtualSourceSearchFromKeyboard(targetEl) {
|
|
10131
|
+
const active = targetEl?.closest("#content .gdp-source-virtual");
|
|
10132
|
+
const fallback = document.querySelector("#content .gdp-source-viewer.virtual .gdp-source-virtual:not([hidden])");
|
|
10133
|
+
const search = active?.__gdpVirtualSourceSearch || fallback?.__gdpVirtualSourceSearch;
|
|
10134
|
+
if (!search)
|
|
10135
|
+
return false;
|
|
10136
|
+
search.open();
|
|
10137
|
+
return true;
|
|
10138
|
+
}
|
|
9941
10139
|
function renderVirtualSource(target, textValue, lines, hljsRef, lang) {
|
|
9942
10140
|
const wrap = document.createElement("div");
|
|
9943
10141
|
wrap.className = "gdp-source-virtual";
|
|
@@ -9986,6 +10184,9 @@
|
|
|
9986
10184
|
info.append(badge, summary, actions);
|
|
9987
10185
|
const scroller = document.createElement("div");
|
|
9988
10186
|
scroller.className = "gdp-source-virtual-scroller";
|
|
10187
|
+
scroller.tabIndex = 0;
|
|
10188
|
+
scroller.setAttribute("role", "region");
|
|
10189
|
+
scroller.setAttribute("aria-label", target.path + " source code");
|
|
9989
10190
|
const spacer = document.createElement("div");
|
|
9990
10191
|
spacer.className = "gdp-source-virtual-spacer";
|
|
9991
10192
|
spacer.style.height = Math.max(1, lines.length * VIRTUAL_SOURCE_ROW_HEIGHT) + "px";
|
|
@@ -9997,6 +10198,7 @@
|
|
|
9997
10198
|
let raf = 0;
|
|
9998
10199
|
let renderedStart = -1;
|
|
9999
10200
|
let renderedEnd = -1;
|
|
10201
|
+
let search = null;
|
|
10000
10202
|
const render = () => {
|
|
10001
10203
|
raf = 0;
|
|
10002
10204
|
const viewportHeight = scroller.clientHeight || window.innerHeight;
|
|
@@ -10023,7 +10225,9 @@
|
|
|
10023
10225
|
const code2 = document.createElement("span");
|
|
10024
10226
|
code2.className = "gdp-source-virtual-line-code";
|
|
10025
10227
|
const line = lines[index] ?? "";
|
|
10026
|
-
|
|
10228
|
+
const searchQuery = search?.query() || "";
|
|
10229
|
+
const activeRange = search?.activeRange() || null;
|
|
10230
|
+
if (appendVirtualSourceLineCode(code2, line, searchQuery, activeRange, index + 1)) {} else if (hljsRef && hljsRef.highlight && lang && line.length <= VIRTUAL_SOURCE_HIGHLIGHT_MAX_LINE_LENGTH && (!hljsRef.getLanguage || hljsRef.getLanguage(lang))) {
|
|
10027
10231
|
try {
|
|
10028
10232
|
code2.innerHTML = hljsRef.highlight(line, { language: lang, ignoreIllegals: true }).value;
|
|
10029
10233
|
code2.classList.add("hljs");
|
|
@@ -10044,6 +10248,8 @@
|
|
|
10044
10248
|
};
|
|
10045
10249
|
scroller.__gdpRenderVirtualSource = render;
|
|
10046
10250
|
scroller.addEventListener("scroll", schedule, { passive: true });
|
|
10251
|
+
search = createVirtualSourceSearch(wrap, scroller, (query) => Promise.resolve(collectVirtualSourceSearchMatches(lines, query)), render);
|
|
10252
|
+
wrap.__gdpVirtualSourceSearch = search;
|
|
10047
10253
|
let resizeObserver = null;
|
|
10048
10254
|
resizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(() => {
|
|
10049
10255
|
if (!scroller.isConnected) {
|
|
@@ -10058,6 +10264,253 @@
|
|
|
10058
10264
|
schedule();
|
|
10059
10265
|
return wrap;
|
|
10060
10266
|
}
|
|
10267
|
+
function renderPagedVirtualSource(target, size, initialStart, initialLines, initialComplete, initialTotal, hljsRef, lang, signal) {
|
|
10268
|
+
const wrap = document.createElement("div");
|
|
10269
|
+
wrap.className = "gdp-source-virtual";
|
|
10270
|
+
const info = document.createElement("div");
|
|
10271
|
+
info.className = "gdp-source-virtual-info";
|
|
10272
|
+
const badge = document.createElement("span");
|
|
10273
|
+
badge.className = "gdp-source-virtual-badge";
|
|
10274
|
+
badge.textContent = "Virtual mode";
|
|
10275
|
+
const summary = document.createElement("span");
|
|
10276
|
+
summary.className = "gdp-source-virtual-summary";
|
|
10277
|
+
const actions = document.createElement("span");
|
|
10278
|
+
actions.className = "gdp-source-virtual-actions";
|
|
10279
|
+
const raw = document.createElement("a");
|
|
10280
|
+
raw.className = "gdp-source-virtual-action";
|
|
10281
|
+
raw.href = buildRawFileUrl(target);
|
|
10282
|
+
raw.target = "_blank";
|
|
10283
|
+
raw.rel = "noreferrer";
|
|
10284
|
+
raw.textContent = "Open raw";
|
|
10285
|
+
const full = document.createElement("a");
|
|
10286
|
+
full.className = "gdp-source-virtual-action";
|
|
10287
|
+
full.href = buildCurrentFileRouteWithVirtualMode(target, "off");
|
|
10288
|
+
full.textContent = "Open full view";
|
|
10289
|
+
full.title = "Render every line without paged loading. This can be slow for large files.";
|
|
10290
|
+
full.addEventListener("click", (e2) => {
|
|
10291
|
+
e2.preventDefault();
|
|
10292
|
+
const url = new URL(full.href, window.location.origin);
|
|
10293
|
+
setRoute(parseRoute(url.pathname, url.search, currentRange()), true);
|
|
10294
|
+
renderStandaloneSource(target);
|
|
10295
|
+
});
|
|
10296
|
+
actions.append(raw, full);
|
|
10297
|
+
info.append(badge, summary, actions);
|
|
10298
|
+
const scroller = document.createElement("div");
|
|
10299
|
+
scroller.className = "gdp-source-virtual-scroller";
|
|
10300
|
+
scroller.tabIndex = 0;
|
|
10301
|
+
scroller.setAttribute("role", "region");
|
|
10302
|
+
scroller.setAttribute("aria-label", target.path + " source code");
|
|
10303
|
+
const spacer = document.createElement("div");
|
|
10304
|
+
spacer.className = "gdp-source-virtual-spacer";
|
|
10305
|
+
const windowEl = document.createElement("div");
|
|
10306
|
+
windowEl.className = "gdp-source-virtual-window";
|
|
10307
|
+
spacer.appendChild(windowEl);
|
|
10308
|
+
scroller.appendChild(spacer);
|
|
10309
|
+
wrap.append(info, scroller);
|
|
10310
|
+
const lines = new Map;
|
|
10311
|
+
const requestedPages = new Set;
|
|
10312
|
+
const failedPages = new Set;
|
|
10313
|
+
const targetLine = lineTargetStart(currentSourceLineTarget(target)) || 1;
|
|
10314
|
+
let complete = initialComplete;
|
|
10315
|
+
let totalRows = initialComplete ? Math.max(1, initialTotal) : Math.max(initialTotal || 1, initialStart + initialLines.length - 1, targetLine + VIRTUAL_SOURCE_PAGE_SIZE);
|
|
10316
|
+
initialLines.forEach((line, index) => lines.set(initialStart + index, line));
|
|
10317
|
+
requestedPages.add(Math.max(0, Math.floor((initialStart - 1) / VIRTUAL_SOURCE_PAGE_SIZE)));
|
|
10318
|
+
for (let line = initialStart;line < initialStart + initialLines.length; line += VIRTUAL_SOURCE_PAGE_SIZE) {
|
|
10319
|
+
requestedPages.add(Math.max(0, Math.floor((line - 1) / VIRTUAL_SOURCE_PAGE_SIZE)));
|
|
10320
|
+
}
|
|
10321
|
+
const updateTotals = () => {
|
|
10322
|
+
SOURCE_CURSOR_TOTALS.set(sourceCursorKey(target), totalRows);
|
|
10323
|
+
summary.textContent = (complete ? totalRows.toLocaleString() : lines.size.toLocaleString() + "+") + " lines loaded from " + formatBytes(size) + ". More rows load as you scroll.";
|
|
10324
|
+
spacer.style.height = Math.max(1, totalRows * VIRTUAL_SOURCE_ROW_HEIGHT) + "px";
|
|
10325
|
+
};
|
|
10326
|
+
const loadPage = (line) => {
|
|
10327
|
+
if (signal?.aborted || complete && line > totalRows)
|
|
10328
|
+
return;
|
|
10329
|
+
const page = Math.max(0, Math.floor((line - 1) / VIRTUAL_SOURCE_PAGE_SIZE));
|
|
10330
|
+
if (requestedPages.has(page))
|
|
10331
|
+
return;
|
|
10332
|
+
if (failedPages.has(page))
|
|
10333
|
+
return;
|
|
10334
|
+
requestedPages.add(page);
|
|
10335
|
+
const start = page * VIRTUAL_SOURCE_PAGE_SIZE + 1;
|
|
10336
|
+
const end = start + VIRTUAL_SOURCE_PAGE_SIZE - 1;
|
|
10337
|
+
trackLoad(fetch(buildFileRangeUrl(target, start, end), { signal }).then((res) => res.ok ? res.json() : null).then((data) => {
|
|
10338
|
+
if (!data || signal?.aborted)
|
|
10339
|
+
return;
|
|
10340
|
+
data.lines.forEach((lineValue, index) => lines.set(data.start + index, lineValue));
|
|
10341
|
+
totalRows = data.complete ? Math.max(1, data.total) : Math.max(totalRows, data.total, end + VIRTUAL_SOURCE_PAGE_SIZE);
|
|
10342
|
+
complete = data.complete === true;
|
|
10343
|
+
updateTotals();
|
|
10344
|
+
renderedStart = -1;
|
|
10345
|
+
renderedEnd = -1;
|
|
10346
|
+
render();
|
|
10347
|
+
}).catch((err) => {
|
|
10348
|
+
if (!isAbortError(err)) {
|
|
10349
|
+
failedPages.add(page);
|
|
10350
|
+
renderedStart = -1;
|
|
10351
|
+
renderedEnd = -1;
|
|
10352
|
+
schedule();
|
|
10353
|
+
}
|
|
10354
|
+
}));
|
|
10355
|
+
};
|
|
10356
|
+
let raf = 0;
|
|
10357
|
+
let renderedStart = -1;
|
|
10358
|
+
let renderedEnd = -1;
|
|
10359
|
+
let search = null;
|
|
10360
|
+
let searchController = null;
|
|
10361
|
+
const render = () => {
|
|
10362
|
+
raf = 0;
|
|
10363
|
+
const viewportHeight = scroller.clientHeight || window.innerHeight;
|
|
10364
|
+
const overscan = 20;
|
|
10365
|
+
const start = Math.max(0, Math.floor(scroller.scrollTop / VIRTUAL_SOURCE_ROW_HEIGHT) - overscan);
|
|
10366
|
+
const end = Math.min(totalRows, Math.ceil((scroller.scrollTop + viewportHeight) / VIRTUAL_SOURCE_ROW_HEIGHT) + overscan);
|
|
10367
|
+
if (start === renderedStart && end === renderedEnd)
|
|
10368
|
+
return;
|
|
10369
|
+
renderedStart = start;
|
|
10370
|
+
renderedEnd = end;
|
|
10371
|
+
windowEl.replaceChildren();
|
|
10372
|
+
windowEl.style.transform = "translateY(" + start * VIRTUAL_SOURCE_ROW_HEIGHT + "px)";
|
|
10373
|
+
const fragment = document.createDocumentFragment();
|
|
10374
|
+
for (let index = start;index < end; index++) {
|
|
10375
|
+
const lineNumber = index + 1;
|
|
10376
|
+
if (!lines.has(lineNumber))
|
|
10377
|
+
loadPage(lineNumber);
|
|
10378
|
+
const row = document.createElement("div");
|
|
10379
|
+
row.className = "gdp-source-virtual-row";
|
|
10380
|
+
row.dataset.line = String(lineNumber);
|
|
10381
|
+
row.classList.toggle("gdp-source-line-target", lineInSourceTarget(lineNumber, currentSourceLineTarget(target)));
|
|
10382
|
+
row.classList.toggle("gdp-source-cursor", sourceCursorMatches(target, lineNumber));
|
|
10383
|
+
const num = document.createElement("span");
|
|
10384
|
+
num.className = "gdp-source-virtual-line-number";
|
|
10385
|
+
num.textContent = String(lineNumber);
|
|
10386
|
+
bindSourceLineNumber(num, wrap, target, lineNumber);
|
|
10387
|
+
const code2 = document.createElement("span");
|
|
10388
|
+
code2.className = "gdp-source-virtual-line-code";
|
|
10389
|
+
const line = lines.get(lineNumber);
|
|
10390
|
+
if (line == null) {
|
|
10391
|
+
code2.textContent = "";
|
|
10392
|
+
} else if (appendVirtualSourceLineCode(code2, line, search?.query() || "", search?.activeRange() || null, lineNumber)) {} else if (hljsRef && hljsRef.highlight && lang && line.length <= VIRTUAL_SOURCE_HIGHLIGHT_MAX_LINE_LENGTH && (!hljsRef.getLanguage || hljsRef.getLanguage(lang))) {
|
|
10393
|
+
try {
|
|
10394
|
+
code2.innerHTML = hljsRef.highlight(line, { language: lang, ignoreIllegals: true }).value;
|
|
10395
|
+
code2.classList.add("hljs");
|
|
10396
|
+
} catch {
|
|
10397
|
+
code2.textContent = line;
|
|
10398
|
+
}
|
|
10399
|
+
} else {
|
|
10400
|
+
code2.textContent = line;
|
|
10401
|
+
}
|
|
10402
|
+
row.append(num, code2);
|
|
10403
|
+
fragment.appendChild(row);
|
|
10404
|
+
}
|
|
10405
|
+
windowEl.appendChild(fragment);
|
|
10406
|
+
if (!complete && totalRows - end < VIRTUAL_SOURCE_PAGE_SIZE) {
|
|
10407
|
+
totalRows += VIRTUAL_SOURCE_PAGE_SIZE;
|
|
10408
|
+
updateTotals();
|
|
10409
|
+
}
|
|
10410
|
+
};
|
|
10411
|
+
const schedule = () => {
|
|
10412
|
+
if (!raf)
|
|
10413
|
+
raf = requestAnimationFrame(render);
|
|
10414
|
+
};
|
|
10415
|
+
scroller.__gdpRenderVirtualSource = render;
|
|
10416
|
+
scroller.addEventListener("scroll", schedule, { passive: true });
|
|
10417
|
+
const findPagedMatches = async (query, matchSignal) => {
|
|
10418
|
+
const matches = [];
|
|
10419
|
+
let startLine = 1;
|
|
10420
|
+
let done = false;
|
|
10421
|
+
while (!done && matches.length < 5000) {
|
|
10422
|
+
const endLine = startLine + VIRTUAL_SOURCE_PAGE_SIZE - 1;
|
|
10423
|
+
const data = await trackLoad(fetch(buildFileRangeUrl(target, startLine, endLine), { signal: matchSignal }).then((res) => {
|
|
10424
|
+
if (!res.ok)
|
|
10425
|
+
throw new Error("file range failed");
|
|
10426
|
+
return res.json();
|
|
10427
|
+
}));
|
|
10428
|
+
if (matchSignal?.aborted)
|
|
10429
|
+
return [];
|
|
10430
|
+
data.lines.forEach((lineValue, index) => {
|
|
10431
|
+
const lineNumber = data.start + index;
|
|
10432
|
+
lines.set(lineNumber, lineValue);
|
|
10433
|
+
for (const range of virtualSourceSearchRanges(lineValue, query)) {
|
|
10434
|
+
matches.push({ line: lineNumber, start: range.start, end: range.end });
|
|
10435
|
+
if (matches.length >= 5000)
|
|
10436
|
+
break;
|
|
10437
|
+
}
|
|
10438
|
+
});
|
|
10439
|
+
totalRows = data.complete ? Math.max(1, data.total) : Math.max(totalRows, data.total, endLine + VIRTUAL_SOURCE_PAGE_SIZE);
|
|
10440
|
+
complete = data.complete === true;
|
|
10441
|
+
updateTotals();
|
|
10442
|
+
if (data.complete || !data.lines.length)
|
|
10443
|
+
done = true;
|
|
10444
|
+
else
|
|
10445
|
+
startLine = data.start + data.lines.length;
|
|
10446
|
+
}
|
|
10447
|
+
renderedStart = -1;
|
|
10448
|
+
renderedEnd = -1;
|
|
10449
|
+
schedule();
|
|
10450
|
+
return matches;
|
|
10451
|
+
};
|
|
10452
|
+
search = createVirtualSourceSearch(wrap, scroller, (query) => {
|
|
10453
|
+
searchController?.abort();
|
|
10454
|
+
searchController = new AbortController;
|
|
10455
|
+
return findPagedMatches(query, searchController.signal);
|
|
10456
|
+
}, render);
|
|
10457
|
+
wrap.__gdpVirtualSourceSearch = search;
|
|
10458
|
+
let resizeObserver = null;
|
|
10459
|
+
resizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(() => {
|
|
10460
|
+
if (!scroller.isConnected) {
|
|
10461
|
+
resizeObserver?.disconnect();
|
|
10462
|
+
resizeObserver = null;
|
|
10463
|
+
return;
|
|
10464
|
+
}
|
|
10465
|
+
schedule();
|
|
10466
|
+
}) : null;
|
|
10467
|
+
resizeObserver?.observe(scroller);
|
|
10468
|
+
updateTotals();
|
|
10469
|
+
if (targetLine <= 1) {
|
|
10470
|
+
render();
|
|
10471
|
+
schedule();
|
|
10472
|
+
}
|
|
10473
|
+
return wrap;
|
|
10474
|
+
}
|
|
10475
|
+
async function renderPagedSourceText(card, target, size, signal) {
|
|
10476
|
+
const body = card.querySelector(".gdp-file-detail-body, .d2h-files-diff, .d2h-file-diff, .gdp-media, .gdp-source-viewer");
|
|
10477
|
+
const isStandalone = card.classList.contains("gdp-standalone-source");
|
|
10478
|
+
const view = document.createElement("div");
|
|
10479
|
+
view.className = "gdp-source-viewer virtual";
|
|
10480
|
+
const header = isStandalone ? null : document.createElement("div");
|
|
10481
|
+
if (header) {
|
|
10482
|
+
header.className = "gdp-source-meta";
|
|
10483
|
+
header.textContent = target.path + " @ " + target.ref;
|
|
10484
|
+
view.appendChild(header);
|
|
10485
|
+
}
|
|
10486
|
+
const lineTarget = lineTargetStart(currentSourceLineTarget(target)) || 1;
|
|
10487
|
+
const initialPage = Math.max(0, Math.floor((lineTarget - 1) / VIRTUAL_SOURCE_PAGE_SIZE));
|
|
10488
|
+
const initialStart = initialPage * VIRTUAL_SOURCE_PAGE_SIZE + 1;
|
|
10489
|
+
const initialEnd = initialStart + VIRTUAL_SOURCE_PAGE_SIZE - 1;
|
|
10490
|
+
const lang = inferLang(target.path);
|
|
10491
|
+
const hljsRef = STATE.syntaxHighlight ? await loadSyntaxHighlighter() : null;
|
|
10492
|
+
if (signal?.aborted)
|
|
10493
|
+
return false;
|
|
10494
|
+
const initial = await trackLoad(fetch(buildFileRangeUrl(target, initialStart, initialEnd), { signal }).then((res) => res.ok ? res.json() : null));
|
|
10495
|
+
if (!initial)
|
|
10496
|
+
return false;
|
|
10497
|
+
if (signal?.aborted)
|
|
10498
|
+
return false;
|
|
10499
|
+
const tabsHost = card.querySelector(".gdp-file-detail-tabs");
|
|
10500
|
+
if (tabsHost) {
|
|
10501
|
+
tabsHost.hidden = false;
|
|
10502
|
+
tabsHost.replaceChildren(createSourceTabs("code").tabs);
|
|
10503
|
+
}
|
|
10504
|
+
SOURCE_CURSOR_TOTALS.set(sourceCursorKey(target), Math.max(1, initial.total, lineTarget));
|
|
10505
|
+
resetSourceCursorForTarget(target, Math.max(1, initial.total, lineTarget));
|
|
10506
|
+
const virtualCode = renderPagedVirtualSource(target, size, initialStart, initial.lines, initial.complete === true, initial.total, hljsRef, lang, signal);
|
|
10507
|
+
view.appendChild(virtualCode);
|
|
10508
|
+
if (body)
|
|
10509
|
+
body.replaceWith(view);
|
|
10510
|
+
else
|
|
10511
|
+
card.appendChild(view);
|
|
10512
|
+
return true;
|
|
10513
|
+
}
|
|
10061
10514
|
function renderSourceMedia(card, target, mediaKind) {
|
|
10062
10515
|
const body = card.querySelector(".gdp-file-detail-body, .d2h-files-diff, .d2h-file-diff, .gdp-media, .gdp-source-viewer");
|
|
10063
10516
|
const isStandalone = card.classList.contains("gdp-standalone-source");
|
|
@@ -10259,6 +10712,19 @@
|
|
|
10259
10712
|
return;
|
|
10260
10713
|
}
|
|
10261
10714
|
if (displayKind === "text") {
|
|
10715
|
+
const meta = await loadRawFileInfo(target);
|
|
10716
|
+
if (req !== SOURCE_REQ_SEQ || !sourceTargetsEqual(sourceTargetFromRoute(), target))
|
|
10717
|
+
return;
|
|
10718
|
+
if (!isVirtualSourceDisabled() && meta.size != null && meta.size >= VIRTUAL_SOURCE_SIZE_THRESHOLD) {
|
|
10719
|
+
const rendered2 = await renderPagedSourceText(card, target, meta.size, controller.signal);
|
|
10720
|
+
if (req !== SOURCE_REQ_SEQ || !sourceTargetsEqual(sourceTargetFromRoute(), target))
|
|
10721
|
+
return;
|
|
10722
|
+
if (!rendered2)
|
|
10723
|
+
return;
|
|
10724
|
+
scrollStandaloneSourceLine(card, lineTargetStart(STATE.route.screen === "file" ? STATE.route.line : undefined));
|
|
10725
|
+
finishSourceLoad(req);
|
|
10726
|
+
return;
|
|
10727
|
+
}
|
|
10262
10728
|
const response = await trackLoad(fetch(buildRawFileUrl(target), { signal: controller.signal }));
|
|
10263
10729
|
if (req !== SOURCE_REQ_SEQ || !sourceTargetsEqual(sourceTargetFromRoute(), target))
|
|
10264
10730
|
return;
|
|
@@ -10296,6 +10762,7 @@
|
|
|
10296
10762
|
if (virtualScroller) {
|
|
10297
10763
|
const centeredOffset = virtualScroller.clientHeight / 2 - VIRTUAL_SOURCE_ROW_HEIGHT / 2;
|
|
10298
10764
|
virtualScroller.scrollTop = Math.max(0, (line - 1) * VIRTUAL_SOURCE_ROW_HEIGHT - Math.max(0, centeredOffset));
|
|
10765
|
+
virtualScroller.__gdpRenderVirtualSource?.();
|
|
10299
10766
|
return;
|
|
10300
10767
|
}
|
|
10301
10768
|
const row = card.querySelector('.gdp-source-table tr[data-line="' + String(line) + '"]');
|
|
@@ -11541,8 +12008,50 @@
|
|
|
11541
12008
|
}
|
|
11542
12009
|
return false;
|
|
11543
12010
|
}
|
|
12011
|
+
function handleVirtualSourcePagingKey(e2, targetEl) {
|
|
12012
|
+
if (e2.__gdpVirtualSourcePagingHandled)
|
|
12013
|
+
return true;
|
|
12014
|
+
if (e2.defaultPrevented || e2.isComposing || PALETTE || document.querySelector(".mkdp-lightbox"))
|
|
12015
|
+
return false;
|
|
12016
|
+
const editable = isEditableKeyTarget(targetEl);
|
|
12017
|
+
const inVirtualSearch = !!targetEl?.closest(".gdp-source-virtual-search");
|
|
12018
|
+
if (editable && !inVirtualSearch)
|
|
12019
|
+
return false;
|
|
12020
|
+
const key = e2.key.toLowerCase();
|
|
12021
|
+
if (e2.altKey || e2.metaKey)
|
|
12022
|
+
return false;
|
|
12023
|
+
const isPlainPageKey = (key === "pagedown" || key === "pageup") && !e2.ctrlKey && !e2.shiftKey;
|
|
12024
|
+
const isCtrlArrowKey = (key === "arrowdown" || key === "arrowup") && e2.ctrlKey && !e2.shiftKey;
|
|
12025
|
+
if (!isPlainPageKey && !isCtrlArrowKey)
|
|
12026
|
+
return false;
|
|
12027
|
+
const scroller = findMainScrollTarget();
|
|
12028
|
+
if (!scroller || !scroller.matches("#content .gdp-source-virtual-scroller"))
|
|
12029
|
+
return false;
|
|
12030
|
+
const pageDown = key === "pagedown" || key === "arrowdown";
|
|
12031
|
+
const pageUp = key === "pageup" || key === "arrowup";
|
|
12032
|
+
if (!pageDown && !pageUp)
|
|
12033
|
+
return false;
|
|
12034
|
+
e2.__gdpVirtualSourcePagingHandled = true;
|
|
12035
|
+
e2.preventDefault();
|
|
12036
|
+
e2.stopPropagation();
|
|
12037
|
+
scrollMainPanel(pageDown ? 1 : -1, e2.repeat, "page");
|
|
12038
|
+
focusMainSurface();
|
|
12039
|
+
return true;
|
|
12040
|
+
}
|
|
12041
|
+
function handleVirtualSourcePagingKeydown(e2) {
|
|
12042
|
+
handleVirtualSourcePagingKey(e2, e2.target);
|
|
12043
|
+
}
|
|
12044
|
+
document.addEventListener("keydown", handleVirtualSourcePagingKeydown, { capture: true });
|
|
11544
12045
|
document.addEventListener("keydown", (e2) => {
|
|
12046
|
+
if (e2.__gdpVirtualSourcePagingHandled)
|
|
12047
|
+
return;
|
|
11545
12048
|
const targetEl = e2.target;
|
|
12049
|
+
if ((e2.ctrlKey || e2.metaKey) && e2.key.toLowerCase() === "f" && !isEditableKeyTarget(targetEl)) {
|
|
12050
|
+
if (openVirtualSourceSearchFromKeyboard(targetEl)) {
|
|
12051
|
+
e2.preventDefault();
|
|
12052
|
+
return;
|
|
12053
|
+
}
|
|
12054
|
+
}
|
|
11546
12055
|
const scope = keymapScope(targetEl);
|
|
11547
12056
|
const action = resolveKeymapAction(e2, {
|
|
11548
12057
|
scope,
|
package/web/style.css
CHANGED
|
@@ -2350,12 +2350,45 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
|
|
|
2350
2350
|
text-overflow: ellipsis;
|
|
2351
2351
|
white-space: nowrap;
|
|
2352
2352
|
}
|
|
2353
|
-
.gdp-source-virtual-
|
|
2353
|
+
.gdp-source-virtual-search {
|
|
2354
2354
|
display: inline-flex;
|
|
2355
2355
|
align-items: center;
|
|
2356
2356
|
gap: 6px;
|
|
2357
|
+
min-width: 0;
|
|
2357
2358
|
margin-left: auto;
|
|
2358
2359
|
}
|
|
2360
|
+
.gdp-source-virtual-search[hidden] {
|
|
2361
|
+
display: none;
|
|
2362
|
+
}
|
|
2363
|
+
.gdp-source-virtual-search input {
|
|
2364
|
+
width: min(220px, 28vw);
|
|
2365
|
+
min-width: 120px;
|
|
2366
|
+
height: 28px;
|
|
2367
|
+
padding: 0 8px;
|
|
2368
|
+
border: 1px solid var(--border);
|
|
2369
|
+
border-radius: 6px;
|
|
2370
|
+
background: var(--bg);
|
|
2371
|
+
color: var(--fg);
|
|
2372
|
+
}
|
|
2373
|
+
.gdp-source-virtual-search button {
|
|
2374
|
+
height: 28px;
|
|
2375
|
+
padding: 0 8px;
|
|
2376
|
+
border: 1px solid var(--border);
|
|
2377
|
+
border-radius: 6px;
|
|
2378
|
+
background: var(--bg-subtle);
|
|
2379
|
+
color: var(--fg);
|
|
2380
|
+
cursor: pointer;
|
|
2381
|
+
}
|
|
2382
|
+
.gdp-source-virtual-search-count {
|
|
2383
|
+
min-width: 62px;
|
|
2384
|
+
color: var(--fg-muted);
|
|
2385
|
+
font-size: 12px;
|
|
2386
|
+
}
|
|
2387
|
+
.gdp-source-virtual-actions {
|
|
2388
|
+
display: inline-flex;
|
|
2389
|
+
align-items: center;
|
|
2390
|
+
gap: 6px;
|
|
2391
|
+
}
|
|
2359
2392
|
.gdp-source-virtual-action {
|
|
2360
2393
|
display: inline-flex;
|
|
2361
2394
|
align-items: center;
|
|
@@ -2385,8 +2418,7 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
|
|
|
2385
2418
|
font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
2386
2419
|
font-size: var(--code-font-size);
|
|
2387
2420
|
line-height: 20px;
|
|
2388
|
-
cursor:
|
|
2389
|
-
user-select: none;
|
|
2421
|
+
cursor: text;
|
|
2390
2422
|
}
|
|
2391
2423
|
.gdp-source-virtual-spacer {
|
|
2392
2424
|
position: relative;
|
|
@@ -2426,6 +2458,15 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
|
|
|
2426
2458
|
.gdp-source-virtual-line-code.hljs {
|
|
2427
2459
|
background: var(--bg);
|
|
2428
2460
|
}
|
|
2461
|
+
.gdp-source-virtual-search-hit {
|
|
2462
|
+
background: #fff8c5;
|
|
2463
|
+
color: inherit;
|
|
2464
|
+
border-radius: 2px;
|
|
2465
|
+
}
|
|
2466
|
+
.gdp-source-virtual-search-hit.active {
|
|
2467
|
+
background: var(--accent);
|
|
2468
|
+
color: var(--bg);
|
|
2469
|
+
}
|
|
2429
2470
|
.gdp-standalone-source.gdp-file-shell {
|
|
2430
2471
|
border: 0;
|
|
2431
2472
|
border-radius: 0;
|
package/web-src/server/git.ts
CHANGED
|
@@ -88,11 +88,34 @@ export function showBytes(ref: string, path: string, cwd: string): { code: numbe
|
|
|
88
88
|
return runBytes(['git', 'show', `${ref}:${path}`], cwd);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
export function catFileBlobStream(oid: string, cwd: string): { stream: ReadableStream<Uint8Array>; exited: Promise<number>; kill(signal?: string): void } {
|
|
92
|
+
const proc = Bun.spawn(['git', 'cat-file', 'blob', oid], { cwd, stdout: 'pipe', stderr: 'ignore', stdin: 'ignore' });
|
|
93
|
+
return {
|
|
94
|
+
stream: proc.stdout as ReadableStream<Uint8Array>,
|
|
95
|
+
exited: proc.exited,
|
|
96
|
+
kill: (signal?: string) => proc.kill(signal),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
91
100
|
export function objectSize(ref: string, path: string, cwd: string): { code: number; size: number; stderr: string } {
|
|
92
101
|
const res = run(['git', 'cat-file', '-s', `${ref}:${path}`], cwd);
|
|
93
102
|
return { code: res.code, size: Number(res.stdout.trim()) || 0, stderr: res.stderr };
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
export function objectByteSize(oid: string, cwd: string): { code: number; size: number; stderr: string } {
|
|
106
|
+
const res = run(['git', 'cat-file', '-s', oid], cwd);
|
|
107
|
+
return { code: res.code, size: Number(res.stdout.trim()) || 0, stderr: res.stderr };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function objectId(ref: string, path: string, cwd: string): { code: number; oid: string; stderr: string } {
|
|
111
|
+
const res = run(['git', 'rev-parse', '--verify', `${ref}:${path}`], cwd);
|
|
112
|
+
const oid = res.stdout.trim();
|
|
113
|
+
if (res.code !== 0 || !oid) return { code: res.code || 1, oid: '', stderr: res.stderr };
|
|
114
|
+
const type = run(['git', 'cat-file', '-t', oid], cwd);
|
|
115
|
+
if (type.code !== 0 || type.stdout.trim() !== 'blob') return { code: 1, oid: '', stderr: type.stderr };
|
|
116
|
+
return { code: 0, oid, stderr: '' };
|
|
117
|
+
}
|
|
118
|
+
|
|
96
119
|
export function verifyTreeRef(ref: string, cwd: string): boolean {
|
|
97
120
|
if (!ref || ref === 'worktree') return false;
|
|
98
121
|
if (ref.startsWith('-')) return false;
|
|
@@ -7,7 +7,18 @@ import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, FileSearc
|
|
|
7
7
|
import { cacheFresh, fileDiffCacheKey, setTimedCacheEntry, type TimedCacheEntry } from './cache';
|
|
8
8
|
import { startDevAssetReload } from './dev-assets';
|
|
9
9
|
import * as git from './git';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
buildLineOffsetIndexFromStream,
|
|
12
|
+
collectByteRangeFromStream,
|
|
13
|
+
collectBytesWithLineOffsetIndexFromStream,
|
|
14
|
+
collectLineRangeFromIndexedText,
|
|
15
|
+
collectLineRangeFromStream,
|
|
16
|
+
isSameWorktreeRange,
|
|
17
|
+
lineByteRangeForIndex,
|
|
18
|
+
parseHttpByteRange,
|
|
19
|
+
type LineOffsetIndex,
|
|
20
|
+
type LineRangeResult,
|
|
21
|
+
} from './range';
|
|
11
22
|
import {
|
|
12
23
|
GREP_MAX_FILE_BYTES,
|
|
13
24
|
buildFileSearchList,
|
|
@@ -29,6 +40,9 @@ const WATCHED_ASSET_FILES = ['index.html', 'style.css', 'app.js'];
|
|
|
29
40
|
const SIZE_SMALL = 2000;
|
|
30
41
|
const SIZE_MEDIUM = 8000;
|
|
31
42
|
const SIZE_LARGE = 20000;
|
|
43
|
+
const LINE_INDEX_MIN_START = 10000;
|
|
44
|
+
const LINE_INDEX_MAX_FILE_BYTES = 256 * 1024 * 1024;
|
|
45
|
+
const BLOB_LINE_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
32
46
|
const MAX_UPLOAD_FILE_BYTES = 10 * 1024 * 1024;
|
|
33
47
|
const MAX_UPLOAD_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
34
48
|
const MAX_UPLOAD_BODY_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
|
|
@@ -54,6 +68,10 @@ const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
|
54
68
|
const fileCache = new Map<string, TimedCacheEntry<{ diffText: string }>>();
|
|
55
69
|
const metaCache = new Map<string, TimedCacheEntry<{ body: string; sig: string }>>();
|
|
56
70
|
const fileListCache = new Map<string, { generation: number; body: FileSearchListResponse }>();
|
|
71
|
+
const lineIndexCache = new Map<string, { signature: string; index: LineOffsetIndex }>();
|
|
72
|
+
const blobLineIndexCache = new Map<string, LineOffsetIndex>();
|
|
73
|
+
const blobBytesCache = new Map<string, Uint8Array>();
|
|
74
|
+
let blobLineCacheBytes = 0;
|
|
57
75
|
|
|
58
76
|
function parseCli() {
|
|
59
77
|
const rest: string[] = [];
|
|
@@ -647,7 +665,138 @@ function handleFileDiff(url: URL) {
|
|
|
647
665
|
return json(body);
|
|
648
666
|
}
|
|
649
667
|
|
|
650
|
-
function
|
|
668
|
+
function worktreeLineIndexSignature(full: string): string | null {
|
|
669
|
+
try {
|
|
670
|
+
const stat = statSync(full) as unknown as { size: number; mtimeMs: number; ctimeMs: number; ino?: number };
|
|
671
|
+
return `size:${stat.size}|mtime:${stat.mtimeMs}|ctime:${stat.ctimeMs}|ino:${stat.ino || 0}`;
|
|
672
|
+
} catch {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function getWorktreeLineIndex(full: string): Promise<LineOffsetIndex | null> {
|
|
678
|
+
const signature = worktreeLineIndexSignature(full);
|
|
679
|
+
if (!signature) return null;
|
|
680
|
+
const cached = lineIndexCache.get(full);
|
|
681
|
+
if (cached?.signature === signature) {
|
|
682
|
+
lineIndexCache.delete(full);
|
|
683
|
+
lineIndexCache.set(full, cached);
|
|
684
|
+
return cached.index;
|
|
685
|
+
}
|
|
686
|
+
const stat = statSync(full) as unknown as { size: number };
|
|
687
|
+
if (stat.size > LINE_INDEX_MAX_FILE_BYTES) return null;
|
|
688
|
+
const index = await buildLineOffsetIndexFromStream(Bun.file(full).stream(), stat.size);
|
|
689
|
+
lineIndexCache.delete(full);
|
|
690
|
+
lineIndexCache.set(full, { signature, index });
|
|
691
|
+
while (lineIndexCache.size > 32) {
|
|
692
|
+
const oldest = lineIndexCache.keys().next().value;
|
|
693
|
+
if (oldest === undefined) break;
|
|
694
|
+
lineIndexCache.delete(oldest);
|
|
695
|
+
}
|
|
696
|
+
return index;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function cachedBlobLineRange(cacheKey: string, start: number, end: number): LineRangeResult | null {
|
|
700
|
+
const bytes = blobBytesCache.get(cacheKey);
|
|
701
|
+
const index = blobLineIndexCache.get(cacheKey);
|
|
702
|
+
if (!bytes || !index) return null;
|
|
703
|
+
blobBytesCache.delete(cacheKey);
|
|
704
|
+
blobBytesCache.set(cacheKey, bytes);
|
|
705
|
+
blobLineIndexCache.delete(cacheKey);
|
|
706
|
+
blobLineIndexCache.set(cacheKey, index);
|
|
707
|
+
const range = lineByteRangeForIndex(index, start, end);
|
|
708
|
+
const textValue = range
|
|
709
|
+
? new TextDecoder().decode(bytes.subarray(range.start, range.endExclusive))
|
|
710
|
+
: '';
|
|
711
|
+
return collectLineRangeFromIndexedText(textValue, index, start, end);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function setBlobLineCache(cacheKey: string, bytes: Uint8Array, index: LineOffsetIndex): void {
|
|
715
|
+
setBlobLineIndexCache(cacheKey, index);
|
|
716
|
+
const existing = blobBytesCache.get(cacheKey);
|
|
717
|
+
if (existing) blobLineCacheBytes -= existing.byteLength;
|
|
718
|
+
blobBytesCache.delete(cacheKey);
|
|
719
|
+
if (bytes.byteLength > BLOB_LINE_CACHE_MAX_BYTES) return;
|
|
720
|
+
blobBytesCache.set(cacheKey, bytes);
|
|
721
|
+
blobLineCacheBytes += bytes.byteLength;
|
|
722
|
+
while (blobBytesCache.size > 16 || blobLineCacheBytes > BLOB_LINE_CACHE_MAX_BYTES) {
|
|
723
|
+
const oldest = blobBytesCache.keys().next().value;
|
|
724
|
+
if (oldest === undefined) break;
|
|
725
|
+
const evicted = blobBytesCache.get(oldest);
|
|
726
|
+
if (evicted) blobLineCacheBytes -= evicted.byteLength;
|
|
727
|
+
blobBytesCache.delete(oldest);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function setBlobLineIndexCache(cacheKey: string, index: LineOffsetIndex): void {
|
|
732
|
+
blobLineIndexCache.delete(cacheKey);
|
|
733
|
+
blobLineIndexCache.set(cacheKey, index);
|
|
734
|
+
while (blobLineIndexCache.size > 128) {
|
|
735
|
+
const oldest = blobLineIndexCache.keys().next().value;
|
|
736
|
+
if (oldest === undefined) break;
|
|
737
|
+
blobLineIndexCache.delete(oldest);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function collectGitBlobLineRangeWithIndex(cacheKey: string, oid: string, index: LineOffsetIndex, start: number, end: number): Promise<LineRangeResult | null> {
|
|
742
|
+
blobLineIndexCache.delete(cacheKey);
|
|
743
|
+
blobLineIndexCache.set(cacheKey, index);
|
|
744
|
+
const range = lineByteRangeForIndex(index, start, end);
|
|
745
|
+
if (!range) return collectLineRangeFromIndexedText('', index, start, end);
|
|
746
|
+
const shown = git.catFileBlobStream(oid, cwd);
|
|
747
|
+
const bytes = await collectByteRangeFromStream(shown.stream, range.start, range.endExclusive);
|
|
748
|
+
await shown.exited;
|
|
749
|
+
if (bytes.byteLength !== range.endExclusive - range.start) return null;
|
|
750
|
+
const textValue = new TextDecoder().decode(bytes);
|
|
751
|
+
return collectLineRangeFromIndexedText(textValue, index, start, end);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function readGitBlobBytesWithIndex(oid: string, sizeHint: number): Promise<{ bytes: Uint8Array; index: LineOffsetIndex } | null> {
|
|
755
|
+
const shown = git.catFileBlobStream(oid, cwd);
|
|
756
|
+
const result = await collectBytesWithLineOffsetIndexFromStream(shown.stream, sizeHint);
|
|
757
|
+
const code = await shown.exited;
|
|
758
|
+
if (code !== 0) return null;
|
|
759
|
+
return result;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function collectGitBlobLineRangeFromStream(oid: string, start: number, end: number): Promise<LineRangeResult | null> {
|
|
763
|
+
const shown = git.catFileBlobStream(oid, cwd);
|
|
764
|
+
const result = await collectLineRangeFromStream(shown.stream, start, end);
|
|
765
|
+
const code = await shown.exited;
|
|
766
|
+
if (code !== 0 && result.complete) return null;
|
|
767
|
+
return result;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function collectIndexedGitBlobLineRange(path: string, oid: string, size: number, start: number, end: number): Promise<LineRangeResult | null> {
|
|
771
|
+
const cacheKey = `${oid}\0${path}`;
|
|
772
|
+
const cached = cachedBlobLineRange(cacheKey, start, end);
|
|
773
|
+
if (cached) return cached;
|
|
774
|
+
const cachedIndex = blobLineIndexCache.get(cacheKey);
|
|
775
|
+
if (cachedIndex) return collectGitBlobLineRangeWithIndex(cacheKey, oid, cachedIndex, start, end);
|
|
776
|
+
if (start < LINE_INDEX_MIN_START) {
|
|
777
|
+
return collectGitBlobLineRangeFromStream(oid, start, end);
|
|
778
|
+
}
|
|
779
|
+
if (size > LINE_INDEX_MAX_FILE_BYTES) return collectGitBlobLineRangeFromStream(oid, start, end);
|
|
780
|
+
const indexedBlob = await readGitBlobBytesWithIndex(oid, size);
|
|
781
|
+
if (!indexedBlob) return null;
|
|
782
|
+
setBlobLineCache(cacheKey, indexedBlob.bytes, indexedBlob.index);
|
|
783
|
+
return cachedBlobLineRange(cacheKey, start, end) || collectGitBlobLineRangeWithIndex(cacheKey, oid, indexedBlob.index, start, end);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function collectIndexedWorktreeLineRange(full: string, start: number, end: number): Promise<LineRangeResult> {
|
|
787
|
+
if (start < LINE_INDEX_MIN_START && !lineIndexCache.has(full)) {
|
|
788
|
+
return collectLineRangeFromStream(Bun.file(full).stream(), start, end);
|
|
789
|
+
}
|
|
790
|
+
const index = await getWorktreeLineIndex(full);
|
|
791
|
+
if (!index) return collectLineRangeFromStream(Bun.file(full).stream(), start, end);
|
|
792
|
+
const range = lineByteRangeForIndex(index, start, end);
|
|
793
|
+
const textValue = range
|
|
794
|
+
? await Bun.file(full).slice(range.start, range.endExclusive).text()
|
|
795
|
+
: '';
|
|
796
|
+
return collectLineRangeFromIndexedText(textValue, index, start, end);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function handleFileRange(url: URL) {
|
|
651
800
|
const path = url.searchParams.get('path') || '';
|
|
652
801
|
if (!safePath(path)) return text('invalid path', 400);
|
|
653
802
|
let start = Number(url.searchParams.get('start') || '1') || 1;
|
|
@@ -655,22 +804,23 @@ function handleFileRange(url: URL) {
|
|
|
655
804
|
if (start < 1) start = 1;
|
|
656
805
|
if (end < start) end = start;
|
|
657
806
|
const ref = url.searchParams.get('ref') || 'worktree';
|
|
658
|
-
let content = '';
|
|
659
807
|
if (ref === 'worktree' || ref === '') {
|
|
660
808
|
const full = safeWorktreePath(path);
|
|
661
809
|
if (!full) return text('no file', 404);
|
|
662
|
-
|
|
810
|
+
const result = await collectIndexedWorktreeLineRange(full, start, end);
|
|
811
|
+
const body: FileRangeResponse = { path, ref, start, end, lines: result.lines, total: result.total, complete: result.complete, generation };
|
|
812
|
+
return json(body);
|
|
663
813
|
} else {
|
|
664
814
|
if (!git.verifyTreeRef(ref, cwd)) return text('invalid ref', 400);
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
667
|
-
|
|
815
|
+
const oid = git.objectId(ref, path, cwd);
|
|
816
|
+
if (oid.code !== 0 || !oid.oid) return text('not in ref', 404);
|
|
817
|
+
const size = git.objectByteSize(oid.oid, cwd);
|
|
818
|
+
if (size.code !== 0) return text('cannot read ref', 500);
|
|
819
|
+
const result = await collectIndexedGitBlobLineRange(path, oid.oid, size.size, start, end);
|
|
820
|
+
if (!result) return text('cannot read ref', 500);
|
|
821
|
+
const body: FileRangeResponse = { path, ref, start, end, lines: result.lines, total: result.total, complete: result.complete, generation };
|
|
822
|
+
return json(body);
|
|
668
823
|
}
|
|
669
|
-
const lines: string[] = [];
|
|
670
|
-
const all = `${content}\n`.split('\n');
|
|
671
|
-
for (let i = start; i <= end && i <= all.length; i++) lines.push(all[i - 1]);
|
|
672
|
-
const body: FileRangeResponse = { path, ref, start, end, lines, total: Math.min(all.length, end + 1), generation };
|
|
673
|
-
return json(body);
|
|
674
824
|
}
|
|
675
825
|
|
|
676
826
|
function handleRawFile(req: Request, url: URL) {
|
|
@@ -692,10 +842,29 @@ function handleRawFile(req: Request, url: URL) {
|
|
|
692
842
|
if (!full) return text('not found', 404);
|
|
693
843
|
const size = rawFileSize(path, ref);
|
|
694
844
|
if (size == null) return text('not found', 404);
|
|
845
|
+
const rangeResult = req.headers.get('range') ? parseHttpByteRange(req.headers.get('range'), size) : null;
|
|
846
|
+
if (rangeResult?.kind === 'unsatisfiable') {
|
|
847
|
+
return new Response(null, {
|
|
848
|
+
status: 416,
|
|
849
|
+
headers: { ...rawFileHeaders(path, size), 'Content-Range': `bytes */${size}`, 'Content-Length': '0' },
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
if (rangeResult?.kind === 'range') {
|
|
853
|
+
const range = rangeResult.range;
|
|
854
|
+
if (req.method === 'HEAD') {
|
|
855
|
+
return new Response(null, {
|
|
856
|
+
status: 206,
|
|
857
|
+
headers: rawFileHeaders(path, size, range),
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
const file = Bun.file(full).slice(range.start, range.end + 1);
|
|
861
|
+
return new Response(file, {
|
|
862
|
+
status: 206,
|
|
863
|
+
headers: rawFileHeaders(path, size, range),
|
|
864
|
+
});
|
|
865
|
+
}
|
|
695
866
|
if (req.method === 'HEAD') return new Response(null, { headers: rawFileHeaders(path, size) });
|
|
696
|
-
|
|
697
|
-
body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
698
|
-
return new Response(body, { headers: rawFileHeaders(path, size) });
|
|
867
|
+
return new Response(Bun.file(full).stream(), { headers: rawFileHeaders(path, size) });
|
|
699
868
|
}
|
|
700
869
|
}
|
|
701
870
|
|
|
@@ -714,7 +883,7 @@ function rawFileSize(path: string, ref: string): number | null {
|
|
|
714
883
|
}
|
|
715
884
|
}
|
|
716
885
|
|
|
717
|
-
function rawFileHeaders(path: string, size: number | null = null): HeadersInit {
|
|
886
|
+
function rawFileHeaders(path: string, size: number | null = null, range?: { start: number; end: number }): HeadersInit {
|
|
718
887
|
const mime: Record<string, string> = {
|
|
719
888
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
720
889
|
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
@@ -727,8 +896,14 @@ function rawFileHeaders(path: string, size: number | null = null): HeadersInit {
|
|
|
727
896
|
'Cache-Control': 'no-store',
|
|
728
897
|
'X-Content-Type-Options': 'nosniff',
|
|
729
898
|
'Content-Security-Policy': 'sandbox',
|
|
899
|
+
'Accept-Ranges': 'bytes',
|
|
730
900
|
};
|
|
731
|
-
if (size != null)
|
|
901
|
+
if (range && size != null) {
|
|
902
|
+
headers['Content-Length'] = String(range.end - range.start + 1);
|
|
903
|
+
headers['Content-Range'] = `bytes ${range.start}-${range.end}/${size}`;
|
|
904
|
+
} else if (size != null) {
|
|
905
|
+
headers['Content-Length'] = String(size);
|
|
906
|
+
}
|
|
732
907
|
return headers;
|
|
733
908
|
}
|
|
734
909
|
|
package/web-src/server/range.ts
CHANGED
|
@@ -3,6 +3,234 @@ export type DiffRange = {
|
|
|
3
3
|
to?: string;
|
|
4
4
|
};
|
|
5
5
|
|
|
6
|
+
export type ByteRange = {
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ByteRangeParseResult =
|
|
12
|
+
| { kind: 'range'; range: ByteRange }
|
|
13
|
+
| { kind: 'invalid' }
|
|
14
|
+
| { kind: 'unsatisfiable' };
|
|
15
|
+
|
|
16
|
+
export type LineRangeResult = {
|
|
17
|
+
lines: string[];
|
|
18
|
+
total: number;
|
|
19
|
+
complete: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type LineOffsetIndex = {
|
|
23
|
+
size: number;
|
|
24
|
+
total: number;
|
|
25
|
+
newlines: Uint32Array | Float64Array;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type IndexedLineByteRange = {
|
|
29
|
+
start: number;
|
|
30
|
+
endExclusive: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type BytesWithLineOffsetIndex = {
|
|
34
|
+
bytes: Uint8Array;
|
|
35
|
+
index: LineOffsetIndex;
|
|
36
|
+
};
|
|
37
|
+
|
|
6
38
|
export function isSameWorktreeRange(range: DiffRange): boolean {
|
|
7
39
|
return range.from === 'worktree' && range.to === 'worktree';
|
|
8
40
|
}
|
|
41
|
+
|
|
42
|
+
export function parseHttpByteRange(header: string | null, size: number): ByteRangeParseResult {
|
|
43
|
+
if (!header) return { kind: 'invalid' };
|
|
44
|
+
if (size < 1) return { kind: 'unsatisfiable' };
|
|
45
|
+
const match = header.match(/^bytes=(\d*)-(\d*)$/);
|
|
46
|
+
if (!match) return { kind: 'invalid' };
|
|
47
|
+
const [, rawStart, rawEnd] = match;
|
|
48
|
+
if (!rawStart && !rawEnd) return { kind: 'invalid' };
|
|
49
|
+
let start: number;
|
|
50
|
+
let end: number;
|
|
51
|
+
if (!rawStart) {
|
|
52
|
+
const suffixLength = Number(rawEnd);
|
|
53
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength < 1) return { kind: 'unsatisfiable' };
|
|
54
|
+
start = Math.max(0, size - suffixLength);
|
|
55
|
+
end = size - 1;
|
|
56
|
+
} else {
|
|
57
|
+
start = Number(rawStart);
|
|
58
|
+
end = rawEnd ? Number(rawEnd) : size - 1;
|
|
59
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return { kind: 'invalid' };
|
|
60
|
+
if (end >= size) end = size - 1;
|
|
61
|
+
}
|
|
62
|
+
if (start < 0 || end < start || start >= size) return { kind: 'unsatisfiable' };
|
|
63
|
+
return { kind: 'range', range: { start, end } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function collectLineRangeFromStream(stream: ReadableStream<Uint8Array>, start: number, end: number): Promise<LineRangeResult> {
|
|
67
|
+
const reader = stream.getReader();
|
|
68
|
+
const decoder = new TextDecoder();
|
|
69
|
+
const lines: string[] = [];
|
|
70
|
+
let lineNo = 1;
|
|
71
|
+
let pending = '';
|
|
72
|
+
let hasMore = false;
|
|
73
|
+
|
|
74
|
+
const pushLine = (line: string) => {
|
|
75
|
+
if (line.endsWith('\r')) line = line.slice(0, -1);
|
|
76
|
+
if (lineNo >= start && lineNo <= end) lines.push(line);
|
|
77
|
+
else if (lineNo > end) hasMore = true;
|
|
78
|
+
lineNo++;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
while (!hasMore) {
|
|
82
|
+
const chunk = await reader.read();
|
|
83
|
+
if (chunk.done) break;
|
|
84
|
+
pending += decoder.decode(chunk.value, { stream: true });
|
|
85
|
+
let newline = pending.indexOf('\n');
|
|
86
|
+
while (newline !== -1) {
|
|
87
|
+
pushLine(pending.slice(0, newline));
|
|
88
|
+
pending = pending.slice(newline + 1);
|
|
89
|
+
if (hasMore) break;
|
|
90
|
+
newline = pending.indexOf('\n');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (hasMore) {
|
|
94
|
+
try { await reader.cancel(); } catch { /* best effort */ }
|
|
95
|
+
return { lines, total: lineNo - 1, complete: false };
|
|
96
|
+
}
|
|
97
|
+
pending += decoder.decode();
|
|
98
|
+
if (pending.length > 0) pushLine(pending);
|
|
99
|
+
if (hasMore) return { lines, total: lineNo - 1, complete: false };
|
|
100
|
+
return { lines, total: Math.max(0, lineNo - 1), complete: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildLineOffsetIndex(bytes: Uint8Array): LineOffsetIndex {
|
|
104
|
+
const builder = createLineOffsetIndexBuilder(bytes.length);
|
|
105
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
106
|
+
if (bytes[index] === 10) builder.push(index);
|
|
107
|
+
}
|
|
108
|
+
const lastByte = bytes.length > 0 ? bytes[bytes.length - 1] : -1;
|
|
109
|
+
return builder.finish(bytes.length, bytes.length > 0 && lastByte !== 10);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function buildLineOffsetIndexFromStream(stream: ReadableStream<Uint8Array>, size: number): Promise<LineOffsetIndex> {
|
|
113
|
+
const reader = stream.getReader();
|
|
114
|
+
const builder = createLineOffsetIndexBuilder(size);
|
|
115
|
+
let offset = 0;
|
|
116
|
+
let lastByte = -1;
|
|
117
|
+
while (true) {
|
|
118
|
+
const chunk = await reader.read();
|
|
119
|
+
if (chunk.done) break;
|
|
120
|
+
const bytes = chunk.value;
|
|
121
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
122
|
+
const byte = bytes[index];
|
|
123
|
+
if (byte === 10) builder.push(offset + index);
|
|
124
|
+
lastByte = byte;
|
|
125
|
+
}
|
|
126
|
+
offset += bytes.length;
|
|
127
|
+
}
|
|
128
|
+
return builder.finish(offset, offset > 0 && lastByte !== 10);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function collectByteRangeFromStream(stream: ReadableStream<Uint8Array>, start: number, endExclusive: number): Promise<Uint8Array> {
|
|
132
|
+
const reader = stream.getReader();
|
|
133
|
+
const chunks: Uint8Array[] = [];
|
|
134
|
+
let offset = 0;
|
|
135
|
+
let total = 0;
|
|
136
|
+
while (offset < endExclusive) {
|
|
137
|
+
const chunk = await reader.read();
|
|
138
|
+
if (chunk.done) break;
|
|
139
|
+
const chunkStart = offset;
|
|
140
|
+
const chunkEnd = offset + chunk.value.byteLength;
|
|
141
|
+
if (chunkEnd > start && chunkStart < endExclusive) {
|
|
142
|
+
const sliceStart = Math.max(0, start - chunkStart);
|
|
143
|
+
const sliceEnd = Math.min(chunk.value.byteLength, endExclusive - chunkStart);
|
|
144
|
+
const slice = chunk.value.subarray(sliceStart, sliceEnd);
|
|
145
|
+
chunks.push(slice);
|
|
146
|
+
total += slice.byteLength;
|
|
147
|
+
}
|
|
148
|
+
offset = chunkEnd;
|
|
149
|
+
}
|
|
150
|
+
try { await reader.cancel(); } catch { /* best effort */ }
|
|
151
|
+
if (chunks.length === 1) return chunks[0];
|
|
152
|
+
const bytes = new Uint8Array(total);
|
|
153
|
+
let writeOffset = 0;
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
bytes.set(chunk, writeOffset);
|
|
156
|
+
writeOffset += chunk.byteLength;
|
|
157
|
+
}
|
|
158
|
+
return bytes;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function collectBytesWithLineOffsetIndexFromStream(stream: ReadableStream<Uint8Array>, sizeHint: number): Promise<BytesWithLineOffsetIndex> {
|
|
162
|
+
const reader = stream.getReader();
|
|
163
|
+
const builder = createLineOffsetIndexBuilder(sizeHint);
|
|
164
|
+
const chunks: Uint8Array[] = [];
|
|
165
|
+
let offset = 0;
|
|
166
|
+
let lastByte = -1;
|
|
167
|
+
while (true) {
|
|
168
|
+
const chunk = await reader.read();
|
|
169
|
+
if (chunk.done) break;
|
|
170
|
+
const bytes = chunk.value;
|
|
171
|
+
chunks.push(bytes);
|
|
172
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
173
|
+
const byte = bytes[index];
|
|
174
|
+
if (byte === 10) builder.push(offset + index);
|
|
175
|
+
lastByte = byte;
|
|
176
|
+
}
|
|
177
|
+
offset += bytes.length;
|
|
178
|
+
}
|
|
179
|
+
const collected = new Uint8Array(offset);
|
|
180
|
+
let writeOffset = 0;
|
|
181
|
+
for (const chunk of chunks) {
|
|
182
|
+
collected.set(chunk, writeOffset);
|
|
183
|
+
writeOffset += chunk.byteLength;
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
bytes: collected,
|
|
187
|
+
index: builder.finish(offset, offset > 0 && lastByte !== 10),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function createLineOffsetIndexBuilder(size: number) {
|
|
192
|
+
const useFloat64 = size > 0xffffffff;
|
|
193
|
+
let capacity = 1024;
|
|
194
|
+
let length = 0;
|
|
195
|
+
let offsets: Uint32Array | Float64Array = useFloat64 ? new Float64Array(capacity) : new Uint32Array(capacity);
|
|
196
|
+
const grow = () => {
|
|
197
|
+
capacity *= 2;
|
|
198
|
+
const next = useFloat64 ? new Float64Array(capacity) : new Uint32Array(capacity);
|
|
199
|
+
next.set(offsets);
|
|
200
|
+
offsets = next;
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
push(offset: number) {
|
|
204
|
+
if (length >= capacity) grow();
|
|
205
|
+
offsets[length++] = offset;
|
|
206
|
+
},
|
|
207
|
+
finish(totalSize: number, hasTrailingLine: boolean): LineOffsetIndex {
|
|
208
|
+
return {
|
|
209
|
+
size: totalSize,
|
|
210
|
+
total: length + (hasTrailingLine ? 1 : 0),
|
|
211
|
+
newlines: offsets.slice(0, length) as Uint32Array | Float64Array,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function lineByteRangeForIndex(index: LineOffsetIndex, start: number, end: number): IndexedLineByteRange | null {
|
|
218
|
+
const normalizedStart = Math.max(1, Math.floor(start));
|
|
219
|
+
const normalizedEnd = Math.max(normalizedStart, Math.floor(end));
|
|
220
|
+
if (normalizedStart > index.total) return null;
|
|
221
|
+
const lastLine = Math.min(normalizedEnd, index.total);
|
|
222
|
+
const byteStart = normalizedStart <= 1 ? 0 : index.newlines[normalizedStart - 2] + 1;
|
|
223
|
+
const byteEnd = lastLine <= index.newlines.length ? index.newlines[lastLine - 1] : index.size;
|
|
224
|
+
return { start: byteStart, endExclusive: byteEnd };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function collectLineRangeFromIndexedText(text: string, index: LineOffsetIndex, start: number, end: number): LineRangeResult {
|
|
228
|
+
const normalizedStart = Math.max(1, Math.floor(start));
|
|
229
|
+
const normalizedEnd = Math.max(normalizedStart, Math.floor(end));
|
|
230
|
+
if (normalizedStart > index.total) return { lines: [], total: index.total, complete: true };
|
|
231
|
+
const expectedLines = Math.min(normalizedEnd, index.total) - normalizedStart + 1;
|
|
232
|
+
const lines = text.length
|
|
233
|
+
? text.split('\n').map(line => line.endsWith('\r') ? line.slice(0, -1) : line)
|
|
234
|
+
: Array.from({ length: expectedLines }, () => '');
|
|
235
|
+
return { lines, total: index.total, complete: end >= index.total };
|
|
236
|
+
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
interface BunFile extends globalThis.Blob {
|
|
2
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
3
|
+
slice(start?: number, end?: number, contentType?: string): BunFile;
|
|
4
|
+
stream(): ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
5
|
+
text(): Promise<string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
declare const Bun: {
|
|
9
|
+
file(path: string): BunFile;
|
|
2
10
|
spawn(args: string[], opts?: Record<string, unknown>): {
|
|
3
11
|
kill(signal?: string): void;
|
|
4
12
|
exited: Promise<number>;
|
|
13
|
+
stdout?: ReadableStream<Uint8Array>;
|
|
14
|
+
stderr?: ReadableStream<Uint8Array>;
|
|
5
15
|
};
|
|
6
16
|
spawnSync(args: string[], opts?: Record<string, unknown>): {
|
|
7
17
|
exitCode: number;
|
package/web-src/types.ts
CHANGED
|
@@ -106,7 +106,13 @@ export type FileRangeResponse = {
|
|
|
106
106
|
start: number;
|
|
107
107
|
end: number;
|
|
108
108
|
lines: string[];
|
|
109
|
+
/**
|
|
110
|
+
* When complete is true, total is the file's total line count.
|
|
111
|
+
* When complete is false, total is only the highest line number the server
|
|
112
|
+
* had to scan to prove more lines exist.
|
|
113
|
+
*/
|
|
109
114
|
total: number;
|
|
115
|
+
complete?: boolean;
|
|
110
116
|
generation?: number;
|
|
111
117
|
};
|
|
112
118
|
|