@youtyan/code-viewer 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@ Requires Node.js 20 or newer when installed from npm. Development uses
9
9
 
10
10
  - Browse repository files and folders in a persistent sidebar.
11
11
  - View git diffs with unified or split layout, lazy loading, and viewed-file state.
12
+ - Browse commit history per branch and open any commit's changed files and diff, with shareable `/history?commit=<sha>` links.
12
13
  - Open files directly from the repository or diff view, including large generated files.
13
14
  - Preview Markdown with a table of contents, task lists, Mermaid diagrams, and Shiki code highlighting.
14
15
  - Preview browser-safe media and show metadata for binary files that cannot be rendered.
@@ -633,6 +633,127 @@ function refCommits(cwd, query = "", max = DEFAULT_REF_COMMIT_LIMIT) {
633
633
  ]);
634
634
  return mergeCommitResults(limit, hashMatches, subjectMatches, authorMatches);
635
635
  }
636
+ function parseRemoteWebUrl(remote) {
637
+ const raw = (remote || "").trim();
638
+ if (!raw)
639
+ return null;
640
+ const sshShorthand = /^[\w.-]+@([\w.-]+):(.+?)(?:\.git)?\/?$/.exec(raw);
641
+ if (sshShorthand)
642
+ return `https://${sshShorthand[1]}/${sshShorthand[2]}`;
643
+ const sshUrl = /^ssh:\/\/(?:[\w.-]+@)?([\w.-]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/.exec(raw);
644
+ if (sshUrl)
645
+ return `https://${sshUrl[1]}/${sshUrl[2]}`;
646
+ const httpUrl = /^https?:\/\/([\w.-]+)\/(.+?)(?:\.git)?\/?$/.exec(raw);
647
+ if (httpUrl)
648
+ return `https://${httpUrl[1]}/${httpUrl[2]}`;
649
+ return null;
650
+ }
651
+ function remoteWebUrl(cwd) {
652
+ const res = run(["git", "remote", "get-url", "origin"], cwd);
653
+ if (res.code !== 0)
654
+ return null;
655
+ return parseRemoteWebUrl(res.stdout.trim());
656
+ }
657
+ function parseHistoryLog(stdout) {
658
+ const parts = stdout.split("\x00");
659
+ const commits = [];
660
+ for (let index = 0;index < parts.length; ) {
661
+ if (!parts[index]) {
662
+ index++;
663
+ continue;
664
+ }
665
+ const sha = parts[index++] || "";
666
+ const subject = parts[index++] || "";
667
+ const author = parts[index++] || "";
668
+ const when = parts[index++] || "";
669
+ const parentsRaw = (parts[index++] || "").trim();
670
+ const body = (parts[index++] || "").trim();
671
+ if (sha)
672
+ commits.push({
673
+ sha,
674
+ subject,
675
+ author,
676
+ when,
677
+ parents: parentsRaw ? parentsRaw.split(/\s+/) : [],
678
+ body
679
+ });
680
+ }
681
+ return commits;
682
+ }
683
+ function historyQueryArgs(query) {
684
+ const trimmed = query.trim().slice(0, 200).replace(/\0/g, "");
685
+ if (!trimmed)
686
+ return { filterArgs: [], pathspec: [], shaTerm: "" };
687
+ const prefixed = /^(author|path):(.*)$/.exec(trimmed);
688
+ if (prefixed) {
689
+ const term = prefixed[2].trim();
690
+ if (!term)
691
+ return { filterArgs: [], pathspec: [], shaTerm: "" };
692
+ if (prefixed[1] === "author") {
693
+ return {
694
+ filterArgs: [
695
+ "--regexp-ignore-case",
696
+ "--fixed-strings",
697
+ `--author=${term}`
698
+ ],
699
+ pathspec: [],
700
+ shaTerm: ""
701
+ };
702
+ }
703
+ return {
704
+ filterArgs: [],
705
+ pathspec: ["--", `:(icase)*${term}*`],
706
+ shaTerm: ""
707
+ };
708
+ }
709
+ return {
710
+ filterArgs: [
711
+ "--regexp-ignore-case",
712
+ "--fixed-strings",
713
+ `--grep=${trimmed}`
714
+ ],
715
+ pathspec: [],
716
+ shaTerm: /^[0-9a-f]{4,40}$/i.test(trimmed) ? trimmed : ""
717
+ };
718
+ }
719
+ function commitHistory(cwd, options) {
720
+ const ref = (options.ref || "HEAD").trim();
721
+ if (!ref || ref.startsWith("-") || ref.includes("\x00"))
722
+ return { commits: [], hasMore: false, error: "invalid ref" };
723
+ const verified = run(["git", "rev-parse", "--verify", `${ref}^{commit}`], cwd);
724
+ if (verified.code !== 0)
725
+ return { commits: [], hasMore: false, error: "unknown ref" };
726
+ const skip = Math.max(0, Math.floor(options.skip) || 0);
727
+ const limit = Math.max(1, Math.min(Math.floor(options.limit) || 1, MAX_HISTORY_LIMIT));
728
+ const { filterArgs, pathspec, shaTerm } = historyQueryArgs(options.query || "");
729
+ const res = run([
730
+ "git",
731
+ "log",
732
+ "-z",
733
+ `--skip=${skip}`,
734
+ `--max-count=${limit + 1}`,
735
+ `--format=${HISTORY_FORMAT}`,
736
+ ...filterArgs,
737
+ verified.stdout.trim(),
738
+ ...pathspec
739
+ ], cwd);
740
+ if (res.code !== 0)
741
+ return { commits: [], hasMore: false, error: "git log failed" };
742
+ let parsed = parseHistoryLog(res.stdout);
743
+ if (shaTerm && skip === 0) {
744
+ const bySha = run(["git", "rev-parse", "--verify", `${shaTerm}^{commit}`], cwd);
745
+ const sha = bySha.code === 0 ? bySha.stdout.trim() : "";
746
+ if (sha) {
747
+ const single = run(["git", "log", "-z", "-1", `--format=${HISTORY_FORMAT}`, sha], cwd);
748
+ if (single.code === 0) {
749
+ const hit = parseHistoryLog(single.stdout);
750
+ parsed = [...hit, ...parsed.filter((c) => c.sha !== sha)];
751
+ }
752
+ }
753
+ }
754
+ const hasMore = parsed.length > limit;
755
+ return { commits: hasMore ? parsed.slice(0, limit) : parsed, hasMore };
756
+ }
636
757
  function nameStatus(args, cwd) {
637
758
  const res = run([
638
759
  "git",
@@ -1051,7 +1172,7 @@ function truncateToNHunks(diffText, n, maxLines = Number.POSITIVE_INFINITY) {
1051
1172
  lineTruncated
1052
1173
  };
1053
1174
  }
1054
- var WORKTREE_RECURSIVE_DEPTH_LIMIT = 32, WORKTREE_RECURSIVE_ENTRY_LIMIT = 50000, DEFAULT_REF_COMMIT_LIMIT = 100, MAX_REF_COMMIT_LIMIT = 500, COMMIT_FORMAT = "%H%x00%s%x00%an%x00%aI", DEFAULT_WORKTREE_OMIT_DIR_NAMES;
1175
+ var WORKTREE_RECURSIVE_DEPTH_LIMIT = 32, WORKTREE_RECURSIVE_ENTRY_LIMIT = 50000, DEFAULT_REF_COMMIT_LIMIT = 100, MAX_REF_COMMIT_LIMIT = 500, COMMIT_FORMAT = "%H%x00%s%x00%an%x00%aI", DEFAULT_WORKTREE_OMIT_DIR_NAMES, HISTORY_FORMAT = "%H%x00%s%x00%an%x00%aI%x00%P%x00%b", MAX_HISTORY_LIMIT = 200;
1055
1176
  var init_git = __esm(() => {
1056
1177
  init_runtime();
1057
1178
  DEFAULT_WORKTREE_OMIT_DIR_NAMES = [
@@ -1814,7 +1935,13 @@ function normalizeNewDirectoryName(name) {
1814
1935
  // web-src/core/routes.ts
1815
1936
  var SPA_PATHS, APP_ENTRY_PATHS;
1816
1937
  var init_routes = __esm(() => {
1817
- SPA_PATHS = ["/todif", "/todiff", "/file", "/help"];
1938
+ SPA_PATHS = [
1939
+ "/todif",
1940
+ "/todiff",
1941
+ "/file",
1942
+ "/help",
1943
+ "/history"
1944
+ ];
1818
1945
  APP_ENTRY_PATHS = ["/", "/index.html"];
1819
1946
  });
1820
1947
 
@@ -2998,6 +3125,7 @@ function handleTree(url) {
2998
3125
  function handleSettings() {
2999
3126
  return json({
3000
3127
  project: basename2(cwd),
3128
+ repo_web_url: remoteWebUrl(cwd),
3001
3129
  scope: {
3002
3130
  omit_dirs_effective: scopeOmitDirNames,
3003
3131
  omit_dirs_built_in: DEFAULT_WORKTREE_OMIT_DIR_NAMES,
@@ -3159,6 +3287,20 @@ function handleRefCommits(url) {
3159
3287
  const max = Number.isFinite(parsedMax) && parsedMax > 0 ? parsedMax : undefined;
3160
3288
  return json({ commits: refCommits(cwd, query, max) });
3161
3289
  }
3290
+ function handleLog(url) {
3291
+ const ref = url.searchParams.get("ref") || "HEAD";
3292
+ const skip = Number(url.searchParams.get("skip") || "0");
3293
+ const limit = Number(url.searchParams.get("limit") || "50");
3294
+ const result = commitHistory(cwd, {
3295
+ ref,
3296
+ skip: Number.isFinite(skip) ? skip : 0,
3297
+ limit: Number.isFinite(limit) ? limit : 50,
3298
+ query: url.searchParams.get("q") || ""
3299
+ });
3300
+ if (result.error)
3301
+ return text(result.error, 400);
3302
+ return json({ commits: result.commits, hasMore: result.hasMore });
3303
+ }
3162
3304
  function handleFileDiff(url) {
3163
3305
  const path = url.searchParams.get("path") || "";
3164
3306
  if (!safePath(path))
@@ -4201,6 +4343,8 @@ var init_preview = __esm(async () => {
4201
4343
  return handleGrep(url);
4202
4344
  if (url.pathname === "/_commits")
4203
4345
  return handleRefCommits(url);
4346
+ if (url.pathname === "/_log")
4347
+ return handleLog(url);
4204
4348
  if (url.pathname === "/file_diff")
4205
4349
  return handleFileDiff(url);
4206
4350
  if (url.pathname === "/file_range")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youtyan/code-viewer",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "description": "Local browser-based code and git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {
package/web/app.js CHANGED
@@ -438,6 +438,15 @@
438
438
  lang: params.get("lang") || "en",
439
439
  section: params.get("section") || "keybindings"
440
440
  };
441
+ case "/history": {
442
+ const commit = params.get("commit") || "";
443
+ return {
444
+ screen: "history",
445
+ ref: params.get("ref") || "HEAD",
446
+ ...commit ? { commit } : {},
447
+ range
448
+ };
449
+ }
441
450
  default:
442
451
  return {
443
452
  screen: "unknown",
@@ -475,6 +484,15 @@
475
484
  const qs = params.toString();
476
485
  return `/help${qs ? `?${qs}` : ""}`;
477
486
  }
487
+ case "history": {
488
+ const params = new URLSearchParams;
489
+ if (route.ref && route.ref !== "HEAD")
490
+ params.set("ref", route.ref);
491
+ if (route.commit)
492
+ params.set("commit", route.commit);
493
+ const qs = params.toString();
494
+ return `/history${qs ? `?${qs}` : ""}`;
495
+ }
478
496
  case "unknown":
479
497
  return "/todif?from=" + encodeURIComponent(route.range.from || "") + "&to=" + encodeURIComponent(route.range.to || "worktree");
480
498
  default:
@@ -7680,6 +7698,114 @@ ${frontmatter.yaml}
7680
7698
  };
7681
7699
  }
7682
7700
 
7701
+ // web-src/views/diff-line-select.ts
7702
+ var SELECTED_CLASS = "gdp-diff-line-selected";
7703
+ function cardPath(el) {
7704
+ return el.closest(".gdp-file-shell[data-path]")?.dataset.path || "";
7705
+ }
7706
+ function afterLineFromCell(cell) {
7707
+ const sideCell = cell.closest("td.d2h-code-side-linenumber");
7708
+ if (sideCell) {
7709
+ const side = sideCell.closest(".d2h-file-side-diff");
7710
+ const wrapper = sideCell.closest(".d2h-file-wrapper");
7711
+ if (!side || !wrapper)
7712
+ return null;
7713
+ const sides = wrapper.querySelectorAll(".d2h-file-side-diff");
7714
+ if (sides.length < 2 || side !== sides[1])
7715
+ return null;
7716
+ const line2 = Number((sideCell.textContent || "").trim());
7717
+ return Number.isInteger(line2) && line2 > 0 ? line2 : null;
7718
+ }
7719
+ const numCell = cell.closest("td.d2h-code-linenumber");
7720
+ if (!numCell)
7721
+ return null;
7722
+ const raw = (numCell.querySelector(".line-num2")?.textContent || "").trim();
7723
+ const line = Number(raw);
7724
+ return Number.isInteger(line) && line > 0 ? line : null;
7725
+ }
7726
+ function rowsWithAfterLines(card) {
7727
+ const out = [];
7728
+ card.querySelectorAll("table.d2h-diff-table tr").forEach((row) => {
7729
+ const cell = row.querySelector("td.d2h-code-linenumber, td.d2h-code-side-linenumber");
7730
+ if (!cell)
7731
+ return;
7732
+ const line = afterLineFromCell(cell);
7733
+ if (line !== null)
7734
+ out.push({ row, line });
7735
+ });
7736
+ return out;
7737
+ }
7738
+ function createDiffLineSelect(deps) {
7739
+ let drag = null;
7740
+ let selection = null;
7741
+ function clearHighlights() {
7742
+ document.querySelectorAll(`.${SELECTED_CLASS}`).forEach((row) => {
7743
+ row.classList.remove(SELECTED_CLASS);
7744
+ });
7745
+ }
7746
+ function applySelection(next) {
7747
+ selection = next;
7748
+ clearHighlights();
7749
+ if (!next) {
7750
+ deps.pill.hide();
7751
+ return;
7752
+ }
7753
+ const start = Math.min(next.start, next.end);
7754
+ const end = Math.max(next.start, next.end);
7755
+ const card = document.querySelector(`.gdp-file-shell[data-path="${CSS.escape(next.path)}"]`);
7756
+ if (card) {
7757
+ for (const item of rowsWithAfterLines(card)) {
7758
+ if (item.line >= start && item.line <= end)
7759
+ item.row.classList.add(SELECTED_CLASS);
7760
+ }
7761
+ }
7762
+ deps.pill.show(next.path, start, end);
7763
+ }
7764
+ function clear() {
7765
+ drag = null;
7766
+ applySelection(null);
7767
+ }
7768
+ const diff = document.querySelector("#diff");
7769
+ if (!diff)
7770
+ return { clear };
7771
+ diff.addEventListener("mousedown", (e2) => {
7772
+ const target = e2.target;
7773
+ const cell = target.closest("td.d2h-code-linenumber, td.d2h-code-side-linenumber");
7774
+ if (!cell)
7775
+ return;
7776
+ const line = afterLineFromCell(cell);
7777
+ const path = cardPath(cell);
7778
+ if (line === null || !path) {
7779
+ if (selection)
7780
+ clear();
7781
+ return;
7782
+ }
7783
+ e2.preventDefault();
7784
+ drag = { path, start: line };
7785
+ applySelection({ path, start: line, end: line });
7786
+ });
7787
+ diff.addEventListener("mouseover", (e2) => {
7788
+ if (!drag)
7789
+ return;
7790
+ const target = e2.target;
7791
+ const cell = target.closest("td.d2h-code-linenumber, td.d2h-code-side-linenumber");
7792
+ if (!cell || cardPath(cell) !== drag.path)
7793
+ return;
7794
+ const line = afterLineFromCell(cell);
7795
+ if (line === null)
7796
+ return;
7797
+ applySelection({ path: drag.path, start: drag.start, end: line });
7798
+ });
7799
+ document.addEventListener("mouseup", () => {
7800
+ drag = null;
7801
+ });
7802
+ document.addEventListener("keydown", (e2) => {
7803
+ if (e2.key === "Escape" && selection && !drag)
7804
+ clear();
7805
+ });
7806
+ return { clear };
7807
+ }
7808
+
7683
7809
  // web-src/core/file-path-copy.ts
7684
7810
  function filePathClipboardText(path) {
7685
7811
  return path || "";
@@ -7690,6 +7816,13 @@ ${frontmatter.yaml}
7690
7816
  const parts = path.split("/").filter(Boolean);
7691
7817
  return parts[parts.length - 1] || "";
7692
7818
  }
7819
+ function fileReferenceClipboardText(path, start, end) {
7820
+ if (!path)
7821
+ return "";
7822
+ const a2 = Math.max(1, Math.floor(Math.min(start, end)));
7823
+ const b2 = Math.max(1, Math.floor(Math.max(start, end)));
7824
+ return a2 === b2 ? `@${path}#${a2}` : `@${path}#${a2}-${b2}`;
7825
+ }
7693
7826
 
7694
7827
  // web-src/core/ws-highlight.ts
7695
7828
  function isWhitespaceOnlyInlineHighlight(text2) {
@@ -9013,6 +9146,356 @@ ${frontmatter.yaml}
9013
9146
  return { renderHelpPage };
9014
9147
  }
9015
9148
 
9149
+ // web-src/core/history.ts
9150
+ var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
9151
+ var HISTORY_PAGE_SIZE = 50;
9152
+ var HISTORY_AUTO_LOAD_MAX_PAGES = 20;
9153
+ function commitDiffRange(commit) {
9154
+ return { from: commit.parents[0] || EMPTY_TREE_SHA, to: commit.sha };
9155
+ }
9156
+ function shouldContinueAutoLoad(state) {
9157
+ if (state.found)
9158
+ return false;
9159
+ if (!state.hasMore)
9160
+ return false;
9161
+ return state.pagesLoaded < HISTORY_AUTO_LOAD_MAX_PAGES;
9162
+ }
9163
+ var MONTH_NAMES = [
9164
+ "January",
9165
+ "February",
9166
+ "March",
9167
+ "April",
9168
+ "May",
9169
+ "June",
9170
+ "July",
9171
+ "August",
9172
+ "September",
9173
+ "October",
9174
+ "November",
9175
+ "December"
9176
+ ];
9177
+ var DAY_MS = 24 * 60 * 60 * 1000;
9178
+ function historyGroupLabel(whenIso, now) {
9179
+ const t2 = Date.parse(whenIso);
9180
+ if (!Number.isFinite(t2))
9181
+ return "Unknown date";
9182
+ const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
9183
+ if (t2 >= dayStart)
9184
+ return "Today";
9185
+ if (t2 >= dayStart - DAY_MS)
9186
+ return "Yesterday";
9187
+ if (t2 >= dayStart - 6 * DAY_MS)
9188
+ return "This week";
9189
+ const d2 = new Date(t2);
9190
+ if (d2.getFullYear() === now.getFullYear() && d2.getMonth() === now.getMonth())
9191
+ return "This month";
9192
+ return `${MONTH_NAMES[d2.getMonth()]} ${d2.getFullYear()}`;
9193
+ }
9194
+
9195
+ // web-src/views/history-view.ts
9196
+ function createHistoryView(deps) {
9197
+ const panel = deps.$("#history-panel");
9198
+ const list2 = deps.$("#history-list");
9199
+ const banner = deps.$("#history-banner");
9200
+ const statusEl = deps.$("#history-status");
9201
+ const sentinel = deps.$("#history-sentinel");
9202
+ let ref = "HEAD";
9203
+ let commits = [];
9204
+ let hasMore = false;
9205
+ let loading = false;
9206
+ let generation = 0;
9207
+ let selectedSha = "";
9208
+ let query = "";
9209
+ function setBanner(message) {
9210
+ banner.textContent = message;
9211
+ banner.hidden = !message;
9212
+ }
9213
+ function setStatusText(message) {
9214
+ statusEl.textContent = message;
9215
+ statusEl.hidden = !message;
9216
+ }
9217
+ function relativeWhen(iso) {
9218
+ const t2 = Date.parse(iso);
9219
+ if (!Number.isFinite(t2))
9220
+ return iso;
9221
+ const sec = Math.round((Date.now() - t2) / 1000);
9222
+ if (sec < 60)
9223
+ return "just now";
9224
+ const min = Math.round(sec / 60);
9225
+ if (min < 60)
9226
+ return `${min}m ago`;
9227
+ const hour = Math.round(min / 60);
9228
+ if (hour < 24)
9229
+ return `${hour}h ago`;
9230
+ const day = Math.round(hour / 24);
9231
+ if (day < 30)
9232
+ return `${day}d ago`;
9233
+ return iso.slice(0, 10);
9234
+ }
9235
+ function fetchPage(skip) {
9236
+ const url = `/_log?ref=${encodeURIComponent(ref)}&skip=${skip}&limit=${HISTORY_PAGE_SIZE}` + (query ? `&q=${encodeURIComponent(query)}` : "");
9237
+ return deps.trackLoad(fetch(url).then(async (r2) => {
9238
+ if (!r2.ok)
9239
+ throw new Error(await r2.text());
9240
+ return await r2.json();
9241
+ })).catch((err) => {
9242
+ setBanner(err instanceof Error ? err.message : "failed to load log");
9243
+ return null;
9244
+ });
9245
+ }
9246
+ function commitRow(commit) {
9247
+ const active = commit.sha === selectedSha ? " active" : "";
9248
+ return `<li class="history-item${active}" data-sha="${deps.escapeHtml(commit.sha)}">` + `<span class="subject" title="${deps.escapeHtml(commit.subject)}">${deps.escapeHtml(commit.subject)}</span>` + `<span class="meta2">` + `<span class="sha">${deps.escapeHtml(commit.sha.slice(0, 7))}</span>` + `<span class="author">${deps.escapeHtml(commit.author)}</span>` + `<span class="when">${deps.escapeHtml(relativeWhen(commit.when))}</span>` + `</span>` + `</li>`;
9249
+ }
9250
+ function renderList() {
9251
+ const now = new Date;
9252
+ const html = [];
9253
+ let lastGroup = "";
9254
+ for (const commit of commits) {
9255
+ const group = historyGroupLabel(commit.when, now);
9256
+ if (group !== lastGroup) {
9257
+ html.push(`<li class="history-group" aria-hidden="true">${deps.escapeHtml(group)}</li>`);
9258
+ lastGroup = group;
9259
+ }
9260
+ html.push(commitRow(commit));
9261
+ }
9262
+ list2.innerHTML = html.join("");
9263
+ setStatusText(loading ? "loading..." : commits.length ? "" : "no commits");
9264
+ }
9265
+ function updateCommitInfo(commit) {
9266
+ const info = document.querySelector("#history-commit-info");
9267
+ if (!info)
9268
+ return;
9269
+ if (!commit) {
9270
+ info.hidden = true;
9271
+ return;
9272
+ }
9273
+ const set2 = (sel, text2) => {
9274
+ const el = info.querySelector(sel);
9275
+ if (el)
9276
+ el.textContent = text2;
9277
+ };
9278
+ set2(".hci-sha", commit.sha);
9279
+ set2(".hci-author", commit.author);
9280
+ const t2 = Date.parse(commit.when);
9281
+ set2(".hci-date", Number.isFinite(t2) ? new Date(t2).toLocaleString() : commit.when);
9282
+ set2(".hci-subject", commit.subject);
9283
+ const body = info.querySelector(".hci-body");
9284
+ if (body) {
9285
+ body.textContent = commit.body;
9286
+ body.hidden = !commit.body;
9287
+ }
9288
+ info.hidden = false;
9289
+ }
9290
+ function updateActiveRow() {
9291
+ list2.querySelectorAll(".history-item").forEach((row) => {
9292
+ row.classList.toggle("active", row.dataset.sha === selectedSha);
9293
+ });
9294
+ }
9295
+ let inFlight = null;
9296
+ function loadNextPage() {
9297
+ if (inFlight)
9298
+ return inFlight;
9299
+ const started = doLoadNextPage().finally(() => {
9300
+ if (inFlight === started)
9301
+ inFlight = null;
9302
+ });
9303
+ inFlight = started;
9304
+ return started;
9305
+ }
9306
+ async function doLoadNextPage() {
9307
+ loading = true;
9308
+ const gen = generation;
9309
+ setStatusText("loading...");
9310
+ const page = await fetchPage(commits.length);
9311
+ if (gen !== generation)
9312
+ return false;
9313
+ loading = false;
9314
+ if (!page) {
9315
+ setStatusText("");
9316
+ return false;
9317
+ }
9318
+ commits = commits.concat(page.commits);
9319
+ hasMore = page.hasMore;
9320
+ renderList();
9321
+ return page.commits.length > 0;
9322
+ }
9323
+ async function selectCommit(commit, options = {}) {
9324
+ const gen = generation;
9325
+ selectedSha = commit.sha;
9326
+ updateActiveRow();
9327
+ updateCommitInfo(commit);
9328
+ if (gen !== generation)
9329
+ return;
9330
+ if (options.updateUrl !== false) {
9331
+ const range = commitDiffRange(commit);
9332
+ deps.setRoute({ screen: "history", ref, commit: commit.sha, range }, true);
9333
+ }
9334
+ if (gen !== generation)
9335
+ return;
9336
+ await deps.applyCommitRange(commitDiffRange(commit));
9337
+ if (gen !== generation)
9338
+ return;
9339
+ }
9340
+ let lookupFailed = false;
9341
+ async function fetchSingleCommit(sha) {
9342
+ const url = `/_log?ref=${encodeURIComponent(sha)}&skip=0&limit=1`;
9343
+ lookupFailed = false;
9344
+ try {
9345
+ const res = await deps.trackLoad(fetch(url).then(async (r2) => {
9346
+ if (r2.status === 400)
9347
+ return null;
9348
+ if (!r2.ok)
9349
+ throw new Error(await r2.text());
9350
+ return await r2.json();
9351
+ }));
9352
+ return res?.commits[0] || null;
9353
+ } catch {
9354
+ lookupFailed = true;
9355
+ return null;
9356
+ }
9357
+ }
9358
+ async function resolveDeepLink(sha) {
9359
+ const gen = generation;
9360
+ let pagesLoaded = 0;
9361
+ if (commits.length === 0) {
9362
+ await loadNextPage();
9363
+ if (gen !== generation)
9364
+ return;
9365
+ pagesLoaded = 1;
9366
+ } else {
9367
+ pagesLoaded = 1;
9368
+ }
9369
+ for (;; ) {
9370
+ const found = commits.find((c2) => c2.sha.startsWith(sha));
9371
+ if (found) {
9372
+ await selectCommit(found, { updateUrl: false });
9373
+ scrollToSelected();
9374
+ return;
9375
+ }
9376
+ if (!shouldContinueAutoLoad({ pagesLoaded, found: false, hasMore }))
9377
+ break;
9378
+ const got = await loadNextPage();
9379
+ if (gen !== generation)
9380
+ return;
9381
+ pagesLoaded++;
9382
+ if (!got && !hasMore)
9383
+ break;
9384
+ }
9385
+ const single = await fetchSingleCommit(sha);
9386
+ if (gen !== generation)
9387
+ return;
9388
+ if (!single) {
9389
+ setBanner(lookupFailed ? `failed to load commit: ${sha}` : `commit not found: ${sha}`);
9390
+ updateCommitInfo(null);
9391
+ deps.showEmptyDiffPane();
9392
+ return;
9393
+ }
9394
+ setBanner(`showing commit outside the loaded ${ref} log`);
9395
+ commits = [single, ...commits];
9396
+ renderList();
9397
+ await selectCommit(single, { updateUrl: false });
9398
+ scrollToSelected();
9399
+ }
9400
+ function scrollToSelected() {
9401
+ list2.querySelector(`.history-item[data-sha="${CSS.escape(selectedSha)}"]`)?.scrollIntoView({ block: "center" });
9402
+ }
9403
+ let entering = Promise.resolve();
9404
+ function enterHistory(force) {
9405
+ entering = entering.then(() => doEnterHistory(force === true)).catch(() => {});
9406
+ return entering;
9407
+ }
9408
+ async function doEnterHistory(force = false) {
9409
+ const route = deps.getRoute();
9410
+ if (route.screen !== "history")
9411
+ return;
9412
+ const nextRef = route.ref || "HEAD";
9413
+ const refChanged = nextRef !== ref;
9414
+ if (refChanged || force || commits.length === 0) {
9415
+ generation++;
9416
+ ref = nextRef;
9417
+ commits = [];
9418
+ hasMore = false;
9419
+ loading = false;
9420
+ inFlight = null;
9421
+ selectedSha = "";
9422
+ setBanner("");
9423
+ updateCommitInfo(null);
9424
+ renderList();
9425
+ await loadNextPage();
9426
+ }
9427
+ const route2 = deps.getRoute();
9428
+ if (route2.screen !== "history")
9429
+ return;
9430
+ if (route2.commit) {
9431
+ await resolveDeepLink(route2.commit);
9432
+ } else {
9433
+ selectedSha = "";
9434
+ updateActiveRow();
9435
+ updateCommitInfo(null);
9436
+ deps.showEmptyDiffPane();
9437
+ }
9438
+ }
9439
+ function onRefPicked(nextRef) {
9440
+ const value = nextRef && nextRef !== "worktree" ? nextRef : "HEAD";
9441
+ deps.setRoute({
9442
+ screen: "history",
9443
+ ref: value,
9444
+ range: { from: "HEAD", to: "worktree" }
9445
+ }, false);
9446
+ enterHistory(true);
9447
+ }
9448
+ list2.addEventListener("click", (e2) => {
9449
+ const row = e2.target.closest(".history-item");
9450
+ if (!row?.dataset.sha)
9451
+ return;
9452
+ const commit = commits.find((c2) => c2.sha === row.dataset.sha);
9453
+ if (commit)
9454
+ selectCommit(commit);
9455
+ });
9456
+ function applyFilter(next) {
9457
+ const value = next.trim();
9458
+ if (value === query)
9459
+ return;
9460
+ query = value;
9461
+ generation++;
9462
+ commits = [];
9463
+ hasMore = false;
9464
+ loading = false;
9465
+ inFlight = null;
9466
+ setBanner("");
9467
+ renderList();
9468
+ loadNextPage();
9469
+ }
9470
+ const filterInput = document.querySelector("#history-filter");
9471
+ let filterTimer = null;
9472
+ filterInput?.addEventListener("input", () => {
9473
+ if (filterTimer)
9474
+ clearTimeout(filterTimer);
9475
+ filterTimer = setTimeout(() => {
9476
+ filterTimer = null;
9477
+ applyFilter(filterInput.value);
9478
+ }, 250);
9479
+ });
9480
+ filterInput?.addEventListener("keydown", (e2) => {
9481
+ if (e2.key === "Escape" && filterInput.value) {
9482
+ filterInput.value = "";
9483
+ applyFilter("");
9484
+ e2.stopPropagation();
9485
+ }
9486
+ });
9487
+ const observer = new IntersectionObserver((entries) => {
9488
+ if (!entries.some((entry) => entry.isIntersecting))
9489
+ return;
9490
+ if (deps.getRoute().screen !== "history")
9491
+ return;
9492
+ if (hasMore && !loading)
9493
+ loadNextPage();
9494
+ }, { root: panel, rootMargin: "200px" });
9495
+ observer.observe(sentinel);
9496
+ return { enterHistory, onRefPicked };
9497
+ }
9498
+
9016
9499
  // web-src/views/hunk-expand.ts
9017
9500
  function createHunkExpand(deps) {
9018
9501
  function parseHunkHeader(text2) {
@@ -9401,6 +9884,76 @@ ${frontmatter.yaml}
9401
9884
  return { setupHunkExpand };
9402
9885
  }
9403
9886
 
9887
+ // web-src/views/line-ref-pill.ts
9888
+ var COPY_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="currentColor">' + '<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>' + '<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>' + "</svg>";
9889
+ var CHECK_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="currentColor">' + '<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>' + "</svg>";
9890
+ function escapeHtml2(value) {
9891
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
9892
+ }
9893
+ function createLineRefPill() {
9894
+ const pill = document.createElement("button");
9895
+ pill.id = "line-ref-pill";
9896
+ pill.type = "button";
9897
+ pill.title = "選択行の参照をコピー(Claude Code / Codex に貼り付け用)";
9898
+ pill.hidden = true;
9899
+ document.body.appendChild(pill);
9900
+ let refText = "";
9901
+ let feedbackTimer = null;
9902
+ function render(state) {
9903
+ pill.classList.toggle("copied", state === "copied");
9904
+ if (state === "copied") {
9905
+ pill.innerHTML = `${CHECK_ICON}<span class="lrp-label">Copied!</span>`;
9906
+ return;
9907
+ }
9908
+ if (state === "failed") {
9909
+ pill.innerHTML = `${COPY_ICON}<span class="lrp-label">copy failed</span>`;
9910
+ return;
9911
+ }
9912
+ pill.innerHTML = `${COPY_ICON}<span class="lrp-label">Copy</span>` + `<span class="lrp-ref">${escapeHtml2(refText)}</span>`;
9913
+ }
9914
+ pill.addEventListener("click", async () => {
9915
+ if (!refText)
9916
+ return;
9917
+ try {
9918
+ await navigator.clipboard.writeText(refText);
9919
+ render("copied");
9920
+ } catch {
9921
+ render("failed");
9922
+ }
9923
+ if (feedbackTimer)
9924
+ clearTimeout(feedbackTimer);
9925
+ feedbackTimer = setTimeout(() => {
9926
+ feedbackTimer = null;
9927
+ if (!pill.hidden)
9928
+ render("ready");
9929
+ }, 1200);
9930
+ });
9931
+ return {
9932
+ show(path, start, end) {
9933
+ const next = fileReferenceClipboardText(path, start, end);
9934
+ if (!next)
9935
+ return;
9936
+ const changed = next !== refText;
9937
+ refText = next;
9938
+ if (feedbackTimer) {
9939
+ clearTimeout(feedbackTimer);
9940
+ feedbackTimer = null;
9941
+ }
9942
+ render("ready");
9943
+ if (pill.hidden || changed) {
9944
+ pill.classList.remove("pop");
9945
+ pill.offsetWidth;
9946
+ pill.classList.add("pop");
9947
+ }
9948
+ pill.hidden = false;
9949
+ },
9950
+ hide() {
9951
+ refText = "";
9952
+ pill.hidden = true;
9953
+ }
9954
+ };
9955
+ }
9956
+
9404
9957
  // web-src/views/ref-picker.ts
9405
9958
  function createRefPicker(deps) {
9406
9959
  function wireRefSelectorInput(input, onPick) {
@@ -9631,7 +10184,7 @@ ${frontmatter.yaml}
9631
10184
  const target = e2.target;
9632
10185
  if (popover.contains(target))
9633
10186
  return;
9634
- if (target.id === "ref-from" || target.id === "ref-to" || target.id === "repo-ref" || target.id === "repo-target")
10187
+ if (target.id === "ref-from" || target.id === "ref-to" || target.id === "repo-ref" || target.id === "repo-target" || target.id === "history-ref")
9635
10188
  return;
9636
10189
  closePopover();
9637
10190
  });
@@ -14758,6 +15311,11 @@ ${frontmatter.yaml}
14758
15311
  return null;
14759
15312
  const settings = await res.json();
14760
15313
  setProjectName(settings.project || "");
15314
+ const repoLink = document.querySelector("#repo-web-link");
15315
+ if (repoLink && settings.repo_web_url) {
15316
+ repoLink.href = settings.repo_web_url;
15317
+ repoLink.hidden = false;
15318
+ }
14761
15319
  SERVER_SCOPE_OMIT_DIRS_DEFAULT = normalizeScopeOmitDirs(settings.scope.omit_dirs_effective);
14762
15320
  SERVER_SCOPE_EXCLUDE_NAMES_DEFAULT = normalizeScopeExcludeNames(settings.scope.exclude_names_effective);
14763
15321
  return settings;
@@ -14796,6 +15354,19 @@ ${frontmatter.yaml}
14796
15354
  let highlightConfigured = false;
14797
15355
  let PROJECT_NAME = "";
14798
15356
  let REPO_SIDEBAR_REF = null;
15357
+ const LINE_REF_PILL = createLineRefPill();
15358
+ const DIFF_LINE_SELECT = createDiffLineSelect({ pill: LINE_REF_PILL });
15359
+ function syncLineRefPill() {
15360
+ const route = STATE.route;
15361
+ if (route.screen === "diff")
15362
+ return;
15363
+ DIFF_LINE_SELECT.clear();
15364
+ if (route.screen === "file" && route.line) {
15365
+ const start = typeof route.line === "number" ? route.line : route.line.start;
15366
+ const end = typeof route.line === "number" ? route.line : route.line.end;
15367
+ LINE_REF_PILL.show(route.path, start, end);
15368
+ }
15369
+ }
14799
15370
  const SIDEBAR = createSidebar({
14800
15371
  $,
14801
15372
  $$,
@@ -15185,7 +15756,7 @@ ${frontmatter.yaml}
15185
15756
  throw e2;
15186
15757
  });
15187
15758
  }
15188
- function escapeHtml2(s2) {
15759
+ function escapeHtml3(s2) {
15189
15760
  return String(s2 == null ? "" : s2).replace(/[&<>"']/g, (c2) => ({
15190
15761
  "&": "&amp;",
15191
15762
  "<": "&lt;",
@@ -15203,6 +15774,19 @@ ${frontmatter.yaml}
15203
15774
  to: STATE.to || DEFAULT_RANGE.to
15204
15775
  };
15205
15776
  }
15777
+ let preHistoryRange = null;
15778
+ function parkRangeForHistory() {
15779
+ if (preHistoryRange === null)
15780
+ preHistoryRange = { from: STATE.from, to: STATE.to };
15781
+ }
15782
+ function restoreRangeAfterHistory() {
15783
+ if (!preHistoryRange)
15784
+ return;
15785
+ STATE.from = preHistoryRange.from;
15786
+ STATE.to = preHistoryRange.to;
15787
+ preHistoryRange = null;
15788
+ syncRefInputs();
15789
+ }
15206
15790
  function repoFileTargetFromRoute() {
15207
15791
  return STATE.route.screen === "file" && STATE.route.view === "blob" ? STATE.route.ref : null;
15208
15792
  }
@@ -15233,12 +15817,22 @@ ${frontmatter.yaml}
15233
15817
  else
15234
15818
  history.pushState(state, "", url);
15235
15819
  syncHeaderMenu();
15820
+ syncLineRefPill();
15236
15821
  }
15237
15822
  function setPageMode() {
15238
15823
  document.body.classList.toggle("gdp-file-detail-page", STATE.route.screen === "file");
15239
15824
  document.body.classList.toggle("gdp-repo-blob-page", STATE.route.screen === "file" && STATE.route.view === "blob");
15240
15825
  document.body.classList.toggle("gdp-repo-page", STATE.route.screen === "repo");
15241
15826
  document.body.classList.toggle("gdp-help-page", STATE.route.screen === "help");
15827
+ document.body.classList.toggle("gdp-history-page", STATE.route.screen === "history");
15828
+ const historyPanel = $("#history-panel");
15829
+ if (historyPanel)
15830
+ historyPanel.hidden = STATE.route.screen !== "history";
15831
+ if (STATE.route.screen === "history") {
15832
+ const historyRefInput = $("#history-ref");
15833
+ if (historyRefInput)
15834
+ historyRefInput.value = STATE.route.ref || "HEAD";
15835
+ }
15242
15836
  syncRepoTargetInput(repoFileTargetFromRoute() || "worktree");
15243
15837
  }
15244
15838
  function syncHeaderMenu() {
@@ -15256,7 +15850,17 @@ ${frontmatter.yaml}
15256
15850
  });
15257
15851
  }
15258
15852
  if (link2.dataset.route === "diff") {
15259
- link2.href = buildRoute({ screen: "diff", range: currentRange() });
15853
+ link2.href = buildRoute({
15854
+ screen: "diff",
15855
+ range: preHistoryRange ?? currentRange()
15856
+ });
15857
+ }
15858
+ if (link2.dataset.route === "history") {
15859
+ link2.href = buildRoute({
15860
+ screen: "history",
15861
+ ref: "HEAD",
15862
+ range: currentRange()
15863
+ });
15260
15864
  }
15261
15865
  if (link2.dataset.route === "help") {
15262
15866
  link2.href = buildRoute({
@@ -15379,7 +15983,7 @@ ${frontmatter.yaml}
15379
15983
  STATE,
15380
15984
  setRoute,
15381
15985
  currentRange,
15382
- escapeHtml: escapeHtml2,
15986
+ escapeHtml: escapeHtml3,
15383
15987
  trackLoad,
15384
15988
  diffCardSelector,
15385
15989
  getHljs,
@@ -15747,6 +16351,18 @@ ${frontmatter.yaml}
15747
16351
  }
15748
16352
  if (STATE.route.screen === "repo")
15749
16353
  return loadRepo();
16354
+ {
16355
+ const empty = $("#empty");
16356
+ if (empty) {
16357
+ const onHistory = STATE.route.screen === "history";
16358
+ const h2 = empty.querySelector("h2");
16359
+ if (h2)
16360
+ h2.textContent = onHistory ? "Empty diff" : "No changes";
16361
+ const p2 = empty.querySelector("p");
16362
+ if (p2)
16363
+ p2.textContent = onHistory ? "This commit has no changes against its first parent." : "The working tree is clean against this ref.";
16364
+ }
16365
+ }
15750
16366
  setStatus("refreshing");
15751
16367
  const params = new URLSearchParams;
15752
16368
  if (STATE.ignoreWs)
@@ -15772,8 +16388,13 @@ ${frontmatter.yaml}
15772
16388
  else if (STATE.route.screen === "file" && STATE.route.view === "blob") {
15773
16389
  setStatus("live");
15774
16390
  applySourceRouteToShell();
16391
+ } else if (STATE.route.screen === "history") {
16392
+ parkRangeForHistory();
16393
+ setStatus("live");
16394
+ HISTORY_VIEW.enterHistory();
15775
16395
  } else
15776
16396
  load();
16397
+ syncLineRefPill();
15777
16398
  });
15778
16399
  function syncRefInputs() {
15779
16400
  const fi = $("#ref-from"), ti = $("#ref-to");
@@ -15783,6 +16404,7 @@ ${frontmatter.yaml}
15783
16404
  ti.value = STATE.to;
15784
16405
  }
15785
16406
  function setRange(from, to) {
16407
+ preHistoryRange = null;
15786
16408
  STATE.from = from || "";
15787
16409
  STATE.to = to || "";
15788
16410
  localStorage.setItem("gdp:from", STATE.from);
@@ -15801,14 +16423,44 @@ ${frontmatter.yaml}
15801
16423
  renderHelpPage();
15802
16424
  } else {
15803
16425
  setRoute({ screen: "diff", range }, true);
16426
+ setPageMode();
15804
16427
  load();
15805
16428
  }
15806
16429
  }
15807
16430
  syncRefInputs();
15808
16431
  syncHeaderMenu();
15809
- createRefPicker({
16432
+ const HISTORY_VIEW = createHistoryView({
15810
16433
  $,
15811
- escapeHtml: escapeHtml2,
16434
+ escapeHtml: escapeHtml3,
16435
+ getRoute: () => STATE.route,
16436
+ setRoute,
16437
+ applyCommitRange: (range) => {
16438
+ STATE.from = range.from;
16439
+ STATE.to = range.to;
16440
+ syncRefInputs();
16441
+ return load();
16442
+ },
16443
+ showEmptyDiffPane: () => {
16444
+ const diff = $("#diff");
16445
+ if (diff)
16446
+ diff.innerHTML = "";
16447
+ const empty = $("#empty");
16448
+ if (empty) {
16449
+ empty.classList.remove("hidden");
16450
+ const h2 = empty.querySelector("h2");
16451
+ if (h2)
16452
+ h2.textContent = "No commit selected";
16453
+ const p2 = empty.querySelector("p");
16454
+ if (p2)
16455
+ p2.textContent = "Select a commit from the list to see its changes.";
16456
+ }
16457
+ setStatus("live");
16458
+ },
16459
+ trackLoad
16460
+ });
16461
+ const REF_PICKER = createRefPicker({
16462
+ $,
16463
+ escapeHtml: escapeHtml3,
15812
16464
  currentRange,
15813
16465
  setRange,
15814
16466
  setRoute,
@@ -15818,8 +16470,18 @@ ${frontmatter.yaml}
15818
16470
  getRepoRef: () => STATE.repoRef,
15819
16471
  getRoute: () => STATE.route
15820
16472
  });
16473
+ if (REF_PICKER) {
16474
+ const historyRefInput = document.querySelector("#history-ref");
16475
+ if (historyRefInput) {
16476
+ historyRefInput.value = "HEAD";
16477
+ REF_PICKER.wireRefSelectorInput(historyRefInput, (ref) => HISTORY_VIEW.onRefPicked(ref));
16478
+ }
16479
+ }
15821
16480
  $("#ref-reset").addEventListener("click", () => setRange("HEAD", "worktree"));
15822
16481
  function applyRouteFromLocation() {
16482
+ if (STATE.route.screen === "history" && window.location.pathname !== "/history") {
16483
+ restoreRangeAfterHistory();
16484
+ }
15823
16485
  const parsedRoute = parseRoute(window.location.pathname, window.location.search, currentRange());
15824
16486
  STATE.route = parsedRoute.screen === "unknown" ? { screen: "diff", range: parsedRoute.range } : parsedRoute;
15825
16487
  STATE.from = STATE.route.range.from;
@@ -15829,6 +16491,7 @@ ${frontmatter.yaml}
15829
16491
  ANNOTATIONS_UI?.restoreSessionFromUrl();
15830
16492
  syncRefInputs();
15831
16493
  syncHeaderMenu();
16494
+ syncLineRefPill();
15832
16495
  if (STATE.route.screen === "help") {
15833
16496
  cancelActiveSourceLoad("navigation");
15834
16497
  setPageMode();
@@ -15843,6 +16506,14 @@ ${frontmatter.yaml}
15843
16506
  loadRepo();
15844
16507
  return;
15845
16508
  }
16509
+ if (STATE.route.screen === "history") {
16510
+ parkRangeForHistory();
16511
+ cancelActiveSourceLoad("navigation");
16512
+ setPageMode();
16513
+ removeStandaloneSource();
16514
+ HISTORY_VIEW.enterHistory();
16515
+ return;
16516
+ }
15846
16517
  if (STATE.route.screen !== "file") {
15847
16518
  cancelActiveSourceLoad("navigation");
15848
16519
  setPageMode();
package/web/index.html CHANGED
@@ -26,12 +26,14 @@
26
26
  <nav class="app-menu" aria-label="Views">
27
27
  <a class="app-menu-item active" data-route="repo" href="/">Repository</a>
28
28
  <a class="app-menu-item" data-route="diff" href="/todif?from=HEAD&to=worktree">Diff Viewer</a>
29
+ <a class="app-menu-item" data-route="history" href="/history">History</a>
29
30
  </nav>
30
31
  <div class="global-actions">
31
32
  <button id="annotations-toggle" class="global-icon-action" title="code annotations" aria-label="code annotations">💬<span id="annotations-count" hidden></span></button>
32
33
  <button id="viewer-settings" class="global-icon-action" title="viewer settings" aria-label="viewer settings"></button>
33
34
  <button id="theme" title="toggle theme">🌗</button>
34
35
  <span id="status" class="status"></span>
36
+ <a id="repo-web-link" class="global-help-link" href="#" target="_blank" rel="noopener" hidden>GitHub</a>
35
37
  <a class="global-help-link" data-route="help" href="/help">Help</a>
36
38
  <span class="product-label" aria-hidden="true">code viewer</span>
37
39
  </div>
@@ -76,6 +78,19 @@
76
78
 
77
79
  <div id="load-bar" aria-hidden="true"></div>
78
80
 
81
+ <aside id="history-panel" aria-label="Commit history" hidden>
82
+ <div class="history-head">
83
+ <span class="history-title">Commits</span>
84
+ <span data-ref-selector-mount data-ref-id="history-ref" data-placeholder="ref..." data-title="history ref"></span>
85
+ </div>
86
+ <div class="history-filter-wrap">
87
+ <input id="history-filter" type="search" placeholder="filter commits… (message, sha, author:name, path:file)" autocomplete="off" />
88
+ </div>
89
+ <div id="history-banner" class="history-banner" role="status" hidden></div>
90
+ <ol id="history-list"></ol>
91
+ <div id="history-sentinel" aria-hidden="true"></div>
92
+ <div id="history-status" class="history-status" role="status" hidden></div>
93
+ </aside>
79
94
  <aside id="sidebar">
80
95
  <div class="sb-head">
81
96
  <span class="sb-title">Files</span>
@@ -180,6 +195,15 @@
180
195
  </aside>
181
196
 
182
197
  <main id="content">
198
+ <section id="history-commit-info" hidden aria-label="Selected commit">
199
+ <div class="hci-head">
200
+ <span id="hci-sha" class="hci-sha"></span>
201
+ <span id="hci-author" class="hci-author"></span>
202
+ <span id="hci-date" class="hci-date"></span>
203
+ </div>
204
+ <h2 id="hci-subject" class="hci-subject"></h2>
205
+ <pre id="hci-body" class="hci-body" hidden></pre>
206
+ </section>
183
207
  <div id="empty" class="empty hidden">
184
208
  <div class="emoji">✨</div>
185
209
  <h2>No changes</h2>
package/web/style.css CHANGED
@@ -50,6 +50,7 @@
50
50
  --topbar-h: 56px;
51
51
  --chrome-h: calc(var(--global-header-h) + var(--topbar-h));
52
52
  --sidebar-w: 308px;
53
+ --history-w: 320px;
53
54
  --sidebar-restore-rail-w: 44px;
54
55
  /* shadows */
55
56
  --shadow-sm: 0 1px 0 rgba(31,35,40,0.04);
@@ -900,6 +901,170 @@ body[data-code-font-size="xlarge"] { --code-font-size: 15px; }
900
901
  100% { transform: translateX(450%); }
901
902
  }
902
903
 
904
+ /* ===== Commit history panel ===== */
905
+ #history-panel {
906
+ display: none;
907
+ position: fixed;
908
+ left: 0;
909
+ top: var(--chrome-h);
910
+ bottom: 0;
911
+ width: var(--history-w);
912
+ background: var(--bg-soft);
913
+ border-right: 1px solid var(--border);
914
+ overflow-y: auto;
915
+ z-index: 31;
916
+ flex-direction: column;
917
+ }
918
+ body.gdp-history-page #history-panel { display: flex; }
919
+ body.gdp-history-page #sidebar { left: var(--history-w); }
920
+ body.gdp-history-page main#content {
921
+ margin-left: calc(var(--history-w) + var(--sidebar-w));
922
+ }
923
+ body.gdp-history-page.gdp-sidebar-hidden main#content {
924
+ margin-left: var(--history-w);
925
+ }
926
+ body.gdp-history-page #sidebar-resizer {
927
+ left: calc(var(--history-w) + var(--sidebar-w) - 4px);
928
+ }
929
+ /* The history screen drives its diff range from the commit list; the topbar
930
+ from/to pickers belong to the Diff Viewer and are hidden here. */
931
+ body.gdp-history-page #topbar .ref-pickers {
932
+ display: none;
933
+ }
934
+ #history-panel[hidden] {
935
+ display: none !important;
936
+ }
937
+ .history-head {
938
+ display: flex;
939
+ align-items: center;
940
+ gap: 8px;
941
+ padding: 13px 14px 10px;
942
+ position: sticky;
943
+ top: 0;
944
+ background: var(--bg-soft);
945
+ border-bottom: 1px solid var(--border);
946
+ z-index: 1;
947
+ }
948
+ .history-title { font-weight: 600; font-size: 13px; }
949
+ .history-filter-wrap {
950
+ padding: 0 12px 8px;
951
+ background: var(--bg-soft);
952
+ border-bottom: 1px solid var(--border);
953
+ }
954
+ #history-filter {
955
+ width: 100%;
956
+ background: var(--bg);
957
+ border: 1px solid var(--border);
958
+ color: var(--fg);
959
+ border-radius: 6px;
960
+ font: inherit;
961
+ font-size: 12px;
962
+ padding: 5px 8px;
963
+ }
964
+ .history-banner {
965
+ margin: 8px 10px 0;
966
+ padding: 6px 10px;
967
+ border: 1px solid var(--border);
968
+ border-radius: 6px;
969
+ background: var(--bg-mute);
970
+ color: var(--fg-muted);
971
+ font-size: 12px;
972
+ }
973
+ #history-list { list-style: none; margin: 0; padding: 0 0 24px; }
974
+ .history-group {
975
+ display: flex;
976
+ align-items: center;
977
+ gap: 8px;
978
+ padding: 14px 14px 6px;
979
+ background: var(--bg-mute);
980
+ border-top: 1px solid var(--border);
981
+ border-bottom: 1px solid var(--border);
982
+ color: var(--accent);
983
+ font-size: 11.5px;
984
+ font-weight: 700;
985
+ text-transform: uppercase;
986
+ letter-spacing: 0.06em;
987
+ }
988
+ .history-group::before {
989
+ content: "";
990
+ width: 4px;
991
+ height: 12px;
992
+ border-radius: 2px;
993
+ background: var(--accent);
994
+ }
995
+ #history-list > .history-group:first-child { border-top: 0; }
996
+ .history-item {
997
+ padding: 8px 14px;
998
+ cursor: pointer;
999
+ border-left: 3px solid transparent;
1000
+ border-bottom: 1px solid var(--border);
1001
+ }
1002
+ .history-item:hover { background: var(--bg-mute); }
1003
+ .history-item.active {
1004
+ background: var(--bg-mute);
1005
+ border-left-color: var(--accent);
1006
+ }
1007
+ .history-item .subject {
1008
+ display: block;
1009
+ font-size: 12.5px;
1010
+ overflow: hidden;
1011
+ text-overflow: ellipsis;
1012
+ white-space: nowrap;
1013
+ }
1014
+ .history-item .meta2 {
1015
+ display: flex;
1016
+ gap: 8px;
1017
+ color: var(--fg-muted);
1018
+ font-size: 11px;
1019
+ margin-top: 2px;
1020
+ }
1021
+ .history-item .sha { font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; }
1022
+ .history-status {
1023
+ padding: 10px 14px;
1024
+ color: var(--fg-muted);
1025
+ font-size: 12px;
1026
+ }
1027
+ #history-sentinel { height: 1px; }
1028
+ #history-commit-info {
1029
+ display: none;
1030
+ margin: 12px 16px 4px;
1031
+ padding: 12px 16px;
1032
+ border: 1px solid var(--border);
1033
+ border-radius: 8px;
1034
+ background: var(--bg-soft);
1035
+ }
1036
+ body.gdp-history-page #history-commit-info:not([hidden]) { display: block; }
1037
+ #history-commit-info .hci-head {
1038
+ display: flex;
1039
+ flex-wrap: wrap;
1040
+ align-items: baseline;
1041
+ gap: 10px;
1042
+ color: var(--fg-muted);
1043
+ font-size: 12px;
1044
+ }
1045
+ #history-commit-info .hci-sha {
1046
+ font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
1047
+ user-select: all;
1048
+ }
1049
+ #history-commit-info .hci-subject {
1050
+ margin: 6px 0 0;
1051
+ font-size: 15px;
1052
+ line-height: 1.4;
1053
+ overflow-wrap: anywhere;
1054
+ }
1055
+ #history-commit-info .hci-body {
1056
+ margin: 8px 0 0;
1057
+ padding: 0;
1058
+ background: transparent;
1059
+ border: 0;
1060
+ font-family: inherit;
1061
+ font-size: 12.5px;
1062
+ line-height: 1.6;
1063
+ color: var(--fg-muted);
1064
+ white-space: pre-wrap;
1065
+ overflow-wrap: anywhere;
1066
+ }
1067
+
903
1068
  /* ===== Sidebar ===== */
904
1069
  #sidebar {
905
1070
  position: fixed;
@@ -1641,7 +1806,10 @@ body.gdp-help-page #content {
1641
1806
 
1642
1807
  #diff > *:first-child { margin-top: 0; }
1643
1808
 
1644
- body:not(.gdp-file-detail-page) #diff::after {
1809
+ /* Scroll-past-end spacer so the sidebar can align the last diff file to the
1810
+ top of the viewport. Not wanted on the history screen, where small
1811
+ single-commit diffs would gain a screenful of dead scroll space. */
1812
+ body:not(.gdp-file-detail-page):not(.gdp-history-page) #diff::after {
1645
1813
  content: "";
1646
1814
  display: block;
1647
1815
  height: calc(100vh - var(--chrome-h) - 40px);
@@ -1911,6 +2079,86 @@ table.d2h-diff-table tr.gdp-diff-line-target .d2h-code-line-ctn {
1911
2079
  table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
1912
2080
  box-shadow: inset 3px 0 0 var(--line-hit-border), inset -1px 0 0 var(--border-muted);
1913
2081
  }
2082
+
2083
+ /* After-side drag selection (diff screen) — same yellow as the file view. */
2084
+ table.d2h-diff-table tr.gdp-diff-line-selected > td,
2085
+ table.d2h-diff-table tr.gdp-diff-line-selected .d2h-code-line,
2086
+ table.d2h-diff-table tr.gdp-diff-line-selected .d2h-code-side-line,
2087
+ table.d2h-diff-table tr.gdp-diff-line-selected .d2h-code-line-ctn {
2088
+ background: var(--line-hit-bg) !important;
2089
+ background-color: var(--line-hit-bg) !important;
2090
+ }
2091
+ table.d2h-diff-table tr.gdp-diff-line-selected > td:first-child {
2092
+ box-shadow: inset 3px 0 0 var(--line-hit-border), inset -1px 0 0 var(--border-muted);
2093
+ }
2094
+ table.d2h-diff-table td.d2h-code-linenumber,
2095
+ table.d2h-diff-table td.d2h-code-side-linenumber {
2096
+ cursor: pointer;
2097
+ user-select: none;
2098
+ }
2099
+
2100
+ /* Floating "copy @path#start-end" pill shown while lines are selected. */
2101
+ #line-ref-pill {
2102
+ position: fixed;
2103
+ left: 50%;
2104
+ bottom: 24px;
2105
+ transform: translateX(-50%);
2106
+ z-index: 400;
2107
+ display: inline-flex;
2108
+ align-items: center;
2109
+ gap: 8px;
2110
+ max-width: min(680px, calc(100vw - 48px));
2111
+ white-space: nowrap;
2112
+ padding: 9px 16px;
2113
+ border: 1px solid var(--accent);
2114
+ border-radius: 999px;
2115
+ background: var(--accent);
2116
+ color: #fff;
2117
+ font: inherit;
2118
+ font-size: 13px;
2119
+ font-weight: 600;
2120
+ cursor: pointer;
2121
+ box-shadow: 0 8px 28px rgba(31, 35, 40, 0.35);
2122
+ }
2123
+ #line-ref-pill:hover {
2124
+ filter: brightness(1.1);
2125
+ }
2126
+ #line-ref-pill svg {
2127
+ flex: 0 0 auto;
2128
+ }
2129
+ #line-ref-pill .lrp-label {
2130
+ flex: 0 0 auto;
2131
+ }
2132
+ #line-ref-pill .lrp-ref {
2133
+ overflow: hidden;
2134
+ text-overflow: ellipsis;
2135
+ padding: 2px 8px;
2136
+ border-radius: 6px;
2137
+ background: rgba(255, 255, 255, 0.18);
2138
+ font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
2139
+ font-size: 12px;
2140
+ font-weight: 400;
2141
+ }
2142
+ #line-ref-pill.copied {
2143
+ background: var(--success, #2ea043);
2144
+ border-color: var(--success, #2ea043);
2145
+ }
2146
+ #line-ref-pill.pop {
2147
+ animation: lrp-pop 0.22s ease-out;
2148
+ }
2149
+ @keyframes lrp-pop {
2150
+ 0% {
2151
+ transform: translateX(-50%) translateY(14px) scale(0.92);
2152
+ opacity: 0;
2153
+ }
2154
+ 100% {
2155
+ transform: translateX(-50%) translateY(0) scale(1);
2156
+ opacity: 1;
2157
+ }
2158
+ }
2159
+ #line-ref-pill[hidden] {
2160
+ display: none;
2161
+ }
1914
2162
  /* Stack height = number of buttons * 20px. With 1 button = 20px row,
1915
2163
  * with 2 (↑+↓) = 40px row, matching GitHub. */
1916
2164
  .gdp-expand-stack {
@@ -3713,6 +3961,17 @@ body.gdp-file-detail-page #empty {
3713
3961
  }
3714
3962
  #content { margin-left: 0; max-width: none; padding-top: calc(var(--chrome-h) + 240px); }
3715
3963
  .controls input[type="search"] { width: 130px; }
3964
+ body.gdp-history-page #history-panel {
3965
+ position: static;
3966
+ width: auto;
3967
+ border-right: 0;
3968
+ border-bottom: 1px solid var(--border);
3969
+ }
3970
+ body.gdp-history-page #sidebar { left: 0; }
3971
+ body.gdp-history-page main#content,
3972
+ body.gdp-history-page.gdp-sidebar-hidden main#content {
3973
+ margin-left: 0;
3974
+ }
3716
3975
  }
3717
3976
 
3718
3977
  /* ===== File shell (placeholder + loaded card) ===== */