@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youtyan/code-viewer",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Local browser-based code and git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {
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
- focusSidebarPanel();
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
- focusSidebarPanel();
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
- focusSidebarPanel();
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
- if (hljsRef && hljsRef.highlight && lang && line.length <= VIRTUAL_SOURCE_HIGHLIGHT_MAX_LINE_LENGTH && (!hljsRef.getLanguage || hljsRef.getLanguage(lang))) {
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-actions {
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: pointer;
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;
@@ -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 { isSameWorktreeRange } from './range';
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 handleFileRange(url: URL) {
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
- content = readFileSync(full, 'utf8');
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 res = git.show(ref, path, cwd);
666
- if (res.code !== 0) return text('not in ref', 404);
667
- content = res.stdout;
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
- const bytes = new Uint8Array(readFileSync(full));
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) headers['Content-Length'] = String(size);
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
 
@@ -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