@youtyan/code-viewer 0.1.10 → 0.1.11

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.10",
3
+ "version": "0.1.11",
4
4
  "description": "Local browser-based code and git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {
package/web/app.js CHANGED
@@ -6393,6 +6393,7 @@
6393
6393
  const VIRTUAL_SOURCE_ROW_HEIGHT = 20;
6394
6394
  const VIRTUAL_SOURCE_HIGHLIGHT_MAX_LINE_LENGTH = 2000;
6395
6395
  let highlightLoadPromise = null;
6396
+ let sourceShikiLoadPromise = null;
6396
6397
  let highlightConfigured = false;
6397
6398
  let PROJECT_NAME = "";
6398
6399
  let REPO_SIDEBAR_REF = null;
@@ -6498,6 +6499,100 @@
6498
6499
  });
6499
6500
  return highlightLoadPromise;
6500
6501
  }
6502
+ const SOURCE_SHIKI_LANGS = Array.from(new Set([
6503
+ "bash",
6504
+ "bibtex",
6505
+ "c",
6506
+ "clojure",
6507
+ "cmake",
6508
+ "cpp",
6509
+ "csharp",
6510
+ "css",
6511
+ "dart",
6512
+ "diff",
6513
+ "dockerfile",
6514
+ "elixir",
6515
+ "erlang",
6516
+ "fortran",
6517
+ "go",
6518
+ "gradle",
6519
+ "graphql",
6520
+ "haskell",
6521
+ "html",
6522
+ "java",
6523
+ "javascript",
6524
+ "json",
6525
+ "julia",
6526
+ "kotlin",
6527
+ "lua",
6528
+ "make",
6529
+ "markdown",
6530
+ "nix",
6531
+ "ocaml",
6532
+ "perl",
6533
+ "php",
6534
+ "properties",
6535
+ "protobuf",
6536
+ "python",
6537
+ "r",
6538
+ "rst",
6539
+ "ruby",
6540
+ "rust",
6541
+ "scala",
6542
+ "scss",
6543
+ "sql",
6544
+ "swift",
6545
+ "terraform",
6546
+ "tex",
6547
+ "toml",
6548
+ "typescript",
6549
+ "vim",
6550
+ "vue",
6551
+ "xml",
6552
+ "yaml"
6553
+ ]));
6554
+ const SOURCE_SHIKI_LANG_ALIASES = {
6555
+ makefile: "make",
6556
+ objectivec: "c",
6557
+ "objective-c": "c",
6558
+ "objective-cpp": "cpp",
6559
+ starlark: "python"
6560
+ };
6561
+ function normalizeSourceShikiLang(lang) {
6562
+ if (!lang)
6563
+ return null;
6564
+ return SOURCE_SHIKI_LANG_ALIASES[lang] || lang;
6565
+ }
6566
+ function loadSourceShikiHighlighter() {
6567
+ if (!sourceShikiLoadPromise) {
6568
+ sourceShikiLoadPromise = import("/shiki.js").then((mod) => {
6569
+ const typed = mod;
6570
+ const langs = typed.bundledLanguages ? SOURCE_SHIKI_LANGS.filter((lang) => !!typed.bundledLanguages?.[lang]) : SOURCE_SHIKI_LANGS;
6571
+ return typed.createHighlighter({
6572
+ themes: ["github-light", "github-dark"],
6573
+ langs
6574
+ });
6575
+ }).catch(() => null);
6576
+ }
6577
+ return sourceShikiLoadPromise;
6578
+ }
6579
+ function sourceShikiLines(textValue, lang, highlighter) {
6580
+ try {
6581
+ const html = highlighter.codeToHtml(textValue || " ", {
6582
+ lang,
6583
+ themes: { light: "github-light", dark: "github-dark" },
6584
+ defaultColor: false
6585
+ });
6586
+ const template = document.createElement("template");
6587
+ template.innerHTML = html;
6588
+ const renderedLines = Array.from(template.content.querySelectorAll(".line"));
6589
+ if (!renderedLines.length)
6590
+ return null;
6591
+ return renderedLines.map((line) => line.innerHTML || " ");
6592
+ } catch {
6593
+ return null;
6594
+ }
6595
+ }
6501
6596
  function rerenderLoadedDiffs() {
6502
6597
  document.querySelectorAll(".gdp-file-shell.loaded").forEach((card) => {
6503
6598
  const data = card._diffData;
@@ -6565,10 +6660,10 @@
6565
6660
  }
6566
6661
  function setFolderIcon(el, collapsed) {
6567
6662
  const path = collapsed ? FOLDER_ICON_PATHS.closed : FOLDER_ICON_PATHS.open;
6568
- el.innerHTML = '<svg class="octicon octicon-file-directory-' + (collapsed ? "fill" : "open-fill") + '" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">' + '<path fill="currentColor" d="' + path + '"></path></svg>';
6663
+ el.innerHTML = '<svg class="octicon octicon-file-directory-' + (collapsed ? "fill" : "open-fill") + '" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path fill="currentColor" d="' + path + '"></path></svg>';
6569
6664
  }
6570
6665
  function setChevronIcon(el) {
6571
- el.innerHTML = '<svg class="octicon octicon-chevron-down" viewBox="0 0 12 12" width="12" height="12" fill="currentColor" aria-hidden="true">' + '<path fill="currentColor" d="' + CHEVRON_DOWN_12_PATH + '"></path></svg>';
6666
+ el.innerHTML = '<svg class="octicon octicon-chevron-down" viewBox="0 0 12 12" width="12" height="12" fill="currentColor" aria-hidden="true"><path fill="currentColor" d="' + CHEVRON_DOWN_12_PATH + '"></path></svg>';
6572
6667
  }
6573
6668
  function iconSvg(className, paths) {
6574
6669
  const pathList = Array.isArray(paths) ? paths : [paths];
@@ -6840,7 +6935,7 @@
6840
6935
  if (meta.totals) {
6841
6936
  const t2 = document.createElement("span");
6842
6937
  t2.className = "num";
6843
- t2.innerHTML = '<span class="add">+' + meta.totals.additions + "</span> " + '<span class="del">−' + meta.totals.deletions + "</span> " + "<span>" + meta.totals.files + " files</span>";
6938
+ t2.innerHTML = '<span class="add">+' + meta.totals.additions + "</span> " + '<span class="del">−' + meta.totals.deletions + "</span> <span>" + meta.totals.files + " files</span>";
6844
6939
  el.appendChild(t2);
6845
6940
  }
6846
6941
  const u2 = document.createElement("span");
@@ -7510,7 +7605,7 @@
7510
7605
  }
7511
7606
  const head = document.createElement("div");
7512
7607
  head.className = "gdp-shell-header";
7513
- head.innerHTML = '<span class="status-pill ' + escapeHtml2(f2.status || "M") + '">' + escapeHtml2(f2.status || "M") + "</span>" + '<span class="path">' + escapeHtml2(f2.display_path || f2.path) + "</span>" + '<span class="stats">' + '<span class="a">+' + (f2.additions || 0) + "</span>" + '<span class="d">−' + (f2.deletions || 0) + "</span>" + "</span>" + '<span class="size-tag ' + escapeHtml2(f2.size_class || "") + '">' + escapeHtml2(f2.size_class || "") + "</span>" + '<span class="loading-indicator" hidden>loading…</span>';
7608
+ head.innerHTML = '<span class="status-pill ' + escapeHtml2(f2.status || "M") + '">' + escapeHtml2(f2.status || "M") + '</span><span class="path">' + escapeHtml2(f2.display_path || f2.path) + '</span><span class="stats"><span class="a">+' + (f2.additions || 0) + "</span>" + '<span class="d">−' + (f2.deletions || 0) + '</span></span><span class="size-tag ' + escapeHtml2(f2.size_class || "") + '">' + escapeHtml2(f2.size_class || "") + "</span>" + '<span class="loading-indicator" hidden>loading…</span>';
7514
7609
  card.appendChild(head);
7515
7610
  const body = document.createElement("div");
7516
7611
  body.className = "gdp-shell-body";
@@ -7948,7 +8043,7 @@
7948
8043
  const button = document.createElement("button");
7949
8044
  button.className = "gdp-expand-btn";
7950
8045
  button.title = spec.title;
7951
- button.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">' + '<path fill="currentColor" d="' + EXPAND_ICON_PATHS[spec.direction] + '"/></svg>';
8046
+ button.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path fill="currentColor" d="' + EXPAND_ICON_PATHS[spec.direction] + '"/></svg>';
7952
8047
  button.addEventListener("click", (e2) => {
7953
8048
  e2.stopPropagation();
7954
8049
  if (button.disabled)
@@ -8046,9 +8141,9 @@
8046
8141
  const num = sideIndex === 0 ? oldStart + i2 : newStart + i2;
8047
8142
  lnHtml = '<td class="d2h-code-side-linenumber d2h-cntx">' + num + "</td>";
8048
8143
  } else {
8049
- lnHtml = '<td class="d2h-code-linenumber d2h-cntx">' + '<div class="line-num1">' + (oldStart + i2) + "</div>" + '<div class="line-num2">' + (newStart + i2) + "</div>" + "</td>";
8144
+ lnHtml = '<td class="d2h-code-linenumber d2h-cntx"><div class="line-num1">' + (oldStart + i2) + '</div><div class="line-num2">' + (newStart + i2) + "</div></td>";
8050
8145
  }
8051
- tr.innerHTML = lnHtml + '<td class="d2h-cntx">' + '<div class="' + (isSplit ? "d2h-code-side-line" : "d2h-code-line") + '">' + '<span class="d2h-code-line-prefix">&nbsp;</span>' + '<span class="d2h-code-line-ctn">' + escapeHtmlText(lines[i2]) + "</span>" + "</div>" + "</td>";
8146
+ tr.innerHTML = lnHtml + '<td class="d2h-cntx"><div class="' + (isSplit ? "d2h-code-side-line" : "d2h-code-line") + '"><span class="d2h-code-line-prefix">&nbsp;</span><span class="d2h-code-line-ctn">' + escapeHtmlText(lines[i2]) + "</span></div></td>";
8052
8147
  frag.appendChild(tr);
8053
8148
  }
8054
8149
  tbody.insertBefore(frag, anchor);
@@ -8562,12 +8657,14 @@
8562
8657
  header.textContent = target.path + " @ " + target.ref;
8563
8658
  }
8564
8659
  const lang = inferLang(target.path);
8565
- const hljsRef = STATE.syntaxHighlight ? await loadSyntaxHighlighter() : null;
8660
+ const usesVirtualSource = shouldVirtualizeSource(textValue, lines) && !isVirtualSourceDisabled();
8661
+ const hljsRef = STATE.syntaxHighlight && usesVirtualSource ? await loadSyntaxHighlighter() : null;
8662
+ const sourceShikiRef = STATE.syntaxHighlight && !usesVirtualSource ? await loadSourceShikiHighlighter() : null;
8566
8663
  if (signal?.aborted)
8567
8664
  return false;
8568
8665
  const previewable = isPreviewableSource(target.path);
8569
8666
  const tabsHost = card.querySelector(".gdp-file-detail-tabs");
8570
- if (shouldVirtualizeSource(textValue, lines) && !isVirtualSourceDisabled()) {
8667
+ if (usesVirtualSource) {
8571
8668
  const virtualCode = renderVirtualSource(target, textValue, lines, hljsRef, lang);
8572
8669
  if (previewable) {
8573
8670
  const { tabs: tabs2, codeButton: codeButton2, previewButton: previewButton2 } = createSourceTabs("preview");
@@ -8623,6 +8720,8 @@
8623
8720
  const table2 = document.createElement("table");
8624
8721
  table2.className = "gdp-source-table";
8625
8722
  const tbody = document.createElement("tbody");
8723
+ const sourceShikiLang = normalizeSourceShikiLang(lang);
8724
+ const shikiLines = sourceShikiRef && sourceShikiLang ? sourceShikiLines(textValue, sourceShikiLang, sourceShikiRef) : null;
8626
8725
  for (let index = 0;index < lines.length; index++) {
8627
8726
  if (signal?.aborted)
8628
8727
  return false;
@@ -8633,13 +8732,9 @@
8633
8732
  num.textContent = String(index + 1);
8634
8733
  const code2 = document.createElement("td");
8635
8734
  code2.className = "gdp-source-line-code";
8636
- if (hljsRef && hljsRef.highlight && lang && (!hljsRef.getLanguage || hljsRef.getLanguage(lang))) {
8637
- try {
8638
- code2.innerHTML = hljsRef.highlight(line || " ", { language: lang, ignoreIllegals: true }).value;
8639
- code2.classList.add("hljs");
8640
- } catch {
8641
- code2.textContent = line || " ";
8642
- }
8735
+ if (shikiLines && shikiLines[index] != null) {
8736
+ code2.innerHTML = shikiLines[index] || " ";
8737
+ code2.classList.add("shiki");
8643
8738
  } else {
8644
8739
  code2.textContent = line || " ";
8645
8740
  }
@@ -9450,7 +9545,7 @@
9450
9545
  leftHTML = mediaTag(path, "HEAD");
9451
9546
  rightHTML = mediaTag(path, "worktree");
9452
9547
  }
9453
- container.innerHTML = '<div class="media-side"><div class="media-label del">Before</div>' + leftHTML + "</div>" + '<div class="media-side"><div class="media-label add">After</div>' + rightHTML + "</div>";
9548
+ container.innerHTML = '<div class="media-side"><div class="media-label del">Before</div>' + leftHTML + '</div><div class="media-side"><div class="media-label add">After</div>' + rightHTML + "</div>";
9454
9549
  body.replaceWith(container);
9455
9550
  }
9456
9551
  function setupScrollSpy() {
@@ -9842,7 +9937,7 @@
9842
9937
  const [sha, subject, author, when] = c2.split("\t");
9843
9938
  if (!sha)
9844
9939
  continue;
9845
- html.push('<div class="rp-item-commit" data-val="' + escapeAttr(sha) + '">' + '<div class="row1">' + '<span class="sha">' + escapeHtml2(sha) + "</span>" + '<span class="subject" title="' + escapeAttr(subject || "") + '">' + escapeHtml2(subject || "") + "</span>" + "</div>" + '<div class="row2">' + '<span class="author">' + escapeHtml2(author || "") + "</span>" + '<span class="when">' + escapeHtml2(when || "") + "</span>" + "</div>" + "</div>");
9940
+ html.push('<div class="rp-item-commit" data-val="' + escapeAttr(sha) + '"><div class="row1"><span class="sha">' + escapeHtml2(sha) + '</span><span class="subject" title="' + escapeAttr(subject || "") + '">' + escapeHtml2(subject || "") + '</span></div><div class="row2"><span class="author">' + escapeHtml2(author || "") + '</span><span class="when">' + escapeHtml2(when || "") + "</span></div></div>");
9846
9941
  }
9847
9942
  } else if (popTab === "branches") {
9848
9943
  const branches = (REFS.branches || []).filter(m);
@@ -9851,7 +9946,7 @@
9851
9946
  }
9852
9947
  for (const b2 of branches) {
9853
9948
  const cur = b2 === REFS.current;
9854
- html.push('<div class="rp-item-ref" data-val="' + escapeAttr(b2) + '">' + '<span class="name">' + escapeHtml2(b2) + "</span>" + (cur ? '<span class="badge cur">current</span>' : '<span class="badge">branch</span>') + "</div>");
9949
+ html.push('<div class="rp-item-ref" data-val="' + escapeAttr(b2) + '"><span class="name">' + escapeHtml2(b2) + "</span>" + (cur ? '<span class="badge cur">current</span>' : '<span class="badge">branch</span>') + "</div>");
9855
9950
  }
9856
9951
  } else if (popTab === "tags") {
9857
9952
  const tags = (REFS.tags || []).filter(m);
@@ -9859,7 +9954,7 @@
9859
9954
  html.push('<div class="rp-empty">no tags</div>');
9860
9955
  }
9861
9956
  for (const t2 of tags) {
9862
- html.push('<div class="rp-item-ref" data-val="' + escapeAttr(t2) + '">' + '<span class="name">' + escapeHtml2(t2) + "</span>" + '<span class="badge">tag</span>' + "</div>");
9957
+ html.push('<div class="rp-item-ref" data-val="' + escapeAttr(t2) + '"><span class="name">' + escapeHtml2(t2) + '</span><span class="badge">tag</span></div>');
9863
9958
  }
9864
9959
  }
9865
9960
  popBody.innerHTML = html.join("");
package/web/shiki.js CHANGED
@@ -13178,5 +13178,6 @@ var createHighlighter = /* @__PURE__ */ createBundledHighlighter({
13178
13178
  engine: () => (0, engine_oniguruma_exports.createOnigurumaEngine)(Promise.resolve().then(() => (init_wasm2(), exports_wasm2)))
13179
13179
  });
13180
13180
  export {
13181
- createHighlighter
13181
+ createHighlighter,
13182
+ bundledLanguages
13182
13183
  };
package/web/style.css CHANGED
@@ -1660,6 +1660,14 @@ table.d2h-diff-table .d2h-code-line-prefix {
1660
1660
  .gdp-source-line-code.hljs {
1661
1661
  background: var(--bg);
1662
1662
  }
1663
+ .gdp-source-line-code.shiki,
1664
+ .gdp-source-line-code.shiki span {
1665
+ color: var(--shiki-light) !important;
1666
+ }
1667
+ [data-theme="dark"] .gdp-source-line-code.shiki,
1668
+ [data-theme="dark"] .gdp-source-line-code.shiki span {
1669
+ color: var(--shiki-dark) !important;
1670
+ }
1663
1671
  .gdp-source-virtual {
1664
1672
  display: grid;
1665
1673
  grid-template-rows: auto minmax(0, 1fr);
@@ -1,6 +1,10 @@
1
+ import { lstatSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
1
4
  // Short enough that a browser reload self-heals stale git data, while still
2
5
  // coalescing bursts from one render pass.
3
6
  export const CACHE_TTL_MS = 1500;
7
+ export const MAX_TIMED_CACHE_ENTRIES = 200;
4
8
 
5
9
  export type TimedCacheEntry<T> = T & { storedAt: number };
6
10
 
@@ -11,3 +15,50 @@ export function cacheFresh<T>(
11
15
  ): cached is TimedCacheEntry<T> {
12
16
  return !!cached && now - cached.storedAt <= ttlMs;
13
17
  }
18
+
19
+ export function setTimedCacheEntry<T>(
20
+ cache: Map<string, TimedCacheEntry<T>>,
21
+ key: string,
22
+ value: T,
23
+ now = Date.now(),
24
+ maxEntries = MAX_TIMED_CACHE_ENTRIES,
25
+ ): void {
26
+ cache.set(key, { ...value, storedAt: now });
27
+ while (cache.size > maxEntries) {
28
+ const oldest = cache.keys().next().value;
29
+ if (oldest === undefined) break;
30
+ cache.delete(oldest);
31
+ }
32
+ }
33
+
34
+ export function worktreeFileSignature(path: string, cwd: string): string {
35
+ try {
36
+ const stats = lstatSync(join(cwd, path));
37
+ const inode = 'ino' in stats ? stats.ino : 0;
38
+ return `state:file|size:${stats.size}|mtime:${stats.mtimeMs}|ctime:${stats.ctimeMs}|ino:${inode}`;
39
+ } catch {
40
+ return 'state:missing';
41
+ }
42
+ }
43
+
44
+ export function fileDiffCacheKey(options: {
45
+ path: string;
46
+ oldPath?: string | null;
47
+ isUntracked: boolean;
48
+ range: { from?: string; to?: string };
49
+ extras: string[];
50
+ args: string[];
51
+ cwd: string;
52
+ }): string {
53
+ const worktreeTarget = options.range.from === 'worktree' || !options.range.to || options.range.to === 'worktree';
54
+ if (options.isUntracked && !worktreeTarget) {
55
+ throw new Error('untracked file diffs require a worktree range');
56
+ }
57
+ const signature = worktreeTarget
58
+ ? `\0${worktreeFileSignature(options.path, options.cwd)}`
59
+ : '';
60
+ if (options.isUntracked) {
61
+ return `u\0${options.path}${signature}\0${options.extras.join('\0')}`;
62
+ }
63
+ return `t\0${options.path}\0${options.oldPath || ''}${signature}\0${[...options.extras, ...options.args].join('\0')}`;
64
+ }
@@ -4,7 +4,7 @@ import { closeSync, constants, existsSync, openSync, readFileSync, realpathSync,
4
4
  import { basename, dirname, extname, join, normalize, relative } from 'node:path';
5
5
  import { APP_ENTRY_PATHS, SPA_PATHS } from '../routes';
6
6
  import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, RepoTreeResponse } from '../types';
7
- import { cacheFresh, type TimedCacheEntry } from './cache';
7
+ import { cacheFresh, fileDiffCacheKey, setTimedCacheEntry, type TimedCacheEntry } from './cache';
8
8
  import { startDevAssetReload } from './dev-assets';
9
9
  import * as git from './git';
10
10
  import { isSameWorktreeRange } from './range';
@@ -273,14 +273,14 @@ function handleDiffJson(url: URL) {
273
273
  fileCache.clear();
274
274
  }
275
275
  const body = JSON.stringify(payload);
276
- metaCache.set(key, { body, sig, storedAt: Date.now() });
276
+ setTimedCacheEntry(metaCache, key, { body, sig });
277
277
  return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
278
278
  }
279
279
  const cached = metaCache.get(key);
280
280
  if (cacheFresh(cached)) return new Response(cached.body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
281
281
  const payload = computePayload(extras, range);
282
282
  const body = JSON.stringify(payload);
283
- metaCache.set(key, { body, sig: JSON.stringify({ ...payload, generation: undefined }), storedAt: Date.now() });
283
+ setTimedCacheEntry(metaCache, key, { body, sig: JSON.stringify({ ...payload, generation: undefined }) });
284
284
  return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
285
285
  }
286
286
 
@@ -420,9 +420,12 @@ function handleFileDiff(url: URL) {
420
420
  }
421
421
  const { args } = buildRangeArgs(range);
422
422
  const oldPath = url.searchParams.get('old_path');
423
- const cacheKey = isUntracked
424
- ? `u\0${path}\0${extras.join('\0')}`
425
- : `t\0${path}\0${oldPath || ''}\0${[...extras, ...args].join('\0')}`;
423
+ let cacheKey: string;
424
+ try {
425
+ cacheKey = fileDiffCacheKey({ path, oldPath, isUntracked, range, extras, args, cwd });
426
+ } catch {
427
+ return text('invalid diff range', 400);
428
+ }
426
429
  const cached = fileCache.get(cacheKey);
427
430
  let diffText: string;
428
431
  let errText = '';
@@ -436,7 +439,7 @@ function handleFileDiff(url: URL) {
436
439
  diffText = res.stdout || '';
437
440
  if (res.code !== 0) errText = res.stderr;
438
441
  }
439
- fileCache.set(cacheKey, { diffText, storedAt: Date.now() });
442
+ setTimedCacheEntry(fileCache, cacheKey, { diffText });
440
443
  }
441
444
  const mode = url.searchParams.get('mode') || 'full';
442
445
  const truncated = mode === 'preview'