@youtyan/code-viewer 0.1.9 → 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.9",
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,8 +6393,20 @@
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 = "";
6399
+ let REPO_SIDEBAR_REF = null;
6400
+ let REPO_SIDEBAR_LOAD_REF = null;
6401
+ let REPO_SIDEBAR_LOAD = null;
6402
+ function invalidateRepoSidebar() {
6403
+ REPO_SIDEBAR_REF = null;
6404
+ REPO_SIDEBAR_LOAD_REF = null;
6405
+ REPO_SIDEBAR_LOAD = null;
6406
+ }
6407
+ function isRepoSidebarReusable(ref) {
6408
+ return REPO_SIDEBAR_REF === (ref || "worktree") && isRepositorySidebarMode();
6409
+ }
6398
6410
  const STATE = (() => {
6399
6411
  const igRaw = localStorage.getItem("gdp:ignore-ws");
6400
6412
  const fallbackRange = {
@@ -6487,6 +6499,100 @@
6487
6499
  });
6488
6500
  return highlightLoadPromise;
6489
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
+ }
6490
6596
  function rerenderLoadedDiffs() {
6491
6597
  document.querySelectorAll(".gdp-file-shell.loaded").forEach((card) => {
6492
6598
  const data = card._diffData;
@@ -6554,10 +6660,10 @@
6554
6660
  }
6555
6661
  function setFolderIcon(el, collapsed) {
6556
6662
  const path = collapsed ? FOLDER_ICON_PATHS.closed : FOLDER_ICON_PATHS.open;
6557
- 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>';
6558
6664
  }
6559
6665
  function setChevronIcon(el) {
6560
- 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>';
6561
6667
  }
6562
6668
  function iconSvg(className, paths) {
6563
6669
  const pathList = Array.isArray(paths) ? paths : [paths];
@@ -6768,6 +6874,8 @@
6768
6874
  ul.innerHTML = "";
6769
6875
  ul.classList.toggle("tree", STATE.sbView === "tree");
6770
6876
  STATE.files = files;
6877
+ if (!onFileClick)
6878
+ REPO_SIDEBAR_REF = null;
6771
6879
  if (STATE.sbView === "tree") {
6772
6880
  const root = buildTree(files);
6773
6881
  renderTreeNode(root, 0, ul, onFileClick);
@@ -6827,7 +6935,7 @@
6827
6935
  if (meta.totals) {
6828
6936
  const t2 = document.createElement("span");
6829
6937
  t2.className = "num";
6830
- 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>";
6831
6939
  el.appendChild(t2);
6832
6940
  }
6833
6941
  const u2 = document.createElement("span");
@@ -7168,6 +7276,7 @@
7168
7276
  });
7169
7277
  if (!res.ok)
7170
7278
  throw new Error(await res.text());
7279
+ invalidateRepoSidebar();
7171
7280
  await loadRepo();
7172
7281
  }
7173
7282
  function createRepoUploadPanel(path) {
@@ -7282,8 +7391,8 @@
7282
7391
  removeStandaloneSource();
7283
7392
  $("#empty").classList.add("hidden");
7284
7393
  $("#diff").replaceChildren();
7285
- $("#filelist").replaceChildren();
7286
- $("#totals").textContent = "";
7394
+ if (!isRepoSidebarReusable(meta.ref))
7395
+ $("#totals").textContent = "";
7287
7396
  STATE.files = [];
7288
7397
  LOAD_QUEUE.length = 0;
7289
7398
  renderRepoBlobSidebar(meta.path || "", meta.ref);
@@ -7426,14 +7535,28 @@
7426
7535
  }
7427
7536
  function renderRepoBlobSidebar(currentPath, ref) {
7428
7537
  syncRepoTargetInput(ref);
7538
+ const normalizedRef = ref || "worktree";
7539
+ if (isRepoSidebarReusable(normalizedRef)) {
7540
+ activateRepoSidebarPath(currentPath);
7541
+ return Promise.resolve();
7542
+ }
7543
+ if (REPO_SIDEBAR_LOAD && REPO_SIDEBAR_LOAD_REF === normalizedRef) {
7544
+ return REPO_SIDEBAR_LOAD.then(() => {
7545
+ activateRepoSidebarPath(currentPath);
7546
+ });
7547
+ }
7429
7548
  const params = new URLSearchParams;
7430
- params.set("ref", ref || "worktree");
7549
+ params.set("ref", normalizedRef);
7431
7550
  params.set("recursive", "1");
7432
- return trackLoad(fetch("/_tree?" + params.toString()).then((r2) => {
7551
+ REPO_SIDEBAR_LOAD_REF = normalizedRef;
7552
+ const load2 = trackLoad(fetch("/_tree?" + params.toString()).then((r2) => {
7433
7553
  if (!r2.ok)
7434
7554
  throw new Error("failed to load repository tree");
7435
7555
  return r2.json();
7436
7556
  })).then((meta) => {
7557
+ const activeRepoRef = repoFileTargetFromRoute() || (STATE.route.screen === "repo" ? STATE.route.ref : "");
7558
+ if ((activeRepoRef || "worktree") !== normalizedRef)
7559
+ return;
7437
7560
  const files = meta.entries.map((entry, index) => ({
7438
7561
  order: index + 1,
7439
7562
  path: entry.path,
@@ -7443,19 +7566,31 @@
7443
7566
  }));
7444
7567
  renderSidebar(files, (file) => {
7445
7568
  if (file.type === "tree") {
7446
- setRoute(repoRoute(ref, file.path));
7569
+ setRoute(repoRoute(normalizedRef, file.path));
7447
7570
  loadRepo();
7448
7571
  return;
7449
7572
  }
7450
- setRoute({ screen: "file", path: file.path, ref, view: "blob", range: currentRange() });
7451
- renderStandaloneSource({ path: file.path, ref });
7573
+ setRoute({ screen: "file", path: file.path, ref: normalizedRef, view: "blob", range: currentRange() });
7574
+ renderStandaloneSource({ path: file.path, ref: normalizedRef });
7452
7575
  });
7453
- markActive(currentPath);
7454
- applyFilter();
7576
+ REPO_SIDEBAR_REF = normalizedRef;
7577
+ activateRepoSidebarPath(currentPath);
7455
7578
  }).catch(() => {
7579
+ REPO_SIDEBAR_REF = null;
7456
7580
  renderSidebar([], undefined);
7457
7581
  $("#totals").textContent = "Cannot load tree";
7582
+ }).finally(() => {
7583
+ if (REPO_SIDEBAR_LOAD === load2) {
7584
+ REPO_SIDEBAR_LOAD_REF = null;
7585
+ REPO_SIDEBAR_LOAD = null;
7586
+ }
7458
7587
  });
7588
+ REPO_SIDEBAR_LOAD = load2;
7589
+ return load2;
7590
+ }
7591
+ function activateRepoSidebarPath(currentPath) {
7592
+ markActive(currentPath);
7593
+ applyFilter();
7459
7594
  }
7460
7595
  function createPlaceholder(f2) {
7461
7596
  const card = document.createElement("div");
@@ -7470,7 +7605,7 @@
7470
7605
  }
7471
7606
  const head = document.createElement("div");
7472
7607
  head.className = "gdp-shell-header";
7473
- 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>';
7474
7609
  card.appendChild(head);
7475
7610
  const body = document.createElement("div");
7476
7611
  body.className = "gdp-shell-body";
@@ -7908,7 +8043,7 @@
7908
8043
  const button = document.createElement("button");
7909
8044
  button.className = "gdp-expand-btn";
7910
8045
  button.title = spec.title;
7911
- 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>';
7912
8047
  button.addEventListener("click", (e2) => {
7913
8048
  e2.stopPropagation();
7914
8049
  if (button.disabled)
@@ -8006,9 +8141,9 @@
8006
8141
  const num = sideIndex === 0 ? oldStart + i2 : newStart + i2;
8007
8142
  lnHtml = '<td class="d2h-code-side-linenumber d2h-cntx">' + num + "</td>";
8008
8143
  } else {
8009
- 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>";
8010
8145
  }
8011
- 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>";
8012
8147
  frag.appendChild(tr);
8013
8148
  }
8014
8149
  tbody.insertBefore(frag, anchor);
@@ -8135,6 +8270,269 @@
8135
8270
  function isPreviewableSource(path) {
8136
8271
  return /\.(md|markdown|mdown|mkdn|mdx)$/i.test(path);
8137
8272
  }
8273
+ const EXT_TO_LANG = {
8274
+ js: "javascript",
8275
+ mjs: "javascript",
8276
+ cjs: "javascript",
8277
+ ts: "typescript",
8278
+ tsx: "typescript",
8279
+ jsx: "javascript",
8280
+ py: "python",
8281
+ rb: "ruby",
8282
+ go: "go",
8283
+ rs: "rust",
8284
+ java: "java",
8285
+ kt: "kotlin",
8286
+ swift: "swift",
8287
+ c: "c",
8288
+ h: "c",
8289
+ cc: "cpp",
8290
+ cpp: "cpp",
8291
+ hpp: "cpp",
8292
+ cs: "csharp",
8293
+ php: "php",
8294
+ lua: "lua",
8295
+ sh: "bash",
8296
+ bash: "bash",
8297
+ zsh: "bash",
8298
+ fish: "bash",
8299
+ sql: "sql",
8300
+ json: "json",
8301
+ yaml: "yaml",
8302
+ yml: "yaml",
8303
+ toml: "toml",
8304
+ tf: "terraform",
8305
+ tfvars: "terraform",
8306
+ hcl: "terraform",
8307
+ xml: "xml",
8308
+ html: "xml",
8309
+ vue: "xml",
8310
+ css: "css",
8311
+ scss: "scss",
8312
+ md: "markdown",
8313
+ dockerfile: "dockerfile",
8314
+ proto: "protobuf",
8315
+ gradle: "gradle",
8316
+ properties: "properties",
8317
+ patch: "diff",
8318
+ diff: "diff",
8319
+ nix: "nix",
8320
+ cue: "cue",
8321
+ rego: "rego",
8322
+ bicep: "bicep",
8323
+ bazel: "starlark",
8324
+ bzl: "starlark",
8325
+ cmake: "cmake",
8326
+ groovy: "groovy",
8327
+ dart: "dart",
8328
+ scala: "scala",
8329
+ clj: "clojure",
8330
+ cljs: "clojure",
8331
+ cljc: "clojure",
8332
+ edn: "clojure",
8333
+ ex: "elixir",
8334
+ exs: "elixir",
8335
+ erl: "erlang",
8336
+ hrl: "erlang",
8337
+ hs: "haskell",
8338
+ lhs: "haskell",
8339
+ ml: "ocaml",
8340
+ mli: "ocaml",
8341
+ jl: "julia",
8342
+ r: "r",
8343
+ rmd: "r",
8344
+ pl: "perl",
8345
+ pm: "perl",
8346
+ tcl: "tcl",
8347
+ vim: "vim",
8348
+ f: "fortran",
8349
+ f90: "fortran",
8350
+ m: "objective-c",
8351
+ mm: "objective-cpp",
8352
+ tex: "tex",
8353
+ bib: "bibtex",
8354
+ rst: "rst"
8355
+ };
8356
+ const TEXT_SOURCE_EXTENSIONS = new Set([
8357
+ ...Object.keys(EXT_TO_LANG),
8358
+ "txt",
8359
+ "md",
8360
+ "markdown",
8361
+ "mdown",
8362
+ "mkdn",
8363
+ "mdx",
8364
+ "json",
8365
+ "jsonc",
8366
+ "csv",
8367
+ "tsv",
8368
+ "yaml",
8369
+ "yml",
8370
+ "toml",
8371
+ "hcl",
8372
+ "tf",
8373
+ "tfvars",
8374
+ "tfstate",
8375
+ "xml",
8376
+ "html",
8377
+ "htm",
8378
+ "css",
8379
+ "scss",
8380
+ "sass",
8381
+ "less",
8382
+ "js",
8383
+ "jsx",
8384
+ "mjs",
8385
+ "cjs",
8386
+ "ts",
8387
+ "tsx",
8388
+ "mts",
8389
+ "cts",
8390
+ "vue",
8391
+ "svelte",
8392
+ "astro",
8393
+ "rs",
8394
+ "go",
8395
+ "py",
8396
+ "rb",
8397
+ "php",
8398
+ "java",
8399
+ "kt",
8400
+ "kts",
8401
+ "c",
8402
+ "cc",
8403
+ "cpp",
8404
+ "cxx",
8405
+ "h",
8406
+ "hpp",
8407
+ "cs",
8408
+ "swift",
8409
+ "sh",
8410
+ "bash",
8411
+ "zsh",
8412
+ "fish",
8413
+ "ps1",
8414
+ "sql",
8415
+ "graphql",
8416
+ "graphqls",
8417
+ "gql",
8418
+ "ini",
8419
+ "conf",
8420
+ "env",
8421
+ "properties",
8422
+ "gitignore",
8423
+ "dockerignore",
8424
+ "editorconfig",
8425
+ "lock",
8426
+ "log",
8427
+ "patch",
8428
+ "diff",
8429
+ "sum",
8430
+ "mk",
8431
+ "proto",
8432
+ "thrift",
8433
+ "prisma",
8434
+ "gradle",
8435
+ "cmake",
8436
+ "nix",
8437
+ "cue",
8438
+ "rego",
8439
+ "bicep",
8440
+ "bazel",
8441
+ "bzl",
8442
+ "dart",
8443
+ "scala",
8444
+ "clj",
8445
+ "cljs",
8446
+ "cljc",
8447
+ "edn",
8448
+ "ex",
8449
+ "exs",
8450
+ "erl",
8451
+ "hrl",
8452
+ "hs",
8453
+ "lhs",
8454
+ "ml",
8455
+ "mli",
8456
+ "jl",
8457
+ "r",
8458
+ "rmd",
8459
+ "pl",
8460
+ "pm",
8461
+ "tcl",
8462
+ "vim",
8463
+ "groovy",
8464
+ "f",
8465
+ "f90",
8466
+ "m",
8467
+ "mm",
8468
+ "pas",
8469
+ "tex",
8470
+ "bib",
8471
+ "rst",
8472
+ "adoc",
8473
+ "org",
8474
+ "ipynb",
8475
+ "ejs",
8476
+ "hbs",
8477
+ "mustache",
8478
+ "liquid",
8479
+ "pug"
8480
+ ]);
8481
+ const TEXT_SOURCE_FILENAMES = new Set([
8482
+ "readme",
8483
+ "license",
8484
+ "copying",
8485
+ "authors",
8486
+ "contributors",
8487
+ "notice",
8488
+ "changelog",
8489
+ "todo",
8490
+ "manifest",
8491
+ "version",
8492
+ "codeowners",
8493
+ "go.mod",
8494
+ "build.bazel",
8495
+ "workspace.bazel",
8496
+ "module.bazel",
8497
+ "gemfile",
8498
+ "rakefile",
8499
+ "procfile",
8500
+ "brewfile",
8501
+ "gnumakefile",
8502
+ "bsdmakefile",
8503
+ ".gitattributes",
8504
+ ".gitmodules",
8505
+ ".npmrc",
8506
+ ".nvmrc",
8507
+ ".yarnrc",
8508
+ ".prettierrc",
8509
+ ".eslintrc",
8510
+ ".babelrc",
8511
+ ".stylelintrc"
8512
+ ]);
8513
+ const FILENAME_TO_LANG = {
8514
+ dockerfile: "dockerfile",
8515
+ makefile: "makefile",
8516
+ gnumakefile: "makefile",
8517
+ bsdmakefile: "makefile",
8518
+ "go.mod": "go",
8519
+ "build.bazel": "starlark",
8520
+ "workspace.bazel": "starlark",
8521
+ "module.bazel": "starlark"
8522
+ };
8523
+ function sourceFileName(path) {
8524
+ return (path.split("/").pop() || path).toLowerCase();
8525
+ }
8526
+ function sourceFileExtension(name) {
8527
+ const index = name.lastIndexOf(".");
8528
+ return index >= 0 ? name.slice(index + 1) : "";
8529
+ }
8530
+ function isDockerfileName(name) {
8531
+ return /^dockerfile(?:[.-].+)?$/i.test(name);
8532
+ }
8533
+ function isMakefileName(name) {
8534
+ return /^makefile(?:[.-].+)?$/i.test(name);
8535
+ }
8138
8536
  function sourceDisplayKind(path) {
8139
8537
  if (isVideo(path))
8140
8538
  return "video";
@@ -8142,9 +8540,13 @@
8142
8540
  return "image";
8143
8541
  if (/\.pdf$/i.test(path))
8144
8542
  return "pdf";
8145
- if (/\.(txt|md|markdown|mdown|mkdn|mdx|json|jsonc|csv|tsv|ya?ml|toml|xml|html?|css|scss|sass|less|js|jsx|mjs|cjs|ts|tsx|mts|cts|vue|svelte|astro|rs|go|py|rb|php|java|kt|kts|c|cc|cpp|cxx|h|hpp|cs|swift|sh|bash|zsh|fish|ps1|sql|graphql|gql|ini|conf|env|gitignore|dockerignore|editorconfig|lock|log)$/i.test(path))
8543
+ const name = sourceFileName(path);
8544
+ const ext = sourceFileExtension(name);
8545
+ if (TEXT_SOURCE_EXTENSIONS.has(ext))
8546
+ return "text";
8547
+ if (TEXT_SOURCE_FILENAMES.has(name))
8146
8548
  return "text";
8147
- if (/\/?(readme|license|notice|changelog|dockerfile|makefile)$/i.test(path))
8549
+ if (isDockerfileName(name) || isMakefileName(name))
8148
8550
  return "text";
8149
8551
  return "unsupported";
8150
8552
  }
@@ -8255,12 +8657,14 @@
8255
8657
  header.textContent = target.path + " @ " + target.ref;
8256
8658
  }
8257
8659
  const lang = inferLang(target.path);
8258
- 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;
8259
8663
  if (signal?.aborted)
8260
8664
  return false;
8261
8665
  const previewable = isPreviewableSource(target.path);
8262
8666
  const tabsHost = card.querySelector(".gdp-file-detail-tabs");
8263
- if (shouldVirtualizeSource(textValue, lines) && !isVirtualSourceDisabled()) {
8667
+ if (usesVirtualSource) {
8264
8668
  const virtualCode = renderVirtualSource(target, textValue, lines, hljsRef, lang);
8265
8669
  if (previewable) {
8266
8670
  const { tabs: tabs2, codeButton: codeButton2, previewButton: previewButton2 } = createSourceTabs("preview");
@@ -8316,6 +8720,8 @@
8316
8720
  const table2 = document.createElement("table");
8317
8721
  table2.className = "gdp-source-table";
8318
8722
  const tbody = document.createElement("tbody");
8723
+ const sourceShikiLang = normalizeSourceShikiLang(lang);
8724
+ const shikiLines = sourceShikiRef && sourceShikiLang ? sourceShikiLines(textValue, sourceShikiLang, sourceShikiRef) : null;
8319
8725
  for (let index = 0;index < lines.length; index++) {
8320
8726
  if (signal?.aborted)
8321
8727
  return false;
@@ -8326,13 +8732,9 @@
8326
8732
  num.textContent = String(index + 1);
8327
8733
  const code2 = document.createElement("td");
8328
8734
  code2.className = "gdp-source-line-code";
8329
- if (hljsRef && hljsRef.highlight && lang && (!hljsRef.getLanguage || hljsRef.getLanguage(lang))) {
8330
- try {
8331
- code2.innerHTML = hljsRef.highlight(line || " ", { language: lang, ignoreIllegals: true }).value;
8332
- code2.classList.add("hljs");
8333
- } catch {
8334
- code2.textContent = line || " ";
8335
- }
8735
+ if (shikiLines && shikiLines[index] != null) {
8736
+ code2.innerHTML = shikiLines[index] || " ";
8737
+ code2.classList.add("shiki");
8336
8738
  } else {
8337
8739
  code2.textContent = line || " ";
8338
8740
  }
@@ -9005,46 +9407,15 @@
9005
9407
  });
9006
9408
  }
9007
9409
  }
9008
- const EXT_TO_LANG = {
9009
- js: "javascript",
9010
- mjs: "javascript",
9011
- cjs: "javascript",
9012
- ts: "typescript",
9013
- tsx: "typescript",
9014
- jsx: "javascript",
9015
- py: "python",
9016
- rb: "ruby",
9017
- go: "go",
9018
- rs: "rust",
9019
- java: "java",
9020
- kt: "kotlin",
9021
- swift: "swift",
9022
- c: "c",
9023
- h: "c",
9024
- cc: "cpp",
9025
- cpp: "cpp",
9026
- hpp: "cpp",
9027
- cs: "csharp",
9028
- php: "php",
9029
- lua: "lua",
9030
- sh: "bash",
9031
- bash: "bash",
9032
- zsh: "bash",
9033
- fish: "bash",
9034
- sql: "sql",
9035
- json: "json",
9036
- yaml: "yaml",
9037
- yml: "yaml",
9038
- toml: "toml",
9039
- xml: "xml",
9040
- html: "xml",
9041
- vue: "xml",
9042
- css: "css",
9043
- scss: "scss",
9044
- md: "markdown",
9045
- dockerfile: "dockerfile"
9046
- };
9047
9410
  function inferLang(path) {
9411
+ const name = sourceFileName(path);
9412
+ const fileLang = FILENAME_TO_LANG[name];
9413
+ if (fileLang)
9414
+ return fileLang;
9415
+ if (isDockerfileName(name))
9416
+ return "dockerfile";
9417
+ if (isMakefileName(name))
9418
+ return "makefile";
9048
9419
  const m = path.match(/\.([^.]+)$/);
9049
9420
  if (!m)
9050
9421
  return null;
@@ -9174,7 +9545,7 @@
9174
9545
  leftHTML = mediaTag(path, "HEAD");
9175
9546
  rightHTML = mediaTag(path, "worktree");
9176
9547
  }
9177
- 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>";
9178
9549
  body.replaceWith(container);
9179
9550
  }
9180
9551
  function setupScrollSpy() {
@@ -9566,7 +9937,7 @@
9566
9937
  const [sha, subject, author, when] = c2.split("\t");
9567
9938
  if (!sha)
9568
9939
  continue;
9569
- 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>");
9570
9941
  }
9571
9942
  } else if (popTab === "branches") {
9572
9943
  const branches = (REFS.branches || []).filter(m);
@@ -9575,7 +9946,7 @@
9575
9946
  }
9576
9947
  for (const b2 of branches) {
9577
9948
  const cur = b2 === REFS.current;
9578
- 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>");
9579
9950
  }
9580
9951
  } else if (popTab === "tags") {
9581
9952
  const tags = (REFS.tags || []).filter(m);
@@ -9583,7 +9954,7 @@
9583
9954
  html.push('<div class="rp-empty">no tags</div>');
9584
9955
  }
9585
9956
  for (const t2 of tags) {
9586
- 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>');
9587
9958
  }
9588
9959
  }
9589
9960
  popBody.innerHTML = html.join("");
@@ -9826,6 +10197,7 @@
9826
10197
  clearTimeout(sseTimer);
9827
10198
  sseTimer = setTimeout(() => {
9828
10199
  sseTimer = null;
10200
+ invalidateRepoSidebar();
9829
10201
  const savedScroll = window.scrollY;
9830
10202
  const savedActive = STATE.activeFile;
9831
10203
  load().then(() => {
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'