@youtyan/code-viewer 0.1.42 → 0.1.43

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,106 @@ function refCommits(cwd, query = "", max = DEFAULT_REF_COMMIT_LIMIT) {
633
633
  ]);
634
634
  return mergeCommitResults(limit, hashMatches, subjectMatches, authorMatches);
635
635
  }
636
+ function parseHistoryLog(stdout) {
637
+ const parts = stdout.split("\x00");
638
+ const commits = [];
639
+ for (let index = 0;index < parts.length; ) {
640
+ if (!parts[index]) {
641
+ index++;
642
+ continue;
643
+ }
644
+ const sha = parts[index++] || "";
645
+ const subject = parts[index++] || "";
646
+ const author = parts[index++] || "";
647
+ const when = parts[index++] || "";
648
+ const parentsRaw = (parts[index++] || "").trim();
649
+ const body = (parts[index++] || "").trim();
650
+ if (sha)
651
+ commits.push({
652
+ sha,
653
+ subject,
654
+ author,
655
+ when,
656
+ parents: parentsRaw ? parentsRaw.split(/\s+/) : [],
657
+ body
658
+ });
659
+ }
660
+ return commits;
661
+ }
662
+ function historyQueryArgs(query) {
663
+ const trimmed = query.trim().slice(0, 200).replace(/\0/g, "");
664
+ if (!trimmed)
665
+ return { filterArgs: [], pathspec: [], shaTerm: "" };
666
+ const prefixed = /^(author|path):(.*)$/.exec(trimmed);
667
+ if (prefixed) {
668
+ const term = prefixed[2].trim();
669
+ if (!term)
670
+ return { filterArgs: [], pathspec: [], shaTerm: "" };
671
+ if (prefixed[1] === "author") {
672
+ return {
673
+ filterArgs: [
674
+ "--regexp-ignore-case",
675
+ "--fixed-strings",
676
+ `--author=${term}`
677
+ ],
678
+ pathspec: [],
679
+ shaTerm: ""
680
+ };
681
+ }
682
+ return {
683
+ filterArgs: [],
684
+ pathspec: ["--", `:(icase)*${term}*`],
685
+ shaTerm: ""
686
+ };
687
+ }
688
+ return {
689
+ filterArgs: [
690
+ "--regexp-ignore-case",
691
+ "--fixed-strings",
692
+ `--grep=${trimmed}`
693
+ ],
694
+ pathspec: [],
695
+ shaTerm: /^[0-9a-f]{4,40}$/i.test(trimmed) ? trimmed : ""
696
+ };
697
+ }
698
+ function commitHistory(cwd, options) {
699
+ const ref = (options.ref || "HEAD").trim();
700
+ if (!ref || ref.startsWith("-") || ref.includes("\x00"))
701
+ return { commits: [], hasMore: false, error: "invalid ref" };
702
+ const verified = run(["git", "rev-parse", "--verify", `${ref}^{commit}`], cwd);
703
+ if (verified.code !== 0)
704
+ return { commits: [], hasMore: false, error: "unknown ref" };
705
+ const skip = Math.max(0, Math.floor(options.skip) || 0);
706
+ const limit = Math.max(1, Math.min(Math.floor(options.limit) || 1, MAX_HISTORY_LIMIT));
707
+ const { filterArgs, pathspec, shaTerm } = historyQueryArgs(options.query || "");
708
+ const res = run([
709
+ "git",
710
+ "log",
711
+ "-z",
712
+ `--skip=${skip}`,
713
+ `--max-count=${limit + 1}`,
714
+ `--format=${HISTORY_FORMAT}`,
715
+ ...filterArgs,
716
+ verified.stdout.trim(),
717
+ ...pathspec
718
+ ], cwd);
719
+ if (res.code !== 0)
720
+ return { commits: [], hasMore: false, error: "git log failed" };
721
+ let parsed = parseHistoryLog(res.stdout);
722
+ if (shaTerm && skip === 0) {
723
+ const bySha = run(["git", "rev-parse", "--verify", `${shaTerm}^{commit}`], cwd);
724
+ const sha = bySha.code === 0 ? bySha.stdout.trim() : "";
725
+ if (sha) {
726
+ const single = run(["git", "log", "-z", "-1", `--format=${HISTORY_FORMAT}`, sha], cwd);
727
+ if (single.code === 0) {
728
+ const hit = parseHistoryLog(single.stdout);
729
+ parsed = [...hit, ...parsed.filter((c) => c.sha !== sha)];
730
+ }
731
+ }
732
+ }
733
+ const hasMore = parsed.length > limit;
734
+ return { commits: hasMore ? parsed.slice(0, limit) : parsed, hasMore };
735
+ }
636
736
  function nameStatus(args, cwd) {
637
737
  const res = run([
638
738
  "git",
@@ -1051,7 +1151,7 @@ function truncateToNHunks(diffText, n, maxLines = Number.POSITIVE_INFINITY) {
1051
1151
  lineTruncated
1052
1152
  };
1053
1153
  }
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;
1154
+ 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
1155
  var init_git = __esm(() => {
1056
1156
  init_runtime();
1057
1157
  DEFAULT_WORKTREE_OMIT_DIR_NAMES = [
@@ -1814,7 +1914,13 @@ function normalizeNewDirectoryName(name) {
1814
1914
  // web-src/core/routes.ts
1815
1915
  var SPA_PATHS, APP_ENTRY_PATHS;
1816
1916
  var init_routes = __esm(() => {
1817
- SPA_PATHS = ["/todif", "/todiff", "/file", "/help"];
1917
+ SPA_PATHS = [
1918
+ "/todif",
1919
+ "/todiff",
1920
+ "/file",
1921
+ "/help",
1922
+ "/history"
1923
+ ];
1818
1924
  APP_ENTRY_PATHS = ["/", "/index.html"];
1819
1925
  });
1820
1926
 
@@ -3159,6 +3265,20 @@ function handleRefCommits(url) {
3159
3265
  const max = Number.isFinite(parsedMax) && parsedMax > 0 ? parsedMax : undefined;
3160
3266
  return json({ commits: refCommits(cwd, query, max) });
3161
3267
  }
3268
+ function handleLog(url) {
3269
+ const ref = url.searchParams.get("ref") || "HEAD";
3270
+ const skip = Number(url.searchParams.get("skip") || "0");
3271
+ const limit = Number(url.searchParams.get("limit") || "50");
3272
+ const result = commitHistory(cwd, {
3273
+ ref,
3274
+ skip: Number.isFinite(skip) ? skip : 0,
3275
+ limit: Number.isFinite(limit) ? limit : 50,
3276
+ query: url.searchParams.get("q") || ""
3277
+ });
3278
+ if (result.error)
3279
+ return text(result.error, 400);
3280
+ return json({ commits: result.commits, hasMore: result.hasMore });
3281
+ }
3162
3282
  function handleFileDiff(url) {
3163
3283
  const path = url.searchParams.get("path") || "";
3164
3284
  if (!safePath(path))
@@ -4201,6 +4321,8 @@ var init_preview = __esm(async () => {
4201
4321
  return handleGrep(url);
4202
4322
  if (url.pathname === "/_commits")
4203
4323
  return handleRefCommits(url);
4324
+ if (url.pathname === "/_log")
4325
+ return handleLog(url);
4204
4326
  if (url.pathname === "/file_diff")
4205
4327
  return handleFileDiff(url);
4206
4328
  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.43",
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:
@@ -9013,6 +9031,356 @@ ${frontmatter.yaml}
9013
9031
  return { renderHelpPage };
9014
9032
  }
9015
9033
 
9034
+ // web-src/core/history.ts
9035
+ var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
9036
+ var HISTORY_PAGE_SIZE = 50;
9037
+ var HISTORY_AUTO_LOAD_MAX_PAGES = 20;
9038
+ function commitDiffRange(commit) {
9039
+ return { from: commit.parents[0] || EMPTY_TREE_SHA, to: commit.sha };
9040
+ }
9041
+ function shouldContinueAutoLoad(state) {
9042
+ if (state.found)
9043
+ return false;
9044
+ if (!state.hasMore)
9045
+ return false;
9046
+ return state.pagesLoaded < HISTORY_AUTO_LOAD_MAX_PAGES;
9047
+ }
9048
+ var MONTH_NAMES = [
9049
+ "January",
9050
+ "February",
9051
+ "March",
9052
+ "April",
9053
+ "May",
9054
+ "June",
9055
+ "July",
9056
+ "August",
9057
+ "September",
9058
+ "October",
9059
+ "November",
9060
+ "December"
9061
+ ];
9062
+ var DAY_MS = 24 * 60 * 60 * 1000;
9063
+ function historyGroupLabel(whenIso, now) {
9064
+ const t2 = Date.parse(whenIso);
9065
+ if (!Number.isFinite(t2))
9066
+ return "Unknown date";
9067
+ const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
9068
+ if (t2 >= dayStart)
9069
+ return "Today";
9070
+ if (t2 >= dayStart - DAY_MS)
9071
+ return "Yesterday";
9072
+ if (t2 >= dayStart - 6 * DAY_MS)
9073
+ return "This week";
9074
+ const d2 = new Date(t2);
9075
+ if (d2.getFullYear() === now.getFullYear() && d2.getMonth() === now.getMonth())
9076
+ return "This month";
9077
+ return `${MONTH_NAMES[d2.getMonth()]} ${d2.getFullYear()}`;
9078
+ }
9079
+
9080
+ // web-src/views/history-view.ts
9081
+ function createHistoryView(deps) {
9082
+ const panel = deps.$("#history-panel");
9083
+ const list2 = deps.$("#history-list");
9084
+ const banner = deps.$("#history-banner");
9085
+ const statusEl = deps.$("#history-status");
9086
+ const sentinel = deps.$("#history-sentinel");
9087
+ let ref = "HEAD";
9088
+ let commits = [];
9089
+ let hasMore = false;
9090
+ let loading = false;
9091
+ let generation = 0;
9092
+ let selectedSha = "";
9093
+ let query = "";
9094
+ function setBanner(message) {
9095
+ banner.textContent = message;
9096
+ banner.hidden = !message;
9097
+ }
9098
+ function setStatusText(message) {
9099
+ statusEl.textContent = message;
9100
+ statusEl.hidden = !message;
9101
+ }
9102
+ function relativeWhen(iso) {
9103
+ const t2 = Date.parse(iso);
9104
+ if (!Number.isFinite(t2))
9105
+ return iso;
9106
+ const sec = Math.round((Date.now() - t2) / 1000);
9107
+ if (sec < 60)
9108
+ return "just now";
9109
+ const min = Math.round(sec / 60);
9110
+ if (min < 60)
9111
+ return `${min}m ago`;
9112
+ const hour = Math.round(min / 60);
9113
+ if (hour < 24)
9114
+ return `${hour}h ago`;
9115
+ const day = Math.round(hour / 24);
9116
+ if (day < 30)
9117
+ return `${day}d ago`;
9118
+ return iso.slice(0, 10);
9119
+ }
9120
+ function fetchPage(skip) {
9121
+ const url = `/_log?ref=${encodeURIComponent(ref)}&skip=${skip}&limit=${HISTORY_PAGE_SIZE}` + (query ? `&q=${encodeURIComponent(query)}` : "");
9122
+ return deps.trackLoad(fetch(url).then(async (r2) => {
9123
+ if (!r2.ok)
9124
+ throw new Error(await r2.text());
9125
+ return await r2.json();
9126
+ })).catch((err) => {
9127
+ setBanner(err instanceof Error ? err.message : "failed to load log");
9128
+ return null;
9129
+ });
9130
+ }
9131
+ function commitRow(commit) {
9132
+ const active = commit.sha === selectedSha ? " active" : "";
9133
+ 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>`;
9134
+ }
9135
+ function renderList() {
9136
+ const now = new Date;
9137
+ const html = [];
9138
+ let lastGroup = "";
9139
+ for (const commit of commits) {
9140
+ const group = historyGroupLabel(commit.when, now);
9141
+ if (group !== lastGroup) {
9142
+ html.push(`<li class="history-group" aria-hidden="true">${deps.escapeHtml(group)}</li>`);
9143
+ lastGroup = group;
9144
+ }
9145
+ html.push(commitRow(commit));
9146
+ }
9147
+ list2.innerHTML = html.join("");
9148
+ setStatusText(loading ? "loading..." : commits.length ? "" : "no commits");
9149
+ }
9150
+ function updateCommitInfo(commit) {
9151
+ const info = document.querySelector("#history-commit-info");
9152
+ if (!info)
9153
+ return;
9154
+ if (!commit) {
9155
+ info.hidden = true;
9156
+ return;
9157
+ }
9158
+ const set2 = (sel, text2) => {
9159
+ const el = info.querySelector(sel);
9160
+ if (el)
9161
+ el.textContent = text2;
9162
+ };
9163
+ set2(".hci-sha", commit.sha);
9164
+ set2(".hci-author", commit.author);
9165
+ const t2 = Date.parse(commit.when);
9166
+ set2(".hci-date", Number.isFinite(t2) ? new Date(t2).toLocaleString() : commit.when);
9167
+ set2(".hci-subject", commit.subject);
9168
+ const body = info.querySelector(".hci-body");
9169
+ if (body) {
9170
+ body.textContent = commit.body;
9171
+ body.hidden = !commit.body;
9172
+ }
9173
+ info.hidden = false;
9174
+ }
9175
+ function updateActiveRow() {
9176
+ list2.querySelectorAll(".history-item").forEach((row) => {
9177
+ row.classList.toggle("active", row.dataset.sha === selectedSha);
9178
+ });
9179
+ }
9180
+ let inFlight = null;
9181
+ function loadNextPage() {
9182
+ if (inFlight)
9183
+ return inFlight;
9184
+ const started = doLoadNextPage().finally(() => {
9185
+ if (inFlight === started)
9186
+ inFlight = null;
9187
+ });
9188
+ inFlight = started;
9189
+ return started;
9190
+ }
9191
+ async function doLoadNextPage() {
9192
+ loading = true;
9193
+ const gen = generation;
9194
+ setStatusText("loading...");
9195
+ const page = await fetchPage(commits.length);
9196
+ if (gen !== generation)
9197
+ return false;
9198
+ loading = false;
9199
+ if (!page) {
9200
+ setStatusText("");
9201
+ return false;
9202
+ }
9203
+ commits = commits.concat(page.commits);
9204
+ hasMore = page.hasMore;
9205
+ renderList();
9206
+ return page.commits.length > 0;
9207
+ }
9208
+ async function selectCommit(commit, options = {}) {
9209
+ const gen = generation;
9210
+ selectedSha = commit.sha;
9211
+ updateActiveRow();
9212
+ updateCommitInfo(commit);
9213
+ if (gen !== generation)
9214
+ return;
9215
+ if (options.updateUrl !== false) {
9216
+ const range = commitDiffRange(commit);
9217
+ deps.setRoute({ screen: "history", ref, commit: commit.sha, range }, true);
9218
+ }
9219
+ if (gen !== generation)
9220
+ return;
9221
+ await deps.applyCommitRange(commitDiffRange(commit));
9222
+ if (gen !== generation)
9223
+ return;
9224
+ }
9225
+ let lookupFailed = false;
9226
+ async function fetchSingleCommit(sha) {
9227
+ const url = `/_log?ref=${encodeURIComponent(sha)}&skip=0&limit=1`;
9228
+ lookupFailed = false;
9229
+ try {
9230
+ const res = await deps.trackLoad(fetch(url).then(async (r2) => {
9231
+ if (r2.status === 400)
9232
+ return null;
9233
+ if (!r2.ok)
9234
+ throw new Error(await r2.text());
9235
+ return await r2.json();
9236
+ }));
9237
+ return res?.commits[0] || null;
9238
+ } catch {
9239
+ lookupFailed = true;
9240
+ return null;
9241
+ }
9242
+ }
9243
+ async function resolveDeepLink(sha) {
9244
+ const gen = generation;
9245
+ let pagesLoaded = 0;
9246
+ if (commits.length === 0) {
9247
+ await loadNextPage();
9248
+ if (gen !== generation)
9249
+ return;
9250
+ pagesLoaded = 1;
9251
+ } else {
9252
+ pagesLoaded = 1;
9253
+ }
9254
+ for (;; ) {
9255
+ const found = commits.find((c2) => c2.sha.startsWith(sha));
9256
+ if (found) {
9257
+ await selectCommit(found, { updateUrl: false });
9258
+ scrollToSelected();
9259
+ return;
9260
+ }
9261
+ if (!shouldContinueAutoLoad({ pagesLoaded, found: false, hasMore }))
9262
+ break;
9263
+ const got = await loadNextPage();
9264
+ if (gen !== generation)
9265
+ return;
9266
+ pagesLoaded++;
9267
+ if (!got && !hasMore)
9268
+ break;
9269
+ }
9270
+ const single = await fetchSingleCommit(sha);
9271
+ if (gen !== generation)
9272
+ return;
9273
+ if (!single) {
9274
+ setBanner(lookupFailed ? `failed to load commit: ${sha}` : `commit not found: ${sha}`);
9275
+ updateCommitInfo(null);
9276
+ deps.showEmptyDiffPane();
9277
+ return;
9278
+ }
9279
+ setBanner(`showing commit outside the loaded ${ref} log`);
9280
+ commits = [single, ...commits];
9281
+ renderList();
9282
+ await selectCommit(single, { updateUrl: false });
9283
+ scrollToSelected();
9284
+ }
9285
+ function scrollToSelected() {
9286
+ list2.querySelector(`.history-item[data-sha="${CSS.escape(selectedSha)}"]`)?.scrollIntoView({ block: "center" });
9287
+ }
9288
+ let entering = Promise.resolve();
9289
+ function enterHistory(force) {
9290
+ entering = entering.then(() => doEnterHistory(force === true)).catch(() => {});
9291
+ return entering;
9292
+ }
9293
+ async function doEnterHistory(force = false) {
9294
+ const route = deps.getRoute();
9295
+ if (route.screen !== "history")
9296
+ return;
9297
+ const nextRef = route.ref || "HEAD";
9298
+ const refChanged = nextRef !== ref;
9299
+ if (refChanged || force || commits.length === 0) {
9300
+ generation++;
9301
+ ref = nextRef;
9302
+ commits = [];
9303
+ hasMore = false;
9304
+ loading = false;
9305
+ inFlight = null;
9306
+ selectedSha = "";
9307
+ setBanner("");
9308
+ updateCommitInfo(null);
9309
+ renderList();
9310
+ await loadNextPage();
9311
+ }
9312
+ const route2 = deps.getRoute();
9313
+ if (route2.screen !== "history")
9314
+ return;
9315
+ if (route2.commit) {
9316
+ await resolveDeepLink(route2.commit);
9317
+ } else {
9318
+ selectedSha = "";
9319
+ updateActiveRow();
9320
+ updateCommitInfo(null);
9321
+ deps.showEmptyDiffPane();
9322
+ }
9323
+ }
9324
+ function onRefPicked(nextRef) {
9325
+ const value = nextRef && nextRef !== "worktree" ? nextRef : "HEAD";
9326
+ deps.setRoute({
9327
+ screen: "history",
9328
+ ref: value,
9329
+ range: { from: "HEAD", to: "worktree" }
9330
+ }, false);
9331
+ enterHistory(true);
9332
+ }
9333
+ list2.addEventListener("click", (e2) => {
9334
+ const row = e2.target.closest(".history-item");
9335
+ if (!row?.dataset.sha)
9336
+ return;
9337
+ const commit = commits.find((c2) => c2.sha === row.dataset.sha);
9338
+ if (commit)
9339
+ selectCommit(commit);
9340
+ });
9341
+ function applyFilter(next) {
9342
+ const value = next.trim();
9343
+ if (value === query)
9344
+ return;
9345
+ query = value;
9346
+ generation++;
9347
+ commits = [];
9348
+ hasMore = false;
9349
+ loading = false;
9350
+ inFlight = null;
9351
+ setBanner("");
9352
+ renderList();
9353
+ loadNextPage();
9354
+ }
9355
+ const filterInput = document.querySelector("#history-filter");
9356
+ let filterTimer = null;
9357
+ filterInput?.addEventListener("input", () => {
9358
+ if (filterTimer)
9359
+ clearTimeout(filterTimer);
9360
+ filterTimer = setTimeout(() => {
9361
+ filterTimer = null;
9362
+ applyFilter(filterInput.value);
9363
+ }, 250);
9364
+ });
9365
+ filterInput?.addEventListener("keydown", (e2) => {
9366
+ if (e2.key === "Escape" && filterInput.value) {
9367
+ filterInput.value = "";
9368
+ applyFilter("");
9369
+ e2.stopPropagation();
9370
+ }
9371
+ });
9372
+ const observer = new IntersectionObserver((entries) => {
9373
+ if (!entries.some((entry) => entry.isIntersecting))
9374
+ return;
9375
+ if (deps.getRoute().screen !== "history")
9376
+ return;
9377
+ if (hasMore && !loading)
9378
+ loadNextPage();
9379
+ }, { root: panel, rootMargin: "200px" });
9380
+ observer.observe(sentinel);
9381
+ return { enterHistory, onRefPicked };
9382
+ }
9383
+
9016
9384
  // web-src/views/hunk-expand.ts
9017
9385
  function createHunkExpand(deps) {
9018
9386
  function parseHunkHeader(text2) {
@@ -9631,7 +9999,7 @@ ${frontmatter.yaml}
9631
9999
  const target = e2.target;
9632
10000
  if (popover.contains(target))
9633
10001
  return;
9634
- if (target.id === "ref-from" || target.id === "ref-to" || target.id === "repo-ref" || target.id === "repo-target")
10002
+ if (target.id === "ref-from" || target.id === "ref-to" || target.id === "repo-ref" || target.id === "repo-target" || target.id === "history-ref")
9635
10003
  return;
9636
10004
  closePopover();
9637
10005
  });
@@ -15239,6 +15607,15 @@ ${frontmatter.yaml}
15239
15607
  document.body.classList.toggle("gdp-repo-blob-page", STATE.route.screen === "file" && STATE.route.view === "blob");
15240
15608
  document.body.classList.toggle("gdp-repo-page", STATE.route.screen === "repo");
15241
15609
  document.body.classList.toggle("gdp-help-page", STATE.route.screen === "help");
15610
+ document.body.classList.toggle("gdp-history-page", STATE.route.screen === "history");
15611
+ const historyPanel = $("#history-panel");
15612
+ if (historyPanel)
15613
+ historyPanel.hidden = STATE.route.screen !== "history";
15614
+ if (STATE.route.screen === "history") {
15615
+ const historyRefInput = $("#history-ref");
15616
+ if (historyRefInput)
15617
+ historyRefInput.value = STATE.route.ref || "HEAD";
15618
+ }
15242
15619
  syncRepoTargetInput(repoFileTargetFromRoute() || "worktree");
15243
15620
  }
15244
15621
  function syncHeaderMenu() {
@@ -15258,6 +15635,13 @@ ${frontmatter.yaml}
15258
15635
  if (link2.dataset.route === "diff") {
15259
15636
  link2.href = buildRoute({ screen: "diff", range: currentRange() });
15260
15637
  }
15638
+ if (link2.dataset.route === "history") {
15639
+ link2.href = buildRoute({
15640
+ screen: "history",
15641
+ ref: "HEAD",
15642
+ range: currentRange()
15643
+ });
15644
+ }
15261
15645
  if (link2.dataset.route === "help") {
15262
15646
  link2.href = buildRoute({
15263
15647
  screen: "help",
@@ -15747,6 +16131,18 @@ ${frontmatter.yaml}
15747
16131
  }
15748
16132
  if (STATE.route.screen === "repo")
15749
16133
  return loadRepo();
16134
+ {
16135
+ const empty = $("#empty");
16136
+ if (empty) {
16137
+ const onHistory = STATE.route.screen === "history";
16138
+ const h2 = empty.querySelector("h2");
16139
+ if (h2)
16140
+ h2.textContent = onHistory ? "Empty diff" : "No changes";
16141
+ const p2 = empty.querySelector("p");
16142
+ if (p2)
16143
+ p2.textContent = onHistory ? "This commit has no changes against its first parent." : "The working tree is clean against this ref.";
16144
+ }
16145
+ }
15750
16146
  setStatus("refreshing");
15751
16147
  const params = new URLSearchParams;
15752
16148
  if (STATE.ignoreWs)
@@ -15772,6 +16168,9 @@ ${frontmatter.yaml}
15772
16168
  else if (STATE.route.screen === "file" && STATE.route.view === "blob") {
15773
16169
  setStatus("live");
15774
16170
  applySourceRouteToShell();
16171
+ } else if (STATE.route.screen === "history") {
16172
+ setStatus("live");
16173
+ HISTORY_VIEW.enterHistory();
15775
16174
  } else
15776
16175
  load();
15777
16176
  });
@@ -15801,12 +16200,42 @@ ${frontmatter.yaml}
15801
16200
  renderHelpPage();
15802
16201
  } else {
15803
16202
  setRoute({ screen: "diff", range }, true);
16203
+ setPageMode();
15804
16204
  load();
15805
16205
  }
15806
16206
  }
15807
16207
  syncRefInputs();
15808
16208
  syncHeaderMenu();
15809
- createRefPicker({
16209
+ const HISTORY_VIEW = createHistoryView({
16210
+ $,
16211
+ escapeHtml: escapeHtml2,
16212
+ getRoute: () => STATE.route,
16213
+ setRoute,
16214
+ applyCommitRange: (range) => {
16215
+ STATE.from = range.from;
16216
+ STATE.to = range.to;
16217
+ syncRefInputs();
16218
+ return load();
16219
+ },
16220
+ showEmptyDiffPane: () => {
16221
+ const diff = $("#diff");
16222
+ if (diff)
16223
+ diff.innerHTML = "";
16224
+ const empty = $("#empty");
16225
+ if (empty) {
16226
+ empty.classList.remove("hidden");
16227
+ const h2 = empty.querySelector("h2");
16228
+ if (h2)
16229
+ h2.textContent = "No commit selected";
16230
+ const p2 = empty.querySelector("p");
16231
+ if (p2)
16232
+ p2.textContent = "Select a commit from the list to see its changes.";
16233
+ }
16234
+ setStatus("live");
16235
+ },
16236
+ trackLoad
16237
+ });
16238
+ const REF_PICKER = createRefPicker({
15810
16239
  $,
15811
16240
  escapeHtml: escapeHtml2,
15812
16241
  currentRange,
@@ -15818,6 +16247,13 @@ ${frontmatter.yaml}
15818
16247
  getRepoRef: () => STATE.repoRef,
15819
16248
  getRoute: () => STATE.route
15820
16249
  });
16250
+ if (REF_PICKER) {
16251
+ const historyRefInput = document.querySelector("#history-ref");
16252
+ if (historyRefInput) {
16253
+ historyRefInput.value = "HEAD";
16254
+ REF_PICKER.wireRefSelectorInput(historyRefInput, (ref) => HISTORY_VIEW.onRefPicked(ref));
16255
+ }
16256
+ }
15821
16257
  $("#ref-reset").addEventListener("click", () => setRange("HEAD", "worktree"));
15822
16258
  function applyRouteFromLocation() {
15823
16259
  const parsedRoute = parseRoute(window.location.pathname, window.location.search, currentRange());
@@ -15843,6 +16279,13 @@ ${frontmatter.yaml}
15843
16279
  loadRepo();
15844
16280
  return;
15845
16281
  }
16282
+ if (STATE.route.screen === "history") {
16283
+ cancelActiveSourceLoad("navigation");
16284
+ setPageMode();
16285
+ removeStandaloneSource();
16286
+ HISTORY_VIEW.enterHistory();
16287
+ return;
16288
+ }
15846
16289
  if (STATE.route.screen !== "file") {
15847
16290
  cancelActiveSourceLoad("navigation");
15848
16291
  setPageMode();
package/web/index.html CHANGED
@@ -26,6 +26,7 @@
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>
@@ -76,6 +77,19 @@
76
77
 
77
78
  <div id="load-bar" aria-hidden="true"></div>
78
79
 
80
+ <aside id="history-panel" aria-label="Commit history" hidden>
81
+ <div class="history-head">
82
+ <span class="history-title">Commits</span>
83
+ <span data-ref-selector-mount data-ref-id="history-ref" data-placeholder="ref..." data-title="history ref"></span>
84
+ </div>
85
+ <div class="history-filter-wrap">
86
+ <input id="history-filter" type="search" placeholder="filter commits… (message, sha, author:name, path:file)" autocomplete="off" />
87
+ </div>
88
+ <div id="history-banner" class="history-banner" role="status" hidden></div>
89
+ <ol id="history-list"></ol>
90
+ <div id="history-sentinel" aria-hidden="true"></div>
91
+ <div id="history-status" class="history-status" role="status" hidden></div>
92
+ </aside>
79
93
  <aside id="sidebar">
80
94
  <div class="sb-head">
81
95
  <span class="sb-title">Files</span>
@@ -180,6 +194,15 @@
180
194
  </aside>
181
195
 
182
196
  <main id="content">
197
+ <section id="history-commit-info" hidden aria-label="Selected commit">
198
+ <div class="hci-head">
199
+ <span id="hci-sha" class="hci-sha"></span>
200
+ <span id="hci-author" class="hci-author"></span>
201
+ <span id="hci-date" class="hci-date"></span>
202
+ </div>
203
+ <h2 id="hci-subject" class="hci-subject"></h2>
204
+ <pre id="hci-body" class="hci-body" hidden></pre>
205
+ </section>
183
206
  <div id="empty" class="empty hidden">
184
207
  <div class="emoji">✨</div>
185
208
  <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,165 @@ 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
+ #history-panel[hidden] {
930
+ display: none !important;
931
+ }
932
+ .history-head {
933
+ display: flex;
934
+ align-items: center;
935
+ gap: 8px;
936
+ padding: 13px 14px 10px;
937
+ position: sticky;
938
+ top: 0;
939
+ background: var(--bg-soft);
940
+ border-bottom: 1px solid var(--border);
941
+ z-index: 1;
942
+ }
943
+ .history-title { font-weight: 600; font-size: 13px; }
944
+ .history-filter-wrap {
945
+ padding: 0 12px 8px;
946
+ background: var(--bg-soft);
947
+ border-bottom: 1px solid var(--border);
948
+ }
949
+ #history-filter {
950
+ width: 100%;
951
+ background: var(--bg);
952
+ border: 1px solid var(--border);
953
+ color: var(--fg);
954
+ border-radius: 6px;
955
+ font: inherit;
956
+ font-size: 12px;
957
+ padding: 5px 8px;
958
+ }
959
+ .history-banner {
960
+ margin: 8px 10px 0;
961
+ padding: 6px 10px;
962
+ border: 1px solid var(--border);
963
+ border-radius: 6px;
964
+ background: var(--bg-mute);
965
+ color: var(--fg-muted);
966
+ font-size: 12px;
967
+ }
968
+ #history-list { list-style: none; margin: 0; padding: 0 0 24px; }
969
+ .history-group {
970
+ display: flex;
971
+ align-items: center;
972
+ gap: 8px;
973
+ padding: 14px 14px 6px;
974
+ background: var(--bg-mute);
975
+ border-top: 1px solid var(--border);
976
+ border-bottom: 1px solid var(--border);
977
+ color: var(--accent);
978
+ font-size: 11.5px;
979
+ font-weight: 700;
980
+ text-transform: uppercase;
981
+ letter-spacing: 0.06em;
982
+ }
983
+ .history-group::before {
984
+ content: "";
985
+ width: 4px;
986
+ height: 12px;
987
+ border-radius: 2px;
988
+ background: var(--accent);
989
+ }
990
+ #history-list > .history-group:first-child { border-top: 0; }
991
+ .history-item {
992
+ padding: 8px 14px;
993
+ cursor: pointer;
994
+ border-left: 3px solid transparent;
995
+ border-bottom: 1px solid var(--border);
996
+ }
997
+ .history-item:hover { background: var(--bg-mute); }
998
+ .history-item.active {
999
+ background: var(--bg-mute);
1000
+ border-left-color: var(--accent);
1001
+ }
1002
+ .history-item .subject {
1003
+ display: block;
1004
+ font-size: 12.5px;
1005
+ overflow: hidden;
1006
+ text-overflow: ellipsis;
1007
+ white-space: nowrap;
1008
+ }
1009
+ .history-item .meta2 {
1010
+ display: flex;
1011
+ gap: 8px;
1012
+ color: var(--fg-muted);
1013
+ font-size: 11px;
1014
+ margin-top: 2px;
1015
+ }
1016
+ .history-item .sha { font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; }
1017
+ .history-status {
1018
+ padding: 10px 14px;
1019
+ color: var(--fg-muted);
1020
+ font-size: 12px;
1021
+ }
1022
+ #history-sentinel { height: 1px; }
1023
+ #history-commit-info {
1024
+ display: none;
1025
+ margin: 12px 16px 4px;
1026
+ padding: 12px 16px;
1027
+ border: 1px solid var(--border);
1028
+ border-radius: 8px;
1029
+ background: var(--bg-soft);
1030
+ }
1031
+ body.gdp-history-page #history-commit-info:not([hidden]) { display: block; }
1032
+ #history-commit-info .hci-head {
1033
+ display: flex;
1034
+ flex-wrap: wrap;
1035
+ align-items: baseline;
1036
+ gap: 10px;
1037
+ color: var(--fg-muted);
1038
+ font-size: 12px;
1039
+ }
1040
+ #history-commit-info .hci-sha {
1041
+ font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
1042
+ user-select: all;
1043
+ }
1044
+ #history-commit-info .hci-subject {
1045
+ margin: 6px 0 0;
1046
+ font-size: 15px;
1047
+ line-height: 1.4;
1048
+ overflow-wrap: anywhere;
1049
+ }
1050
+ #history-commit-info .hci-body {
1051
+ margin: 8px 0 0;
1052
+ padding: 0;
1053
+ background: transparent;
1054
+ border: 0;
1055
+ font-family: inherit;
1056
+ font-size: 12.5px;
1057
+ line-height: 1.6;
1058
+ color: var(--fg-muted);
1059
+ white-space: pre-wrap;
1060
+ overflow-wrap: anywhere;
1061
+ }
1062
+
903
1063
  /* ===== Sidebar ===== */
904
1064
  #sidebar {
905
1065
  position: fixed;
@@ -3713,6 +3873,17 @@ body.gdp-file-detail-page #empty {
3713
3873
  }
3714
3874
  #content { margin-left: 0; max-width: none; padding-top: calc(var(--chrome-h) + 240px); }
3715
3875
  .controls input[type="search"] { width: 130px; }
3876
+ body.gdp-history-page #history-panel {
3877
+ position: static;
3878
+ width: auto;
3879
+ border-right: 0;
3880
+ border-bottom: 1px solid var(--border);
3881
+ }
3882
+ body.gdp-history-page #sidebar { left: 0; }
3883
+ body.gdp-history-page main#content,
3884
+ body.gdp-history-page.gdp-sidebar-hidden main#content {
3885
+ margin-left: 0;
3886
+ }
3716
3887
  }
3717
3888
 
3718
3889
  /* ===== File shell (placeholder + loaded card) ===== */