@youtyan/code-viewer 0.1.0 → 0.1.1

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/web/app.js CHANGED
@@ -33,6 +33,16 @@
33
33
  function mapNewToOld(newLine, prevHunkEndNew, prevHunkEndOld) {
34
34
  return prevHunkEndOld + (newLine - prevHunkEndNew);
35
35
  }
36
+ function trailingClickRange(hunkEndNew, step) {
37
+ return { start: hunkEndNew, end: hunkEndNew + step - 1 };
38
+ }
39
+ function applyTrailingResult(state, receivedCount, step) {
40
+ return {
41
+ newStart: state.newStart + receivedCount,
42
+ oldStart: state.oldStart + receivedCount,
43
+ eof: receivedCount === 0 || receivedCount < step
44
+ };
45
+ }
36
46
  var GdpExpandLogic = {
37
47
  initExpandState,
38
48
  remainingGap,
@@ -41,9 +51,137 @@
41
51
  downClickRange,
42
52
  applyUp,
43
53
  applyDown,
44
- mapNewToOld
54
+ mapNewToOld,
55
+ trailingClickRange,
56
+ applyTrailingResult
45
57
  };
46
58
 
59
+ // web-src/file-navigation.ts
60
+ function nextVisibleFileIndex(currentIndex, itemCount, direction) {
61
+ if (itemCount <= 0)
62
+ return -1;
63
+ if (currentIndex < 0)
64
+ return direction > 0 ? 0 : itemCount - 1;
65
+ return Math.max(0, Math.min(itemCount - 1, currentIndex + direction));
66
+ }
67
+
68
+ // web-src/file-path-copy.ts
69
+ function filePathClipboardText(path) {
70
+ return path || "";
71
+ }
72
+
73
+ // web-src/file-filter.ts
74
+ function normalizeFileFilterQuery(value) {
75
+ return (value || "").toLowerCase().trim();
76
+ }
77
+ function parseSlashRegex(query) {
78
+ if (!query.startsWith("/") || query.length < 2)
79
+ return null;
80
+ const lastSlash = query.lastIndexOf("/");
81
+ if (lastSlash <= 0)
82
+ return null;
83
+ return {
84
+ source: query.slice(1, lastSlash),
85
+ flags: query.slice(lastSlash + 1)
86
+ };
87
+ }
88
+ function compileFileFilter(value) {
89
+ const raw = (value || "").trim();
90
+ if (!raw)
91
+ return { kind: "empty", match: () => true };
92
+ const slashRegex = parseSlashRegex(raw);
93
+ if (slashRegex) {
94
+ try {
95
+ const regex = new RegExp(slashRegex.source, slashRegex.flags);
96
+ return { kind: "regex", match: (path) => regex.test(path) };
97
+ } catch (error) {
98
+ return {
99
+ kind: "invalid",
100
+ match: () => false,
101
+ error: error instanceof Error ? error.message : String(error)
102
+ };
103
+ }
104
+ }
105
+ const q = normalizeFileFilterQuery(raw.startsWith("/") ? raw.slice(1) : raw);
106
+ return {
107
+ kind: "substring",
108
+ match: (path) => path.toLowerCase().includes(q)
109
+ };
110
+ }
111
+
112
+ // web-src/routes.ts
113
+ function assertNever(value) {
114
+ throw new Error("unhandled route: " + JSON.stringify(value));
115
+ }
116
+ function parseLegacyRange(value, fallback) {
117
+ const raw = value || "";
118
+ const sep = raw.indexOf("..");
119
+ if (sep < 0)
120
+ return fallback;
121
+ return {
122
+ from: raw.slice(0, sep) || fallback.from,
123
+ to: raw.slice(sep + 2) || fallback.to
124
+ };
125
+ }
126
+ function parseRoute(pathname, search, fallbackRange) {
127
+ const params = new URLSearchParams(search);
128
+ const legacyRange = parseLegacyRange(params.get("range"), fallbackRange);
129
+ const range = {
130
+ from: params.get("from") || legacyRange.from,
131
+ to: params.get("to") || legacyRange.to
132
+ };
133
+ switch (pathname) {
134
+ case "/":
135
+ case "/index.html":
136
+ return {
137
+ screen: "repo",
138
+ ref: params.get("ref") || params.get("target") || "worktree",
139
+ path: params.get("path") || "",
140
+ range
141
+ };
142
+ case "/todif":
143
+ case "/todiff":
144
+ return { screen: "diff", range };
145
+ case "/file": {
146
+ const path = params.get("path") || "";
147
+ const target = params.get("target") || "";
148
+ const ref = target || params.get("ref") || "worktree";
149
+ if (!path)
150
+ return { screen: "unknown", reason: "missing-path", rawPathname: pathname, rawSearch: search, range };
151
+ return { screen: "file", path, ref, range, view: target ? "blob" : "detail" };
152
+ }
153
+ default:
154
+ return { screen: "unknown", reason: "unknown-pathname", rawPathname: pathname, rawSearch: search, range };
155
+ }
156
+ }
157
+ function buildRoute(route) {
158
+ switch (route.screen) {
159
+ case "repo": {
160
+ const params = new URLSearchParams;
161
+ if (route.ref && route.ref !== "worktree")
162
+ params.set("ref", route.ref);
163
+ if (route.path)
164
+ params.set("path", route.path);
165
+ const qs = params.toString();
166
+ return "/" + (qs ? "?" + qs : "");
167
+ }
168
+ case "file":
169
+ if (route.view === "blob") {
170
+ return "/file?path=" + encodeURIComponent(route.path) + "&target=" + encodeURIComponent(route.ref || "worktree");
171
+ }
172
+ return "/file?path=" + encodeURIComponent(route.path) + "&ref=" + encodeURIComponent(route.ref || "worktree") + "&from=" + encodeURIComponent(route.range.from || "") + "&to=" + encodeURIComponent(route.range.to || "worktree");
173
+ case "diff":
174
+ return "/todif?from=" + encodeURIComponent(route.range.from || "") + "&to=" + encodeURIComponent(route.range.to || "worktree");
175
+ case "unknown":
176
+ return "/todif?from=" + encodeURIComponent(route.range.from || "") + "&to=" + encodeURIComponent(route.range.to || "worktree");
177
+ default:
178
+ return assertNever(route);
179
+ }
180
+ }
181
+ function buildRawFileUrl(target) {
182
+ return "/_file?path=" + encodeURIComponent(target.path) + "&ref=" + encodeURIComponent(target.ref || "worktree");
183
+ }
184
+
47
185
  // web-src/ws-highlight.ts
48
186
  function isWhitespaceOnlyInlineHighlight(text) {
49
187
  return !!text && !/\S/.test(text);
@@ -62,14 +200,35 @@
62
200
  // web-src/app.ts
63
201
  window.GdpExpandLogic = GdpExpandLogic;
64
202
  (() => {
203
+ const FOLDER_ICON_PATHS = {
204
+ closed: "M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z",
205
+ open: "M.513 1.513A1.75 1.75 0 0 1 1.75 1h3.5c.55 0 1.07.26 1.4.7l.9 1.2a.25.25 0 0 0 .2.1H13a1 1 0 0 1 1 1v.5H2.75a.75.75 0 0 0 0 1.5h11.978a1 1 0 0 1 .994 1.117L15 13.25A1.75 1.75 0 0 1 13.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75c0-.464.184-.91.513-1.237Z"
206
+ };
207
+ const CHEVRON_DOWN_12_PATH = "M6 8.825c-.2 0-.4-.1-.5-.2l-3.3-3.3c-.3-.3-.3-.8 0-1.1.3-.3.8-.3 1.1 0l2.7 2.7 2.7-2.7c.3-.3.8-.3 1.1 0 .3.3.3.8 0 1.1l-3.2 3.2c-.2.2-.4.3-.6.3Z";
208
+ const CHEVRON_DOWN_16_PATH = "M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z";
209
+ const COPY_16_PATHS = [
210
+ "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",
211
+ "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"
212
+ ];
213
+ const FILE_16_PATH = "M2 1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 12.25 16h-8.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 8 4.25V1.5Zm5.75.062V4.25c0 .138.112.25.25.25h2.688Z";
214
+ const UNFOLD_16_PATH = "m8.177.677 2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25a.75.75 0 0 1-1.5 0V4H5.104a.25.25 0 0 1-.177-.427L7.823.677a.25.25 0 0 1 .354 0ZM7.25 10.75a.75.75 0 0 1 1.5 0V12h2.146a.25.25 0 0 1 .177.427l-2.896 2.896a.25.25 0 0 1-.354 0l-2.896-2.896A.25.25 0 0 1 5.104 12H7.25v-1.25Zm-5-2a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z";
215
+ const FOLD_16_PATH = "M10.896 2H8.75V.75a.75.75 0 0 0-1.5 0V2H5.104a.25.25 0 0 0-.177.427l2.896 2.896a.25.25 0 0 0 .354 0l2.896-2.896A.25.25 0 0 0 10.896 2ZM8.75 15.25a.75.75 0 0 1-1.5 0V14H5.104a.25.25 0 0 1-.177-.427l2.896-2.896a.25.25 0 0 1 .354 0l2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25Zm-6.5-6.5a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z";
65
216
  const $ = (sel) => document.querySelector(sel);
66
217
  const $$ = (sel) => Array.from(document.querySelectorAll(sel));
67
218
  const diffCardSelector = (path) => '.gdp-file-shell[data-path="' + (window.CSS && CSS.escape ? CSS.escape(path) : path) + '"]';
68
219
  const HIGHLIGHT_SRC = "/vendor/highlight.js/highlight.min.js";
220
+ const DEFAULT_RANGE = { from: "HEAD", to: "worktree" };
69
221
  let highlightLoadPromise = null;
70
222
  let highlightConfigured = false;
223
+ let PROJECT_NAME = "";
71
224
  const STATE = (() => {
72
225
  const igRaw = localStorage.getItem("gdp:ignore-ws");
226
+ const fallbackRange = {
227
+ from: localStorage.getItem("gdp:from") || DEFAULT_RANGE.from,
228
+ to: localStorage.getItem("gdp:to") || DEFAULT_RANGE.to
229
+ };
230
+ const parsedRoute = parseRoute(window.location.pathname, window.location.search, fallbackRange);
231
+ const route = parsedRoute.screen === "unknown" ? { screen: "diff", range: parsedRoute.range } : parsedRoute;
73
232
  return {
74
233
  layout: localStorage.getItem("gdp:layout") || "side-by-side",
75
234
  theme: localStorage.getItem("gdp:theme") || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
@@ -77,14 +236,17 @@
77
236
  sbWidth: parseInt(localStorage.getItem("gdp:sbwidth")) || 308,
78
237
  collapsedDirs: new Set(JSON.parse(localStorage.getItem("gdp:collapsed-dirs") || "[]")),
79
238
  ignoreWs: igRaw === null ? true : igRaw === "1",
80
- from: localStorage.getItem("gdp:from") || "HEAD",
81
- to: localStorage.getItem("gdp:to") || "worktree",
239
+ from: route.range.from,
240
+ to: route.range.to,
82
241
  collapsed: false,
83
242
  files: [],
84
243
  activeFile: null,
85
244
  autoReload: localStorage.getItem("gdp:auto-reload") !== "0",
86
245
  hideTests: localStorage.getItem("gdp:hide-tests") === "1",
87
- syntaxHighlight: localStorage.getItem("gdp:syntax-highlight") !== "0"
246
+ syntaxHighlight: localStorage.getItem("gdp:syntax-highlight") !== "0",
247
+ viewedFiles: new Set(JSON.parse(localStorage.getItem("gdp:viewed-files") || "[]")),
248
+ route,
249
+ repoRef: route.screen === "repo" ? route.ref : "worktree"
88
250
  };
89
251
  })();
90
252
  function setStatus(s) {
@@ -191,6 +353,35 @@
191
353
  span.title = { M: "modified", A: "added", D: "deleted", R: "renamed" }[ch] || ch;
192
354
  return span;
193
355
  }
356
+ function persistViewedFiles() {
357
+ localStorage.setItem("gdp:viewed-files", JSON.stringify([...STATE.viewedFiles]));
358
+ }
359
+ function setFileViewed(path, viewed) {
360
+ if (viewed)
361
+ STATE.viewedFiles.add(path);
362
+ else
363
+ STATE.viewedFiles.delete(path);
364
+ persistViewedFiles();
365
+ applyViewedState();
366
+ }
367
+ function setFolderIcon(el, collapsed) {
368
+ const path = collapsed ? FOLDER_ICON_PATHS.closed : FOLDER_ICON_PATHS.open;
369
+ 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>';
370
+ }
371
+ function setChevronIcon(el) {
372
+ 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>';
373
+ }
374
+ function iconSvg(className, paths) {
375
+ const pathList = Array.isArray(paths) ? paths : [paths];
376
+ return '<svg class="octicon ' + className + '" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">' + pathList.map((path) => '<path fill="currentColor" d="' + path + '"></path>').join("") + "</svg>";
377
+ }
378
+ function setUnfoldButtonState(button, expanded) {
379
+ if (!button)
380
+ return;
381
+ button.setAttribute("aria-pressed", expanded ? "true" : "false");
382
+ button.title = expanded ? "Collapse expanded lines" : "Expand all lines";
383
+ button.innerHTML = expanded ? iconSvg("octicon-fold", FOLD_16_PATH) : iconSvg("octicon-unfold", UNFOLD_16_PATH);
384
+ }
194
385
  function buildTree(files) {
195
386
  const root = { name: "", dirs: {}, files: [], path: "", minOrder: Infinity };
196
387
  for (const f of files) {
@@ -226,7 +417,7 @@
226
417
  Object.values(root.dirs).forEach(compress);
227
418
  return root;
228
419
  }
229
- function renderTreeNode(node, depth, ul) {
420
+ function renderTreeNode(node, depth, ul, onFileClick) {
230
421
  const items = [];
231
422
  for (const k of Object.keys(node.dirs)) {
232
423
  const d = node.dirs[k];
@@ -245,7 +436,7 @@
245
436
  li.style.setProperty("--lvl-pad", 12 + depth * 14 + "px");
246
437
  const chev = document.createElement("span");
247
438
  chev.className = "chev";
248
- chev.textContent = "▾";
439
+ setChevronIcon(chev);
249
440
  li.appendChild(chev);
250
441
  const dirIcon = document.createElement("span");
251
442
  dirIcon.className = "dir-icon";
@@ -259,12 +450,12 @@
259
450
  if (collapsed)
260
451
  li.classList.add("collapsed");
261
452
  const updateIcon = () => {
262
- dirIcon.textContent = li.classList.contains("collapsed") ? "\uD83D\uDCC1" : "\uD83D\uDCC2";
453
+ setFolderIcon(dirIcon, li.classList.contains("collapsed"));
263
454
  };
264
455
  updateIcon();
265
456
  const childUl = document.createElement("ul");
266
457
  childUl.className = "tree-children";
267
- renderTreeNode(dir, depth + 1, childUl);
458
+ renderTreeNode(dir, depth + 1, childUl, onFileClick);
268
459
  li.addEventListener("click", (e) => {
269
460
  e.stopPropagation();
270
461
  li.classList.toggle("collapsed");
@@ -282,45 +473,76 @@
282
473
  const li = document.createElement("li");
283
474
  li.className = "tree-file";
284
475
  li.dataset.path = f.path;
476
+ li.classList.toggle("viewed", STATE.viewedFiles.has(f.path));
285
477
  li.style.setProperty("--lvl-pad", 12 + depth * 14 + "px");
286
- li.appendChild(fileBadge(f.status));
478
+ const spacer = document.createElement("span");
479
+ spacer.className = "chev-spacer";
480
+ li.appendChild(spacer);
481
+ if (f.status) {
482
+ li.appendChild(fileBadge(f.status));
483
+ } else {
484
+ const icon = document.createElement("span");
485
+ icon.className = "d2h-icon-wrapper";
486
+ icon.innerHTML = fileEntryIcon();
487
+ li.appendChild(icon);
488
+ }
287
489
  const name = document.createElement("span");
288
490
  name.className = "name";
289
491
  name.textContent = f.path.split("/").pop();
290
492
  name.title = f.path;
291
493
  li.appendChild(name);
292
- li.addEventListener("click", () => scrollToFile(f.path));
293
- li.addEventListener("mouseenter", () => prefetchByPath(f.path), { passive: true });
494
+ li.addEventListener("click", () => {
495
+ if (onFileClick)
496
+ onFileClick(f);
497
+ else
498
+ scrollToFile(f.path);
499
+ });
500
+ if (!onFileClick)
501
+ li.addEventListener("mouseenter", () => prefetchByPath(f.path), { passive: true });
294
502
  ul.appendChild(li);
295
503
  }
296
504
  }
297
505
  }
298
- function renderFlat(files, ul) {
506
+ function renderFlat(files, ul, onFileClick) {
299
507
  files.forEach((f, i) => {
300
508
  const li = document.createElement("li");
301
509
  li.dataset.index = String(i);
302
510
  li.dataset.path = f.path;
303
- li.appendChild(fileBadge(f.status));
511
+ li.classList.toggle("viewed", STATE.viewedFiles.has(f.path));
512
+ if (f.status) {
513
+ li.appendChild(fileBadge(f.status));
514
+ } else {
515
+ const icon = document.createElement("span");
516
+ icon.className = "d2h-icon-wrapper";
517
+ icon.innerHTML = fileEntryIcon();
518
+ li.appendChild(icon);
519
+ }
304
520
  const name = document.createElement("span");
305
521
  name.className = "name";
306
522
  name.textContent = f.path;
307
523
  name.title = f.path;
308
524
  li.appendChild(name);
309
- li.addEventListener("click", () => scrollToFile(f.path));
310
- li.addEventListener("mouseenter", () => prefetchByPath(f.path), { passive: true });
525
+ li.addEventListener("click", () => {
526
+ if (onFileClick)
527
+ onFileClick(f);
528
+ else
529
+ scrollToFile(f.path);
530
+ });
531
+ if (!onFileClick)
532
+ li.addEventListener("mouseenter", () => prefetchByPath(f.path), { passive: true });
311
533
  ul.appendChild(li);
312
534
  });
313
535
  }
314
- function renderSidebar(files) {
536
+ function renderSidebar(files, onFileClick) {
315
537
  const ul = $("#filelist");
316
538
  ul.innerHTML = "";
317
539
  ul.classList.toggle("tree", STATE.sbView === "tree");
318
540
  STATE.files = files;
319
541
  if (STATE.sbView === "tree") {
320
542
  const root = buildTree(files);
321
- renderTreeNode(root, 0, ul);
543
+ renderTreeNode(root, 0, ul, onFileClick);
322
544
  } else {
323
- renderFlat(files, ul);
545
+ renderFlat(files, ul, onFileClick);
324
546
  }
325
547
  $("#totals").textContent = files.length ? files.length + " file" + (files.length === 1 ? "" : "s") : "";
326
548
  $$(".sb-view-seg button").forEach((b) => {
@@ -330,20 +552,23 @@
330
552
  markActive(STATE.activeFile);
331
553
  applyFilter();
332
554
  }
555
+ function syncRepoTargetInput(ref) {
556
+ const input = document.querySelector("#repo-target");
557
+ const wrap = document.querySelector("#repo-target-wrap");
558
+ if (!input || !wrap)
559
+ return;
560
+ input.value = ref || "worktree";
561
+ wrap.hidden = !(STATE.route.screen === "file" && STATE.route.view === "blob");
562
+ }
333
563
  function renderMeta(meta) {
334
564
  const el = $("#meta");
335
565
  if (!meta) {
336
566
  el.textContent = "";
337
567
  return;
338
568
  }
569
+ PROJECT_NAME = meta.project || PROJECT_NAME;
339
570
  document.title = (meta.project ? meta.project + " - " : "") + "git diff preview";
340
571
  el.innerHTML = "";
341
- if (meta.range) {
342
- const r = document.createElement("span");
343
- r.className = "ref";
344
- r.textContent = meta.range;
345
- el.appendChild(r);
346
- }
347
572
  if (meta.branch) {
348
573
  const b = document.createElement("span");
349
574
  b.className = "ref";
@@ -397,19 +622,46 @@
397
622
  li.classList.toggle("active", li.dataset.path === path);
398
623
  });
399
624
  }
625
+ function applyViewedState() {
626
+ $$("#filelist li[data-path]").forEach((li) => {
627
+ const path = li.dataset.path || "";
628
+ li.classList.toggle("viewed", STATE.viewedFiles.has(path));
629
+ });
630
+ $$(".gdp-file-shell[data-path]").forEach((card) => {
631
+ const path = card.dataset.path || "";
632
+ const viewed = STATE.viewedFiles.has(path);
633
+ card.classList.toggle("viewed", viewed);
634
+ card.querySelectorAll(".d2h-file-collapse-input").forEach((checkbox) => {
635
+ checkbox.checked = viewed;
636
+ });
637
+ });
638
+ }
400
639
  function applyFilter() {
401
- const q = ($("#filter").value || "").toLowerCase().trim();
640
+ const input = $("#sb-filter");
641
+ const filter = compileFileFilter(input.value);
642
+ const invalid = filter.kind === "invalid";
643
+ input.toggleAttribute("aria-invalid", invalid);
644
+ input.title = invalid ? filter.error || "invalid regular expression" : "";
645
+ const matches = invalid ? () => true : filter.match;
402
646
  $$("#filelist li[data-path]").forEach((li) => {
403
- const match = !q || li.dataset.path.toLowerCase().includes(q);
647
+ const match = matches(li.dataset.path || "");
404
648
  li.classList.toggle("hidden", !match);
405
649
  });
650
+ document.querySelectorAll(".gdp-file-shell").forEach((card) => {
651
+ const match = matches(card.dataset.path || "");
652
+ card.classList.toggle("hidden-by-filter", !match);
653
+ });
654
+ updateTreeDirVisibility();
655
+ if (typeof applyViewedState === "function")
656
+ applyViewedState();
657
+ }
658
+ function updateTreeDirVisibility() {
406
659
  $$("#filelist .tree-dir").forEach((dir) => {
407
660
  const childUl = dir.nextElementSibling;
408
661
  if (!childUl || !childUl.classList.contains("tree-children"))
409
662
  return;
410
- const anyVisible = !!childUl.querySelector(".tree-file:not(.hidden)");
411
- const fullVisible = !q;
412
- dir.classList.toggle("hidden", !(fullVisible || anyVisible));
663
+ const anyVisible = !!childUl.querySelector(".tree-file:not(.hidden):not(.hidden-by-tests)");
664
+ dir.classList.toggle("hidden", !anyVisible);
413
665
  });
414
666
  }
415
667
  let SERVER_GENERATION = 0;
@@ -418,6 +670,7 @@
418
670
  let ACTIVE_LOADS = 0;
419
671
  const MAX_PARALLEL = 2;
420
672
  let lazyObserver = null;
673
+ let SOURCE_REQ_SEQ = 0;
421
674
  let IN_FLIGHT = 0;
422
675
  function updateLoadBar() {
423
676
  const el = $("#load-bar");
@@ -442,6 +695,65 @@
442
695
  function escapeHtml(s) {
443
696
  return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
444
697
  }
698
+ function sourceTargetsEqual(a, b) {
699
+ return !!a && !!b && a.path === b.path && a.ref === b.ref;
700
+ }
701
+ function fileSourceTarget(file) {
702
+ if ((file.status || "").startsWith("D")) {
703
+ return { path: file.old_path || file.path, ref: STATE.from || "HEAD" };
704
+ }
705
+ const ref = STATE.to && STATE.to !== "worktree" ? STATE.to : "worktree";
706
+ return { path: file.path, ref };
707
+ }
708
+ function currentRange() {
709
+ return { from: STATE.from || DEFAULT_RANGE.from, to: STATE.to || DEFAULT_RANGE.to };
710
+ }
711
+ function sourceTargetFromRoute() {
712
+ return STATE.route.screen === "file" ? { path: STATE.route.path, ref: STATE.route.ref } : null;
713
+ }
714
+ function repoFileTargetFromRoute() {
715
+ return STATE.route.screen === "file" && STATE.route.view === "blob" ? STATE.route.ref : null;
716
+ }
717
+ function setRoute(route, replace = false) {
718
+ const nextRoute = route.screen === "unknown" ? { screen: "diff", range: route.range } : route;
719
+ STATE.route = nextRoute;
720
+ STATE.from = nextRoute.range.from;
721
+ STATE.to = nextRoute.range.to;
722
+ if (nextRoute.screen === "repo" || nextRoute.screen === "file" && nextRoute.view === "blob") {
723
+ STATE.repoRef = nextRoute.ref || "worktree";
724
+ }
725
+ const url = buildRoute(nextRoute);
726
+ const state = nextRoute.screen === "file" ? { screen: "file", path: nextRoute.path, ref: nextRoute.ref, view: nextRoute.view || "detail" } : { view: nextRoute.screen };
727
+ if (replace)
728
+ history.replaceState(state, "", url);
729
+ else
730
+ history.pushState(state, "", url);
731
+ syncHeaderMenu();
732
+ }
733
+ function setPageMode() {
734
+ document.body.classList.toggle("gdp-file-detail-page", STATE.route.screen === "file");
735
+ document.body.classList.toggle("gdp-repo-blob-page", STATE.route.screen === "file" && STATE.route.view === "blob");
736
+ document.body.classList.toggle("gdp-repo-page", STATE.route.screen === "repo");
737
+ syncRepoTargetInput(repoFileTargetFromRoute() || "worktree");
738
+ }
739
+ function syncHeaderMenu() {
740
+ document.querySelectorAll(".app-menu-item").forEach((link) => {
741
+ const fileRouteOwner = STATE.route.screen === "file" && STATE.route.view === "blob" ? "repo" : "diff";
742
+ const active = link.dataset.route === STATE.route.screen || STATE.route.screen === "file" && link.dataset.route === fileRouteOwner;
743
+ link.classList.toggle("active", active);
744
+ link.setAttribute("aria-current", active ? "page" : "false");
745
+ if (link.dataset.route === "repo") {
746
+ link.href = buildRoute({ screen: "repo", ref: STATE.repoRef || "worktree", path: "", range: currentRange() });
747
+ }
748
+ if (link.dataset.route === "diff") {
749
+ link.href = buildRoute({ screen: "diff", range: currentRange() });
750
+ }
751
+ });
752
+ }
753
+ function removeStandaloneSource() {
754
+ document.querySelectorAll(".gdp-standalone-source").forEach((el) => el.remove());
755
+ document.querySelectorAll(".gdp-repo-blob-layout").forEach((el) => el.remove());
756
+ }
445
757
  function renderShell(meta) {
446
758
  const newFiles = meta.files || [];
447
759
  STATE.files = newFiles;
@@ -506,9 +818,229 @@
506
818
  }
507
819
  setupLazyObserver();
508
820
  enqueueInitialLoads();
821
+ applySourceRouteToShell();
509
822
  setupScrollSpy();
510
823
  if (typeof applyHideTests === "function")
511
824
  applyHideTests();
825
+ applyFilter();
826
+ applyViewedState();
827
+ }
828
+ function fileEntryIcon() {
829
+ return iconSvg("octicon-file", FILE_16_PATH);
830
+ }
831
+ function repoRoute(ref, path) {
832
+ return { screen: "repo", ref: ref || "worktree", path, range: currentRange() };
833
+ }
834
+ function wireRepoTargetPicker(input, onPick) {
835
+ input.addEventListener("focus", () => openPopover(input));
836
+ input.addEventListener("click", (e) => {
837
+ e.stopPropagation();
838
+ openPopover(input);
839
+ });
840
+ input.addEventListener("mousedown", (e) => {
841
+ if (popover.hidden) {
842
+ e.preventDefault();
843
+ input.focus();
844
+ }
845
+ });
846
+ input.addEventListener("keydown", (e) => {
847
+ if (e.key === "Enter") {
848
+ e.preventDefault();
849
+ closePopover();
850
+ } else if (e.key === "Escape") {
851
+ closePopover();
852
+ input.blur();
853
+ }
854
+ });
855
+ input.addEventListener("change", () => onPick(input.value || "worktree"));
856
+ }
857
+ function createRepoBreadcrumb(target, path) {
858
+ const nav = document.createElement("nav");
859
+ nav.className = "gdp-file-breadcrumb gdp-repo-breadcrumb";
860
+ const root = document.createElement("button");
861
+ root.type = "button";
862
+ root.className = path ? "gdp-file-breadcrumb-part" : "gdp-file-breadcrumb-current";
863
+ root.textContent = PROJECT_NAME || "repository";
864
+ root.addEventListener("click", () => {
865
+ setRoute(repoRoute(target, ""));
866
+ loadRepo();
867
+ });
868
+ nav.appendChild(root);
869
+ const parts = path ? path.split("/") : [];
870
+ parts.forEach((part, index) => {
871
+ const sep = document.createElement("span");
872
+ sep.className = "gdp-file-breadcrumb-sep";
873
+ sep.textContent = "/";
874
+ nav.appendChild(sep);
875
+ const currentPath = parts.slice(0, index + 1).join("/");
876
+ const button = document.createElement("button");
877
+ button.type = "button";
878
+ button.className = index === parts.length - 1 ? "gdp-file-breadcrumb-current" : "gdp-file-breadcrumb-part";
879
+ button.textContent = part;
880
+ button.disabled = index === parts.length - 1;
881
+ button.addEventListener("click", () => {
882
+ setRoute(repoRoute(target, currentPath));
883
+ loadRepo();
884
+ });
885
+ nav.appendChild(button);
886
+ });
887
+ return nav;
888
+ }
889
+ function renderRepo(meta) {
890
+ PROJECT_NAME = meta.project || PROJECT_NAME;
891
+ setPageMode();
892
+ removeStandaloneSource();
893
+ $("#empty").classList.add("hidden");
894
+ $("#diff").replaceChildren();
895
+ $("#filelist").replaceChildren();
896
+ $("#totals").textContent = "";
897
+ STATE.files = [];
898
+ LOAD_QUEUE.length = 0;
899
+ const target = $("#diff");
900
+ const shell = document.createElement("section");
901
+ shell.className = "gdp-repo-shell";
902
+ const targetPicker = document.createElement("input");
903
+ targetPicker.className = "ref-input gdp-repo-target";
904
+ targetPicker.id = "repo-ref";
905
+ targetPicker.readOnly = true;
906
+ targetPicker.autocomplete = "off";
907
+ targetPicker.value = meta.ref || "worktree";
908
+ targetPicker.placeholder = "ref...";
909
+ targetPicker.title = "repository ref";
910
+ wireRepoTargetPicker(targetPicker, (ref) => {
911
+ setRoute(repoRoute(ref, ""));
912
+ loadRepo();
913
+ });
914
+ const toolbar = document.createElement("div");
915
+ toolbar.className = "gdp-file-detail-header gdp-repo-toolbar";
916
+ toolbar.append(createRepoBreadcrumb(meta.ref, meta.path || ""), targetPicker);
917
+ shell.appendChild(toolbar);
918
+ const listCard = document.createElement("section");
919
+ listCard.className = "gdp-file-shell loaded gdp-repo-list-shell";
920
+ const listWrapper = document.createElement("div");
921
+ listWrapper.className = "d2h-file-wrapper";
922
+ const listHeader = document.createElement("div");
923
+ listHeader.className = "d2h-file-header";
924
+ const listName = document.createElement("div");
925
+ listName.className = "d2h-file-name-wrapper";
926
+ const listIcon = document.createElement("span");
927
+ listIcon.className = "dir-icon";
928
+ setFolderIcon(listIcon, false);
929
+ const listTitle = document.createElement("span");
930
+ listTitle.className = "d2h-file-name";
931
+ listTitle.textContent = meta.path || meta.project || "Files";
932
+ listName.append(listIcon, listTitle);
933
+ listHeader.appendChild(listName);
934
+ listWrapper.appendChild(listHeader);
935
+ const list = document.createElement("div");
936
+ list.className = "gdp-source-viewer gdp-repo-file-list";
937
+ if (meta.path) {
938
+ const parent = meta.path.split("/").slice(0, -1).join("/");
939
+ const row = document.createElement("button");
940
+ row.type = "button";
941
+ row.className = "gdp-repo-row parent";
942
+ const parentIcon = document.createElement("span");
943
+ parentIcon.className = "dir-icon";
944
+ setFolderIcon(parentIcon, false);
945
+ const parentName = document.createElement("span");
946
+ parentName.className = "name";
947
+ parentName.textContent = "..";
948
+ const parentKind = document.createElement("span");
949
+ parentKind.className = "kind";
950
+ parentKind.textContent = "parent";
951
+ row.append(parentIcon, parentName, parentKind);
952
+ row.addEventListener("click", () => {
953
+ setRoute(repoRoute(meta.ref, parent));
954
+ loadRepo();
955
+ });
956
+ list.appendChild(row);
957
+ }
958
+ meta.entries.forEach((entry) => {
959
+ const row = document.createElement("button");
960
+ row.type = "button";
961
+ row.className = "gdp-repo-row " + entry.type;
962
+ const icon = document.createElement("span");
963
+ icon.className = entry.type === "tree" ? "dir-icon" : "d2h-icon-wrapper";
964
+ if (entry.type === "tree")
965
+ setFolderIcon(icon, true);
966
+ else
967
+ icon.innerHTML = fileEntryIcon();
968
+ const name = document.createElement("span");
969
+ name.className = "name";
970
+ name.textContent = entry.name;
971
+ const kind = document.createElement("span");
972
+ kind.className = "kind";
973
+ kind.textContent = entry.type === "tree" ? "directory" : entry.type === "commit" ? "submodule" : "file";
974
+ row.append(icon, name, kind);
975
+ row.addEventListener("click", () => {
976
+ if (entry.type === "tree") {
977
+ setRoute(repoRoute(meta.ref, entry.path));
978
+ loadRepo();
979
+ } else if (entry.type === "blob") {
980
+ setRoute({ screen: "file", path: entry.path, ref: meta.ref, view: "blob", range: currentRange() });
981
+ renderStandaloneSource({ path: entry.path, ref: meta.ref });
982
+ }
983
+ });
984
+ list.appendChild(row);
985
+ });
986
+ if (!meta.entries.length) {
987
+ const empty = document.createElement("div");
988
+ empty.className = "gdp-repo-empty";
989
+ empty.textContent = "No files in this directory.";
990
+ list.appendChild(empty);
991
+ }
992
+ listWrapper.appendChild(list);
993
+ listCard.appendChild(listWrapper);
994
+ shell.appendChild(listCard);
995
+ if (meta.readme && meta.readme.text) {
996
+ const readme = document.createElement("section");
997
+ readme.className = "gdp-file-shell loaded gdp-repo-readme";
998
+ const wrapper = document.createElement("div");
999
+ wrapper.className = "d2h-file-wrapper";
1000
+ const readmeHeader = document.createElement("div");
1001
+ readmeHeader.className = "d2h-file-header";
1002
+ const nameWrapper = document.createElement("div");
1003
+ nameWrapper.className = "d2h-file-name-wrapper";
1004
+ const icon = document.createElement("span");
1005
+ icon.className = "d2h-icon-wrapper";
1006
+ icon.innerHTML = iconSvg("octicon-file", FILE_16_PATH);
1007
+ const name = document.createElement("span");
1008
+ name.className = "d2h-file-name";
1009
+ name.textContent = meta.readme.path;
1010
+ nameWrapper.append(icon, name);
1011
+ readmeHeader.appendChild(nameWrapper);
1012
+ wrapper.appendChild(readmeHeader);
1013
+ wrapper.appendChild(renderMarkdownPreview(meta.readme.text, { path: meta.readme.path, ref: meta.ref }, getHljs()));
1014
+ readme.appendChild(wrapper);
1015
+ shell.appendChild(readme);
1016
+ }
1017
+ target.appendChild(shell);
1018
+ }
1019
+ function renderRepoBlobSidebar(currentPath, ref) {
1020
+ syncRepoTargetInput(ref);
1021
+ const params = new URLSearchParams;
1022
+ params.set("ref", ref || "worktree");
1023
+ params.set("recursive", "1");
1024
+ return trackLoad(fetch("/_tree?" + params.toString()).then((r) => {
1025
+ if (!r.ok)
1026
+ throw new Error("failed to load repository tree");
1027
+ return r.json();
1028
+ })).then((meta) => {
1029
+ const files = meta.entries.filter((entry) => entry.type !== "tree").map((entry, index) => ({
1030
+ order: index + 1,
1031
+ path: entry.path,
1032
+ display_path: entry.path
1033
+ }));
1034
+ renderSidebar(files, (file) => {
1035
+ setRoute({ screen: "file", path: file.path, ref, view: "blob", range: currentRange() });
1036
+ renderStandaloneSource({ path: file.path, ref });
1037
+ });
1038
+ markActive(currentPath);
1039
+ applyFilter();
1040
+ }).catch(() => {
1041
+ renderSidebar([], undefined);
1042
+ $("#totals").textContent = "Cannot load tree";
1043
+ });
512
1044
  }
513
1045
  function createPlaceholder(f) {
514
1046
  const card = document.createElement("div");
@@ -517,6 +1049,7 @@
517
1049
  card.dataset.key = f.key || f.path;
518
1050
  card.dataset.sizeClass = f.size_class || "small";
519
1051
  card.dataset.status = f.status || "M";
1052
+ card.classList.toggle("viewed", STATE.viewedFiles.has(f.path));
520
1053
  if (f.estimated_height_px) {
521
1054
  card.style.minHeight = f.estimated_height_px + "px";
522
1055
  }
@@ -733,7 +1266,8 @@
733
1266
  outputFormat: layout,
734
1267
  synchronisedScroll: true,
735
1268
  highlight: !!(STATE.syntaxHighlight && file.highlight && hljsRef),
736
- fileListToggle: false
1269
+ fileListToggle: false,
1270
+ fileContentToggle: false
737
1271
  }, hljsRef);
738
1272
  ui.draw();
739
1273
  if (STATE.ignoreWs)
@@ -889,8 +1423,8 @@
889
1423
  else
890
1424
  item.bottomExpandedEnd = end;
891
1425
  for (const sib of item.siblings || [{ tr: item.tr }]) {
892
- const ln2 = sib.tr.querySelector(".d2h-code-linenumber.d2h-info, .d2h-code-side-linenumber.d2h-info");
893
- const old = ln2 && ln2.querySelector(".gdp-expand-stack");
1426
+ const ln = sib.tr.querySelector(".d2h-code-linenumber.d2h-info, .d2h-code-side-linenumber.d2h-info");
1427
+ const old = ln && ln.querySelector(".gdp-expand-stack");
894
1428
  if (old)
895
1429
  old.remove();
896
1430
  }
@@ -903,46 +1437,131 @@
903
1437
  const remainingSize = remainingEnd - remainingStart + 1;
904
1438
  const isFirst = prevHunkEndNew === 0;
905
1439
  const buildStack = () => {
906
- const stack = document.createElement("div");
907
- stack.className = "gdp-expand-stack";
908
- const mkBtn = (path, title, fn) => {
909
- const b = document.createElement("button");
910
- b.className = "gdp-expand-btn";
911
- b.title = title;
912
- b.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">' + '<path fill="currentColor" d="' + path + '"/></svg>';
913
- b.addEventListener("click", (e) => {
914
- e.stopPropagation();
915
- if (b.disabled)
916
- return;
917
- fn();
918
- });
919
- return b;
920
- };
921
- const upPath = "M8 3.5 3.75 7.75l1.06 1.06L7.25 6.37V13h1.5V6.37l2.44 2.44 1.06-1.06L8 3.5z";
922
- const downPath = "M8 12.5 12.25 8.25l-1.06-1.06L8.75 9.63V3h-1.5v6.63L4.81 7.19 3.75 8.25 8 12.5z";
1440
+ const buttons = [];
923
1441
  if (isFirst) {
924
- stack.appendChild(mkBtn(upPath, "Show " + Math.min(STEP, remainingSize) + " more lines", () => fetchAndInsert(Math.max(remainingStart, remainingEnd - STEP + 1), remainingEnd, "after")));
1442
+ buttons.push({
1443
+ direction: "up",
1444
+ title: "Show " + Math.min(STEP, remainingSize) + " more lines",
1445
+ onClick: () => fetchAndInsert(Math.max(remainingStart, remainingEnd - STEP + 1), remainingEnd, "after")
1446
+ });
925
1447
  } else {
926
- stack.appendChild(mkBtn(upPath, "Show " + Math.min(STEP, remainingSize) + " more lines", () => fetchAndInsert(remainingStart, Math.min(remainingEnd, remainingStart + STEP - 1), "before")));
927
- stack.appendChild(mkBtn(downPath, "Show " + Math.min(STEP, remainingSize) + " more lines", () => fetchAndInsert(Math.max(remainingStart, remainingEnd - STEP + 1), remainingEnd, "after")));
1448
+ buttons.push({
1449
+ direction: "up",
1450
+ title: "Show " + Math.min(STEP, remainingSize) + " more lines",
1451
+ onClick: () => fetchAndInsert(remainingStart, Math.min(remainingEnd, remainingStart + STEP - 1), "before")
1452
+ });
1453
+ buttons.push({
1454
+ direction: "down",
1455
+ title: "Show " + Math.min(STEP, remainingSize) + " more lines",
1456
+ onClick: () => fetchAndInsert(Math.max(remainingStart, remainingEnd - STEP + 1), remainingEnd, "after")
1457
+ });
928
1458
  }
929
- return stack;
1459
+ return createExpandStack(buttons);
930
1460
  };
931
- const firstSib = item.siblings && item.siblings[0] || { tr: item.tr };
932
- const ln = firstSib.tr.querySelector(".d2h-code-linenumber.d2h-info, .d2h-code-side-linenumber.d2h-info");
933
- if (ln && !ln.querySelector(".gdp-expand-stack")) {
934
- ln.appendChild(buildStack());
1461
+ const siblings = item.siblings || [{ tr: item.tr }];
1462
+ siblings.forEach((sib) => {
1463
+ const ln = sib.tr.querySelector(".d2h-code-linenumber.d2h-info, .d2h-code-side-linenumber.d2h-info");
1464
+ if (ln && !ln.querySelector(".gdp-expand-stack")) {
1465
+ ln.appendChild(buildStack());
1466
+ }
1467
+ });
1468
+ const firstSib = siblings[0];
1469
+ if (firstSib) {
1470
+ syncExpandRowHeights(siblings.map((sib) => sib.tr), firstSib.tr);
935
1471
  }
1472
+ }
1473
+ const EXPAND_ICON_PATHS = {
1474
+ up: "M8 3.5 3.75 7.75l1.06 1.06L7.25 6.37V13h1.5V6.37l2.44 2.44 1.06-1.06L8 3.5z",
1475
+ down: "M8 12.5 12.25 8.25l-1.06-1.06L8.75 9.63V3h-1.5v6.63L4.81 7.19 3.75 8.25 8 12.5z"
1476
+ };
1477
+ function createExpandStack(buttons) {
1478
+ const stack = document.createElement("div");
1479
+ stack.className = "gdp-expand-stack";
1480
+ buttons.forEach((spec) => {
1481
+ const button = document.createElement("button");
1482
+ button.className = "gdp-expand-btn";
1483
+ button.title = spec.title;
1484
+ 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>';
1485
+ button.addEventListener("click", (e) => {
1486
+ e.stopPropagation();
1487
+ if (button.disabled)
1488
+ return;
1489
+ spec.onClick();
1490
+ });
1491
+ stack.appendChild(button);
1492
+ });
1493
+ return stack;
1494
+ }
1495
+ function syncExpandRowHeights(rows, stackRow) {
936
1496
  const syncHeight = () => {
937
- const stack = firstSib.tr.querySelector(".gdp-expand-stack");
1497
+ const stack = stackRow.querySelector(".gdp-expand-stack");
938
1498
  const targetH = stack ? Math.max(20, stack.getBoundingClientRect().height) : 20;
939
- for (const sib of item.siblings || [{ tr: item.tr }]) {
940
- sib.tr.style.setProperty("height", targetH + "px", "important");
941
- }
1499
+ rows.forEach((row) => row.style.setProperty("height", targetH + "px", "important"));
942
1500
  };
943
1501
  requestAnimationFrame(syncHeight);
944
1502
  setTimeout(syncHeight, 100);
945
1503
  }
1504
+ function attachTrailingExpandControls(item, file, ref, refPath) {
1505
+ const STEP = 20;
1506
+ let nextNewStart = nextNewLine(item.hunk);
1507
+ let nextOldStart = nextOldLine(item.hunk);
1508
+ const rows = (item.siblings || [{ tr: item.tr, sideIndex: 0 }]).map((sib) => {
1509
+ const tbody = sib.tr.parentElement;
1510
+ if (!tbody)
1511
+ return null;
1512
+ const isSplit = !!sib.tr.querySelector("td.d2h-code-side-linenumber");
1513
+ const tr = document.createElement("tr");
1514
+ tr.className = "gdp-hunk-row gdp-trailing-expand-row";
1515
+ const ln = document.createElement("td");
1516
+ ln.className = isSplit ? "d2h-code-side-linenumber d2h-info" : "d2h-code-linenumber d2h-info";
1517
+ const info = document.createElement("td");
1518
+ info.className = "d2h-info";
1519
+ const spacer = document.createElement("div");
1520
+ spacer.className = isSplit ? "d2h-code-side-line" : "d2h-code-line";
1521
+ info.appendChild(spacer);
1522
+ tr.appendChild(ln);
1523
+ tr.appendChild(info);
1524
+ tbody.appendChild(tr);
1525
+ return { tr, ln, sideIndex: sib.sideIndex || 0 };
1526
+ }).filter(Boolean);
1527
+ if (!rows.length)
1528
+ return;
1529
+ const setBusy = (busy) => {
1530
+ rows.forEach((row) => row.ln.querySelectorAll(".gdp-expand-btn").forEach((btn) => {
1531
+ btn.disabled = busy;
1532
+ }));
1533
+ };
1534
+ const fetchAndInsert = () => {
1535
+ const range = window.GdpExpandLogic.trailingClickRange(nextNewStart, STEP);
1536
+ setBusy(true);
1537
+ const url = "/file_range?path=" + refPath + "&ref=" + encodeURIComponent(ref) + "&start=" + range.start + "&end=" + range.end;
1538
+ trackLoad(fetch(url).then((r) => r.json())).then((data) => {
1539
+ const lines = data && data.lines || [];
1540
+ if (!lines.length) {
1541
+ rows.forEach((row) => row.tr.remove());
1542
+ return;
1543
+ }
1544
+ const card = item.tr.closest(".d2h-file-wrapper");
1545
+ rows.forEach((row) => insertContextRows(row.tr, lines, range.start, nextOldStart, "before", row.sideIndex));
1546
+ const next = window.GdpExpandLogic.applyTrailingResult({ newStart: nextNewStart, oldStart: nextOldStart }, lines.length, STEP);
1547
+ nextNewStart = next.newStart;
1548
+ nextOldStart = next.oldStart;
1549
+ if (card)
1550
+ highlightInsertedSpans(card, file);
1551
+ if (next.eof) {
1552
+ rows.forEach((row) => row.tr.remove());
1553
+ return;
1554
+ }
1555
+ setBusy(false);
1556
+ }).catch(() => {
1557
+ setBusy(false);
1558
+ });
1559
+ };
1560
+ rows.forEach((row) => {
1561
+ row.ln.appendChild(createExpandStack([{ direction: "down", title: "Show more lines", onClick: fetchAndInsert }]));
1562
+ });
1563
+ syncExpandRowHeights(rows.map((row) => row.tr), rows[0].tr);
1564
+ }
946
1565
  function insertContextRows(targetTr, lines, newStart, oldStart, dir, sideIndex) {
947
1566
  const tbody = targetTr.parentElement;
948
1567
  if (!tbody)
@@ -970,10 +1589,556 @@
970
1589
  function escapeHtmlText(s) {
971
1590
  return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
972
1591
  }
1592
+ function setFileCollapsed(card, collapsed) {
1593
+ card.classList.toggle("gdp-file-collapsed", collapsed);
1594
+ card.querySelectorAll(".d2h-files-diff, .d2h-file-diff, .gdp-source-viewer, .gdp-media").forEach((body) => {
1595
+ body.classList.toggle("d2h-d-none", collapsed);
1596
+ });
1597
+ const button = card.querySelector(".gdp-file-toggle");
1598
+ if (button) {
1599
+ button.setAttribute("aria-expanded", collapsed ? "false" : "true");
1600
+ button.title = collapsed ? "Expand file" : "Collapse file";
1601
+ }
1602
+ const unfold = card.querySelector(".gdp-file-unfold");
1603
+ if (unfold)
1604
+ unfold.disabled = collapsed;
1605
+ const viewFile = card.querySelector(".gdp-view-file");
1606
+ if (viewFile)
1607
+ viewFile.disabled = collapsed;
1608
+ }
1609
+ function setViewFileButtonState(button, sourceMode) {
1610
+ if (!button)
1611
+ return;
1612
+ button.classList.add("gdp-btn", "gdp-btn-sm");
1613
+ button.textContent = sourceMode ? "View Diff" : "View File";
1614
+ button.setAttribute("aria-pressed", sourceMode ? "true" : "false");
1615
+ button.title = sourceMode ? "View diff" : "View file";
1616
+ }
1617
+ function renderSourceLoading(card, target) {
1618
+ const body = card.querySelector(".gdp-file-detail-body, .d2h-files-diff, .d2h-file-diff, .gdp-media, .gdp-source-viewer");
1619
+ const view = document.createElement("div");
1620
+ view.className = "gdp-source-viewer loading";
1621
+ view.textContent = "Loading " + target.path + " at " + target.ref + "...";
1622
+ if (body)
1623
+ body.replaceWith(view);
1624
+ else
1625
+ card.appendChild(view);
1626
+ }
1627
+ function renderSourceError(card, target, message) {
1628
+ const body = card.querySelector(".gdp-file-detail-body, .d2h-files-diff, .d2h-file-diff, .gdp-media, .gdp-source-viewer");
1629
+ const view = document.createElement("div");
1630
+ view.className = "gdp-source-viewer error";
1631
+ view.textContent = message || "Cannot load " + target.path + " at " + target.ref;
1632
+ if (body)
1633
+ body.replaceWith(view);
1634
+ else
1635
+ card.appendChild(view);
1636
+ }
1637
+ function isPreviewableSource(path) {
1638
+ return /\.(md|markdown|mdown|mkdn|mdx)$/i.test(path);
1639
+ }
1640
+ function appendInlineMarkdown(parent, text) {
1641
+ const parts = text.split(/(`[^`]+`)/g);
1642
+ parts.forEach((part) => {
1643
+ if (part.startsWith("`") && part.endsWith("`") && part.length > 1) {
1644
+ const code = document.createElement("code");
1645
+ code.textContent = part.slice(1, -1);
1646
+ parent.appendChild(code);
1647
+ } else {
1648
+ parent.appendChild(document.createTextNode(part));
1649
+ }
1650
+ });
1651
+ }
1652
+ function appendMarkdownParagraph(markdown, lines) {
1653
+ if (!lines.length)
1654
+ return;
1655
+ const p = document.createElement("p");
1656
+ appendInlineMarkdown(p, lines.join(" ").trim());
1657
+ markdown.appendChild(p);
1658
+ }
1659
+ function renderMarkdownPreview(textValue, target, hljsRef) {
1660
+ const markdown = document.createElement("div");
1661
+ markdown.className = "gdp-markdown-preview markdown-body";
1662
+ const lines = textValue.replace(/\r\n/g, `
1663
+ `).replace(/\r/g, `
1664
+ `).split(`
1665
+ `);
1666
+ let paragraph = [];
1667
+ let list = null;
1668
+ const flushParagraph = () => {
1669
+ appendMarkdownParagraph(markdown, paragraph);
1670
+ paragraph = [];
1671
+ };
1672
+ const flushList = () => {
1673
+ list = null;
1674
+ };
1675
+ for (let i = 0;i < lines.length; i++) {
1676
+ const line = lines[i];
1677
+ const fence = line.match(/^```(\S*)\s*$/);
1678
+ if (fence) {
1679
+ flushParagraph();
1680
+ flushList();
1681
+ const codeLines = [];
1682
+ i++;
1683
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) {
1684
+ codeLines.push(lines[i]);
1685
+ i++;
1686
+ }
1687
+ const pre = document.createElement("pre");
1688
+ const code = document.createElement("code");
1689
+ const lang = fence[1] || inferLang(target.path) || "";
1690
+ const raw = codeLines.join(`
1691
+ `);
1692
+ if (hljsRef && hljsRef.highlight && lang && (!hljsRef.getLanguage || hljsRef.getLanguage(lang))) {
1693
+ try {
1694
+ code.innerHTML = hljsRef.highlight(raw, { language: lang, ignoreIllegals: true }).value;
1695
+ code.classList.add("hljs");
1696
+ } catch {
1697
+ code.textContent = raw;
1698
+ }
1699
+ } else {
1700
+ code.textContent = raw;
1701
+ }
1702
+ pre.appendChild(code);
1703
+ markdown.appendChild(pre);
1704
+ continue;
1705
+ }
1706
+ if (!line.trim()) {
1707
+ flushParagraph();
1708
+ flushList();
1709
+ continue;
1710
+ }
1711
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
1712
+ if (heading) {
1713
+ flushParagraph();
1714
+ flushList();
1715
+ const level = String(Math.min(heading[1].length, 6));
1716
+ const h = document.createElement("h" + level);
1717
+ appendInlineMarkdown(h, heading[2]);
1718
+ markdown.appendChild(h);
1719
+ continue;
1720
+ }
1721
+ if (/^\s*---+\s*$/.test(line)) {
1722
+ flushParagraph();
1723
+ flushList();
1724
+ markdown.appendChild(document.createElement("hr"));
1725
+ continue;
1726
+ }
1727
+ const bullet = line.match(/^\s*[-*+]\s+(.+)$/);
1728
+ const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
1729
+ if (bullet || ordered) {
1730
+ flushParagraph();
1731
+ const tag = ordered ? "ol" : "ul";
1732
+ if (!list || list.tagName.toLowerCase() !== tag) {
1733
+ list = document.createElement(tag);
1734
+ markdown.appendChild(list);
1735
+ }
1736
+ const li = document.createElement("li");
1737
+ appendInlineMarkdown(li, (bullet || ordered)[1]);
1738
+ list.appendChild(li);
1739
+ continue;
1740
+ }
1741
+ const quote = line.match(/^\s*>\s?(.*)$/);
1742
+ if (quote) {
1743
+ flushParagraph();
1744
+ flushList();
1745
+ const blockquote = document.createElement("blockquote");
1746
+ appendInlineMarkdown(blockquote, quote[1]);
1747
+ markdown.appendChild(blockquote);
1748
+ continue;
1749
+ }
1750
+ flushList();
1751
+ paragraph.push(line);
1752
+ }
1753
+ flushParagraph();
1754
+ return markdown;
1755
+ }
1756
+ async function renderSourceText(card, target, textValue) {
1757
+ const lines = textValue.length ? textValue.replace(/\r\n/g, `
1758
+ `).replace(/\r/g, `
1759
+ `).split(`
1760
+ `) : [""];
1761
+ const body = card.querySelector(".gdp-file-detail-body, .d2h-files-diff, .d2h-file-diff, .gdp-media, .gdp-source-viewer");
1762
+ const isStandalone = card.classList.contains("gdp-standalone-source");
1763
+ const view = document.createElement("div");
1764
+ view.className = "gdp-source-viewer";
1765
+ const header = isStandalone ? null : document.createElement("div");
1766
+ if (header) {
1767
+ header.className = "gdp-source-meta";
1768
+ header.textContent = target.path + " @ " + target.ref;
1769
+ }
1770
+ const table = document.createElement("table");
1771
+ table.className = "gdp-source-table";
1772
+ const tbody = document.createElement("tbody");
1773
+ const hljsRef = await loadSyntaxHighlighter();
1774
+ const lang = inferLang(target.path);
1775
+ lines.forEach((line, index) => {
1776
+ const tr = document.createElement("tr");
1777
+ const num = document.createElement("td");
1778
+ num.className = "gdp-source-line-number";
1779
+ num.textContent = String(index + 1);
1780
+ const code = document.createElement("td");
1781
+ code.className = "gdp-source-line-code";
1782
+ if (hljsRef && hljsRef.highlight && lang && (!hljsRef.getLanguage || hljsRef.getLanguage(lang))) {
1783
+ try {
1784
+ code.innerHTML = hljsRef.highlight(line || " ", { language: lang, ignoreIllegals: true }).value;
1785
+ code.classList.add("hljs");
1786
+ } catch {
1787
+ code.textContent = line || " ";
1788
+ }
1789
+ } else {
1790
+ code.textContent = line || " ";
1791
+ }
1792
+ tr.appendChild(num);
1793
+ tr.appendChild(code);
1794
+ tbody.appendChild(tr);
1795
+ });
1796
+ table.appendChild(tbody);
1797
+ if (isPreviewableSource(target.path)) {
1798
+ const tabsHost = card.querySelector(".gdp-file-detail-tabs");
1799
+ const tabs = document.createElement("div");
1800
+ tabs.className = "gdp-source-tabs";
1801
+ const previewButton = document.createElement("button");
1802
+ previewButton.type = "button";
1803
+ previewButton.className = "active";
1804
+ previewButton.textContent = "Preview";
1805
+ const codeButton = document.createElement("button");
1806
+ codeButton.type = "button";
1807
+ codeButton.textContent = "Code";
1808
+ const preview = renderMarkdownPreview(textValue, target, hljsRef);
1809
+ table.hidden = true;
1810
+ previewButton.addEventListener("click", () => {
1811
+ previewButton.classList.add("active");
1812
+ codeButton.classList.remove("active");
1813
+ preview.hidden = false;
1814
+ table.hidden = true;
1815
+ });
1816
+ codeButton.addEventListener("click", () => {
1817
+ codeButton.classList.add("active");
1818
+ previewButton.classList.remove("active");
1819
+ preview.hidden = true;
1820
+ table.hidden = false;
1821
+ });
1822
+ tabs.appendChild(previewButton);
1823
+ tabs.appendChild(codeButton);
1824
+ if (header)
1825
+ view.appendChild(header);
1826
+ if (tabsHost) {
1827
+ tabsHost.hidden = false;
1828
+ tabsHost.replaceChildren(tabs);
1829
+ }
1830
+ view.appendChild(preview);
1831
+ view.appendChild(table);
1832
+ if (body)
1833
+ body.replaceWith(view);
1834
+ else
1835
+ card.appendChild(view);
1836
+ return;
1837
+ }
1838
+ if (header)
1839
+ view.appendChild(header);
1840
+ view.appendChild(table);
1841
+ if (body)
1842
+ body.replaceWith(view);
1843
+ else
1844
+ card.appendChild(view);
1845
+ }
1846
+ function renderSourceMedia(card, target, mediaKind) {
1847
+ const body = card.querySelector(".gdp-file-detail-body, .d2h-files-diff, .d2h-file-diff, .gdp-media, .gdp-source-viewer");
1848
+ const isStandalone = card.classList.contains("gdp-standalone-source");
1849
+ const view = document.createElement("div");
1850
+ view.className = "gdp-source-viewer media";
1851
+ if (!isStandalone) {
1852
+ const meta = document.createElement("div");
1853
+ meta.className = "gdp-source-meta";
1854
+ meta.textContent = target.path + " @ " + target.ref;
1855
+ view.appendChild(meta);
1856
+ }
1857
+ const url = buildRawFileUrl(target);
1858
+ if (mediaKind === "video") {
1859
+ const video = document.createElement("video");
1860
+ video.src = url;
1861
+ video.controls = true;
1862
+ video.preload = "metadata";
1863
+ view.appendChild(video);
1864
+ } else {
1865
+ const img = document.createElement("img");
1866
+ img.src = url;
1867
+ img.alt = "";
1868
+ view.appendChild(img);
1869
+ }
1870
+ if (body)
1871
+ body.replaceWith(view);
1872
+ else
1873
+ card.appendChild(view);
1874
+ }
1875
+ function renderSourceBinary(card, target) {
1876
+ const body = card.querySelector(".gdp-file-detail-body, .d2h-files-diff, .d2h-file-diff, .gdp-media, .gdp-source-viewer");
1877
+ const isStandalone = card.classList.contains("gdp-standalone-source");
1878
+ const view = document.createElement("div");
1879
+ view.className = "gdp-source-viewer binary";
1880
+ const link = document.createElement("a");
1881
+ link.href = buildRawFileUrl(target);
1882
+ link.textContent = "Open raw file";
1883
+ link.target = "_blank";
1884
+ link.rel = "noreferrer";
1885
+ if (!isStandalone) {
1886
+ const meta = document.createElement("div");
1887
+ meta.className = "gdp-source-meta";
1888
+ meta.textContent = target.path + " @ " + target.ref;
1889
+ view.appendChild(meta);
1890
+ }
1891
+ view.appendChild(link);
1892
+ if (body)
1893
+ body.replaceWith(view);
1894
+ else
1895
+ card.appendChild(view);
1896
+ }
1897
+ function createFileBreadcrumb(path) {
1898
+ const nav = document.createElement("nav");
1899
+ nav.className = "gdp-file-breadcrumb";
1900
+ nav.setAttribute("aria-label", "File path");
1901
+ const parts = path.split("/").filter(Boolean);
1902
+ const allParts = PROJECT_NAME ? [PROJECT_NAME, ...parts] : parts;
1903
+ allParts.forEach((part, index) => {
1904
+ if (index > 0) {
1905
+ const sep = document.createElement("span");
1906
+ sep.className = "gdp-file-breadcrumb-sep";
1907
+ sep.textContent = "/";
1908
+ nav.appendChild(sep);
1909
+ }
1910
+ const crumb = document.createElement("span");
1911
+ crumb.className = index === allParts.length - 1 ? "gdp-file-breadcrumb-current" : "gdp-file-breadcrumb-part";
1912
+ crumb.textContent = part;
1913
+ nav.appendChild(crumb);
1914
+ });
1915
+ if (!allParts.length) {
1916
+ const crumb = document.createElement("span");
1917
+ crumb.className = "gdp-file-breadcrumb-current";
1918
+ crumb.textContent = path;
1919
+ nav.appendChild(crumb);
1920
+ }
1921
+ return nav;
1922
+ }
1923
+ async function renderStandaloneSource(target) {
1924
+ const req = ++SOURCE_REQ_SEQ;
1925
+ const root = $("#diff");
1926
+ const repoTarget = repoFileTargetFromRoute();
1927
+ setPageMode();
1928
+ removeStandaloneSource();
1929
+ document.querySelectorAll(".gdp-repo-blob-layout").forEach((el) => el.remove());
1930
+ const card = document.createElement("article");
1931
+ card.className = "gdp-file-shell loaded gdp-standalone-source gdp-source-mode";
1932
+ card.dataset.path = target.path;
1933
+ const wrapper = document.createElement("div");
1934
+ wrapper.className = "gdp-file-detail-wrapper";
1935
+ const sticky = document.createElement("div");
1936
+ sticky.className = "gdp-file-detail-sticky";
1937
+ const header = document.createElement("div");
1938
+ header.className = "gdp-file-detail-header";
1939
+ const name = document.createElement("div");
1940
+ name.className = "gdp-file-detail-path";
1941
+ name.appendChild(createFileBreadcrumb(target.path));
1942
+ const copy = document.createElement("button");
1943
+ copy.type = "button";
1944
+ copy.className = "gdp-file-header-icon gdp-copy-path";
1945
+ copy.title = "copy file path";
1946
+ copy.innerHTML = iconSvg("octicon-copy", COPY_16_PATHS);
1947
+ copy.addEventListener("click", async (e) => {
1948
+ e.stopPropagation();
1949
+ try {
1950
+ await navigator.clipboard.writeText(target.path);
1951
+ copy.classList.add("copied");
1952
+ setTimeout(() => {
1953
+ copy.classList.remove("copied");
1954
+ }, 1200);
1955
+ } catch {
1956
+ copy.classList.add("failed");
1957
+ setTimeout(() => {
1958
+ copy.classList.remove("failed");
1959
+ }, 1200);
1960
+ }
1961
+ });
1962
+ name.appendChild(copy);
1963
+ header.appendChild(name);
1964
+ if (!repoTarget) {
1965
+ const back = document.createElement("button");
1966
+ back.type = "button";
1967
+ back.className = "gdp-view-file gdp-btn gdp-btn-sm";
1968
+ setViewFileButtonState(back, true);
1969
+ back.addEventListener("click", () => {
1970
+ setRoute({ screen: "diff", range: currentRange() });
1971
+ setPageMode();
1972
+ removeStandaloneSource();
1973
+ });
1974
+ header.appendChild(back);
1975
+ }
1976
+ sticky.appendChild(header);
1977
+ const tabsHost = document.createElement("div");
1978
+ tabsHost.className = "gdp-file-detail-tabs";
1979
+ tabsHost.hidden = true;
1980
+ sticky.appendChild(tabsHost);
1981
+ wrapper.appendChild(sticky);
1982
+ const detailBody = document.createElement("div");
1983
+ detailBody.className = "gdp-file-detail-body";
1984
+ wrapper.appendChild(detailBody);
1985
+ card.appendChild(wrapper);
1986
+ if (repoTarget) {
1987
+ const layout = document.createElement("div");
1988
+ layout.className = "gdp-repo-blob-layout";
1989
+ renderRepoBlobSidebar(target.path, repoTarget);
1990
+ layout.appendChild(card);
1991
+ root.replaceChildren(layout);
1992
+ } else {
1993
+ root.prepend(card);
1994
+ }
1995
+ renderSourceLoading(card, target);
1996
+ try {
1997
+ const mediaKind = isVideo(target.path) ? "video" : isMedia(target.path) ? "image" : null;
1998
+ if (mediaKind === "image" || mediaKind === "video") {
1999
+ if (req !== SOURCE_REQ_SEQ || !sourceTargetsEqual(sourceTargetFromRoute(), target))
2000
+ return;
2001
+ renderSourceMedia(card, target, mediaKind);
2002
+ return;
2003
+ }
2004
+ const response = await trackLoad(fetch(buildRawFileUrl(target)));
2005
+ if (req !== SOURCE_REQ_SEQ || !sourceTargetsEqual(sourceTargetFromRoute(), target))
2006
+ return;
2007
+ if (!response.ok) {
2008
+ renderSourceError(card, target, "Cannot load " + target.path + " at " + target.ref);
2009
+ return;
2010
+ }
2011
+ const textValue = await response.text();
2012
+ if (req !== SOURCE_REQ_SEQ || !sourceTargetsEqual(sourceTargetFromRoute(), target))
2013
+ return;
2014
+ await renderSourceText(card, target, textValue);
2015
+ } catch {
2016
+ if (req !== SOURCE_REQ_SEQ || !sourceTargetsEqual(sourceTargetFromRoute(), target))
2017
+ return;
2018
+ renderSourceError(card, target, "Cannot load " + target.path + " at " + target.ref);
2019
+ }
2020
+ }
2021
+ function applySourceRouteToShell() {
2022
+ const target = sourceTargetFromRoute();
2023
+ setPageMode();
2024
+ if (!target) {
2025
+ removeStandaloneSource();
2026
+ document.querySelectorAll(".gdp-view-file").forEach((button) => {
2027
+ setViewFileButtonState(button, false);
2028
+ });
2029
+ return;
2030
+ }
2031
+ renderStandaloneSource(target);
2032
+ }
2033
+ async function expandAllFileContext(card, file) {
2034
+ if (card.classList.contains("gdp-context-expanded")) {
2035
+ const data = card._diffData;
2036
+ if (!data)
2037
+ return;
2038
+ card.classList.remove("gdp-context-expanded");
2039
+ mountDiff(card, file, data);
2040
+ if (data.truncated && data.mode === "preview")
2041
+ addExpandHunksUI(file, data, card);
2042
+ scheduleIdleHighlight(card, file);
2043
+ setUnfoldButtonState(card.querySelector(".gdp-file-unfold"), false);
2044
+ return;
2045
+ }
2046
+ if (card._diffData && (card._diffData.truncated || card._diffData.mode === "preview")) {
2047
+ await loadFile(file, card, file.load_url);
2048
+ card.classList.add("gdp-context-expanded");
2049
+ setUnfoldButtonState(card.querySelector(".gdp-file-unfold"), true);
2050
+ return;
2051
+ }
2052
+ const button = card.querySelector(".gdp-file-unfold");
2053
+ if (button)
2054
+ button.disabled = true;
2055
+ try {
2056
+ for (let i = 0;i < 200; i++) {
2057
+ const next = card.querySelector(".gdp-expand-btn:not(:disabled)");
2058
+ if (!next)
2059
+ break;
2060
+ next.click();
2061
+ await new Promise((resolve) => setTimeout(resolve, 80));
2062
+ }
2063
+ card.classList.add("gdp-context-expanded");
2064
+ setUnfoldButtonState(button || null, true);
2065
+ } finally {
2066
+ if (button)
2067
+ button.disabled = false;
2068
+ }
2069
+ }
973
2070
  function appendStatSquaresToHeader(card, file) {
974
2071
  const header = card.querySelector(".d2h-file-header");
975
- if (!header || header.querySelector(".gdp-stat-squares"))
2072
+ if (!header)
976
2073
  return;
2074
+ if (!header.querySelector(".gdp-file-toggle")) {
2075
+ const toggle = document.createElement("button");
2076
+ toggle.type = "button";
2077
+ toggle.className = "gdp-file-header-icon gdp-file-toggle";
2078
+ toggle.title = "Collapse file";
2079
+ toggle.setAttribute("aria-expanded", "true");
2080
+ toggle.innerHTML = iconSvg("octicon-chevron-down", CHEVRON_DOWN_16_PATH);
2081
+ toggle.addEventListener("click", (e) => {
2082
+ e.stopPropagation();
2083
+ setFileCollapsed(card, !card.classList.contains("gdp-file-collapsed"));
2084
+ });
2085
+ header.insertBefore(toggle, header.firstChild);
2086
+ }
2087
+ header.querySelectorAll(".d2h-file-collapse-input").forEach((checkbox) => {
2088
+ checkbox.checked = STATE.viewedFiles.has(file.path);
2089
+ if (checkbox.dataset.gdpBound !== "1") {
2090
+ checkbox.dataset.gdpBound = "1";
2091
+ checkbox.addEventListener("change", () => setFileViewed(file.path, checkbox.checked));
2092
+ }
2093
+ });
2094
+ if (!header.querySelector(".gdp-copy-path")) {
2095
+ const nameWrapper = header.querySelector(".d2h-file-name-wrapper");
2096
+ const copy = document.createElement("button");
2097
+ copy.type = "button";
2098
+ copy.className = "gdp-file-header-icon gdp-copy-path";
2099
+ copy.title = "copy file path";
2100
+ copy.innerHTML = iconSvg("octicon-copy", COPY_16_PATHS);
2101
+ copy.addEventListener("click", async (e) => {
2102
+ e.stopPropagation();
2103
+ const path = filePathClipboardText(file.path);
2104
+ if (!path)
2105
+ return;
2106
+ try {
2107
+ await navigator.clipboard.writeText(path);
2108
+ copy.classList.add("copied");
2109
+ setTimeout(() => {
2110
+ copy.classList.remove("copied");
2111
+ }, 1200);
2112
+ } catch {
2113
+ copy.classList.add("failed");
2114
+ setTimeout(() => {
2115
+ copy.classList.remove("failed");
2116
+ }, 1200);
2117
+ }
2118
+ });
2119
+ const statusTag = nameWrapper ? nameWrapper.querySelector(".d2h-tag") : null;
2120
+ if (statusTag)
2121
+ statusTag.insertAdjacentElement("afterend", copy);
2122
+ else if (nameWrapper)
2123
+ nameWrapper.insertAdjacentElement("beforeend", copy);
2124
+ else
2125
+ header.insertBefore(copy, header.firstChild);
2126
+ }
2127
+ if (!header.querySelector(".gdp-file-unfold")) {
2128
+ const unfold = document.createElement("button");
2129
+ unfold.type = "button";
2130
+ unfold.className = "gdp-file-header-icon gdp-file-unfold";
2131
+ setUnfoldButtonState(unfold, card.classList.contains("gdp-context-expanded"));
2132
+ unfold.addEventListener("click", (e) => {
2133
+ e.stopPropagation();
2134
+ expandAllFileContext(card, file);
2135
+ });
2136
+ const copy = header.querySelector(".gdp-copy-path");
2137
+ if (copy)
2138
+ copy.insertAdjacentElement("afterend", unfold);
2139
+ else
2140
+ header.appendChild(unfold);
2141
+ }
977
2142
  if (!header.querySelector(".gdp-stat-text")) {
978
2143
  const stats = document.createElement("span");
979
2144
  stats.className = "gdp-stat-text";
@@ -1010,6 +2175,21 @@
1010
2175
  wrap.appendChild(box);
1011
2176
  }
1012
2177
  header.appendChild(wrap);
2178
+ if (!header.querySelector(".gdp-view-file")) {
2179
+ const viewFile = document.createElement("button");
2180
+ viewFile.type = "button";
2181
+ viewFile.className = "gdp-view-file gdp-btn gdp-btn-sm";
2182
+ setViewFileButtonState(viewFile, false);
2183
+ viewFile.addEventListener("click", (e) => {
2184
+ e.stopPropagation();
2185
+ const target = fileSourceTarget(file);
2186
+ setRoute({ screen: "file", path: target.path, ref: target.ref, range: currentRange() });
2187
+ applySourceRouteToShell();
2188
+ });
2189
+ header.appendChild(viewFile);
2190
+ } else {
2191
+ setViewFileButtonState(header.querySelector(".gdp-view-file"), false);
2192
+ }
1013
2193
  }
1014
2194
  function renderFile(file, data, card) {
1015
2195
  card._diffData = data;
@@ -1379,29 +2559,72 @@
1379
2559
  localStorage.setItem("gdp:theme", STATE.theme);
1380
2560
  applyTheme();
1381
2561
  });
1382
- $("#collapse").addEventListener("click", () => collapseAll());
1383
- function syncFilters(srcEl) {
1384
- const v = srcEl.value;
1385
- ["#filter", "#sb-filter"].forEach((sel) => {
1386
- const el = $(sel);
1387
- if (el && el !== srcEl)
1388
- el.value = v;
1389
- });
1390
- applyFilter();
2562
+ function visibleFileItems() {
2563
+ return $$("#filelist li[data-path]:not(.hidden):not(.hidden-by-tests)");
2564
+ }
2565
+ function moveActiveVisibleFile(direction) {
2566
+ const items = visibleFileItems();
2567
+ if (!items.length)
2568
+ return;
2569
+ const current = items.findIndex((li) => li.classList.contains("active"));
2570
+ const idx = nextVisibleFileIndex(current, items.length, direction);
2571
+ const target = items[idx];
2572
+ if (!target)
2573
+ return;
2574
+ if (target.dataset.path)
2575
+ markActive(target.dataset.path);
2576
+ target.scrollIntoView({ block: "nearest" });
2577
+ if (target.dataset.path)
2578
+ prefetchByPath(target.dataset.path);
2579
+ }
2580
+ function jumpToActiveOrFirstFilteredFile() {
2581
+ const items = visibleFileItems();
2582
+ const active = items.find((li) => li.classList.contains("active"));
2583
+ const target = active || items[0];
2584
+ if (target) {
2585
+ target.click();
2586
+ $("#sb-filter").blur();
2587
+ }
1391
2588
  }
1392
- $("#filter").addEventListener("input", (e) => syncFilters(e.target));
1393
2589
  const sbFilter = $("#sb-filter");
1394
- if (sbFilter)
1395
- sbFilter.addEventListener("input", (e) => syncFilters(e.target));
2590
+ if (sbFilter) {
2591
+ sbFilter.addEventListener("input", () => applyFilter());
2592
+ sbFilter.addEventListener("keydown", (e) => {
2593
+ if (e.key === "Enter") {
2594
+ e.preventDefault();
2595
+ jumpToActiveOrFirstFilteredFile();
2596
+ } else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
2597
+ e.preventDefault();
2598
+ moveActiveVisibleFile(e.key === "ArrowDown" ? 1 : -1);
2599
+ } else if (e.key === "Escape") {
2600
+ if (sbFilter.value) {
2601
+ sbFilter.value = "";
2602
+ applyFilter();
2603
+ } else {
2604
+ sbFilter.blur();
2605
+ }
2606
+ }
2607
+ });
2608
+ }
2609
+ function focusFileFilter() {
2610
+ const input = $("#sb-filter");
2611
+ input.focus();
2612
+ input.select();
2613
+ }
1396
2614
  document.addEventListener("keydown", (e) => {
2615
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
2616
+ e.preventDefault();
2617
+ focusFileFilter();
2618
+ return;
2619
+ }
1397
2620
  const targetEl = e.target;
1398
2621
  if (targetEl && (targetEl.tagName === "INPUT" || targetEl.tagName === "TEXTAREA"))
1399
2622
  return;
1400
2623
  if (e.key === "/") {
1401
2624
  e.preventDefault();
1402
- $("#filter").focus();
2625
+ focusFileFilter();
1403
2626
  } else if (e.key === "j" || e.key === "k") {
1404
- const items = $$("#filelist li[data-path]:not(.hidden)");
2627
+ const items = visibleFileItems();
1405
2628
  if (!items.length)
1406
2629
  return;
1407
2630
  let idx = items.findIndex((li) => li.classList.contains("active"));
@@ -1427,7 +2650,31 @@
1427
2650
  });
1428
2651
  applyTheme();
1429
2652
  setLayout(STATE.layout);
2653
+ setPageMode();
2654
+ if (window.location.pathname === "/") {
2655
+ setRoute(STATE.route, true);
2656
+ }
2657
+ function loadRepo() {
2658
+ if (STATE.route.screen !== "repo")
2659
+ return Promise.resolve();
2660
+ setStatus("refreshing");
2661
+ const params = new URLSearchParams;
2662
+ params.set("ref", STATE.route.ref || "worktree");
2663
+ if (STATE.route.path)
2664
+ params.set("path", STATE.route.path);
2665
+ return trackLoad(fetch("/_tree?" + params.toString()).then((r) => {
2666
+ if (!r.ok)
2667
+ throw new Error("failed to load repository tree");
2668
+ return r.json();
2669
+ })).then((data) => {
2670
+ renderRepo(data);
2671
+ setStatus("live");
2672
+ syncHeaderMenu();
2673
+ }).catch(() => setStatus("error"));
2674
+ }
1430
2675
  function load(opts) {
2676
+ if (STATE.route.screen === "repo")
2677
+ return loadRepo();
1431
2678
  setStatus("refreshing");
1432
2679
  const params = new URLSearchParams;
1433
2680
  if (STATE.ignoreWs)
@@ -1444,7 +2691,10 @@
1444
2691
  setStatus("live");
1445
2692
  }).catch(() => setStatus("error"));
1446
2693
  }
1447
- load();
2694
+ if (STATE.route.screen === "repo")
2695
+ loadRepo();
2696
+ else
2697
+ load();
1448
2698
  function syncRefInputs() {
1449
2699
  const fi = $("#ref-from"), ti = $("#ref-to");
1450
2700
  if (fi)
@@ -1458,9 +2708,16 @@
1458
2708
  localStorage.setItem("gdp:from", STATE.from);
1459
2709
  localStorage.setItem("gdp:to", STATE.to);
1460
2710
  syncRefInputs();
2711
+ const range = currentRange();
2712
+ if (STATE.route.screen === "file") {
2713
+ setRoute({ screen: "file", path: STATE.route.path, ref: STATE.route.ref, range }, true);
2714
+ } else {
2715
+ setRoute({ screen: "diff", range }, true);
2716
+ }
1461
2717
  load();
1462
2718
  }
1463
2719
  syncRefInputs();
2720
+ syncHeaderMenu();
1464
2721
  const REFS = { branches: [], tags: [], commits: [], current: "" };
1465
2722
  const popover = $("#ref-popover");
1466
2723
  const popBody = popover.querySelector(".rp-body");
@@ -1544,7 +2801,8 @@
1544
2801
  });
1545
2802
  popover.hidden = false;
1546
2803
  const r = input.getBoundingClientRect();
1547
- popover.style.left = Math.max(8, r.left) + "px";
2804
+ const popWidth = Math.min(560, Math.floor(window.innerWidth * 0.9));
2805
+ popover.style.left = Math.max(8, Math.min(r.left, window.innerWidth - popWidth - 8)) + "px";
1548
2806
  popover.style.top = r.bottom + 4 + "px";
1549
2807
  setTimeout(() => popSearch.focus(), 0);
1550
2808
  }
@@ -1561,17 +2819,31 @@
1561
2819
  el.focus();
1562
2820
  }
1563
2821
  });
2822
+ el.addEventListener("click", (e) => {
2823
+ e.stopPropagation();
2824
+ openPopover(el);
2825
+ });
1564
2826
  el.addEventListener("keydown", (e) => {
1565
2827
  if (e.key === "Enter") {
1566
2828
  e.preventDefault();
1567
2829
  closePopover();
1568
- setRange($("#ref-from").value, $("#ref-to").value);
1569
2830
  } else if (e.key === "Escape") {
1570
2831
  closePopover();
1571
2832
  el.blur();
1572
2833
  }
1573
2834
  });
1574
2835
  });
2836
+ wireRepoTargetPicker($("#repo-target"), (ref) => {
2837
+ if (STATE.route.screen !== "file")
2838
+ return;
2839
+ setRoute({ screen: "file", path: STATE.route.path, ref, view: "blob", range: currentRange() });
2840
+ renderStandaloneSource({ path: STATE.route.path, ref });
2841
+ });
2842
+ document.addEventListener("focusin", (e) => {
2843
+ const el = e.target;
2844
+ if (el instanceof HTMLInputElement && (el.id === "repo-ref" || el.id === "repo-target"))
2845
+ openPopover(el);
2846
+ });
1575
2847
  popSearch.addEventListener("input", () => buildPopBody(popSearch.value));
1576
2848
  popSearch.addEventListener("keydown", (e) => {
1577
2849
  if (e.key === "Escape") {
@@ -1586,8 +2858,19 @@
1586
2858
  function handlePicked(val) {
1587
2859
  if (!popTarget || !val)
1588
2860
  return;
1589
- popTarget.value = val;
1590
- const targetWasFrom = popTarget.id === "ref-from";
2861
+ const pickedTarget = popTarget;
2862
+ pickedTarget.value = val;
2863
+ if (pickedTarget.id === "repo-ref") {
2864
+ closePopover();
2865
+ pickedTarget.dispatchEvent(new Event("change"));
2866
+ return;
2867
+ }
2868
+ if (pickedTarget.id === "repo-target") {
2869
+ closePopover();
2870
+ pickedTarget.dispatchEvent(new Event("change"));
2871
+ return;
2872
+ }
2873
+ const targetWasFrom = pickedTarget.id === "ref-from";
1591
2874
  const otherEmpty = !$("#ref-to").value;
1592
2875
  closePopover();
1593
2876
  setRange($("#ref-from").value, $("#ref-to").value);
@@ -1618,12 +2901,36 @@
1618
2901
  const target = e.target;
1619
2902
  if (popover.contains(target))
1620
2903
  return;
1621
- if (target.id === "ref-from" || target.id === "ref-to")
2904
+ if (target.id === "ref-from" || target.id === "ref-to" || target.id === "repo-ref" || target.id === "repo-target")
1622
2905
  return;
1623
2906
  closePopover();
1624
2907
  });
1625
- $("#ref-apply").addEventListener("click", () => setRange($("#ref-from").value, $("#ref-to").value));
1626
2908
  $("#ref-reset").addEventListener("click", () => setRange("HEAD", "worktree"));
2909
+ window.addEventListener("popstate", () => {
2910
+ const parsedRoute = parseRoute(window.location.pathname, window.location.search, currentRange());
2911
+ STATE.route = parsedRoute.screen === "unknown" ? { screen: "diff", range: parsedRoute.range } : parsedRoute;
2912
+ STATE.from = STATE.route.range.from;
2913
+ STATE.to = STATE.route.range.to;
2914
+ if (STATE.route.screen === "repo")
2915
+ STATE.repoRef = STATE.route.ref || "worktree";
2916
+ syncRefInputs();
2917
+ syncHeaderMenu();
2918
+ if (STATE.route.screen === "repo") {
2919
+ SOURCE_REQ_SEQ++;
2920
+ setPageMode();
2921
+ removeStandaloneSource();
2922
+ loadRepo();
2923
+ return;
2924
+ }
2925
+ if (STATE.route.screen !== "file") {
2926
+ SOURCE_REQ_SEQ++;
2927
+ setPageMode();
2928
+ removeStandaloneSource();
2929
+ load();
2930
+ return;
2931
+ }
2932
+ applySourceRouteToShell();
2933
+ });
1627
2934
  function applyIgnoreWs() {
1628
2935
  const btn = $("#ignore-ws");
1629
2936
  if (btn)
@@ -1704,13 +3011,9 @@
1704
3011
  const isTest = TEST_RE.test(li.dataset.path || "");
1705
3012
  li.classList.toggle("hidden-by-tests", STATE.hideTests && isTest);
1706
3013
  });
1707
- document.querySelectorAll("#filelist .tree-dir").forEach((dir) => {
1708
- const childUl = dir.nextElementSibling;
1709
- if (!childUl)
1710
- return;
1711
- const anyVisible = !!childUl.querySelector(".tree-file:not(.hidden):not(.hidden-by-tests)");
1712
- dir.classList.toggle("hidden-by-tests", STATE.hideTests && !anyVisible);
1713
- });
3014
+ updateTreeDirVisibility();
3015
+ if (typeof applyViewedState === "function")
3016
+ applyViewedState();
1714
3017
  }
1715
3018
  applyHideTests();
1716
3019
  $("#hide-tests").addEventListener("click", () => {