@tanishqkancharla/diffmap 0.1.3 → 0.1.5

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.
Files changed (50) hide show
  1. package/README.md +25 -14
  2. package/package.json +3 -3
  3. package/src/cli.test.ts +50 -0
  4. package/src/cli.ts +126 -51
  5. package/src/contentPlugin.ts +5 -3
  6. package/src/errors.ts +6 -1
  7. package/src/gist/GistApp.tsx +34 -21
  8. package/src/gist/GistStatus.tsx +8 -43
  9. package/src/gist/main.tsx +2 -2
  10. package/src/gist/pin.ts +14 -0
  11. package/src/github/GitHubApp.tsx +235 -0
  12. package/src/github/fetchRepo.test.ts +17 -0
  13. package/src/github/fetchRepo.ts +300 -0
  14. package/src/github/patch.test.ts +30 -0
  15. package/src/github/patch.ts +40 -0
  16. package/src/github/pin.ts +14 -0
  17. package/src/github/route.test.ts +68 -0
  18. package/src/github/route.ts +175 -0
  19. package/src/host/HostApp.tsx +22 -0
  20. package/src/host/HostHome.tsx +73 -0
  21. package/src/host/files.ts +10 -0
  22. package/src/host/home.md +194 -0
  23. package/src/host/home.test.ts +82 -0
  24. package/src/index.ts +1 -0
  25. package/src/mermaid.ts +29 -1
  26. package/src/packageVersion.ts +7 -0
  27. package/src/parseViewer.test.ts +185 -0
  28. package/src/parseViewer.ts +27 -3
  29. package/src/serve.test.ts +306 -0
  30. package/src/serve.ts +23 -5
  31. package/src/share.ts +5 -1
  32. package/src/update.test.ts +19 -0
  33. package/src/update.ts +285 -0
  34. package/src/viewer/CallStackDiff.tsx +26 -6
  35. package/src/viewer/CloseServerButton.tsx +16 -0
  36. package/src/viewer/FileExcerpt.tsx +52 -3
  37. package/src/viewer/HtmlPlaceholder.tsx +6 -1
  38. package/src/viewer/SourceDiffPanel.tsx +155 -59
  39. package/src/viewer/TableOfContents.tsx +230 -39
  40. package/src/viewer/TocButton.tsx +27 -0
  41. package/src/viewer/ViewerApp.tsx +182 -47
  42. package/src/viewer/main.tsx +10 -3
  43. package/src/viewer/sourceHeader.test.ts +131 -0
  44. package/src/viewer/sourceHeader.ts +103 -0
  45. package/src/viewer/sourceNavigation.ts +81 -3
  46. package/src/viewer/viewerMode.ts +1 -1
  47. package/src/viewer/vite-env.d.ts +2 -1
  48. package/vite.config.ts +1 -1
  49. package/src/viewer/Check.tsx +0 -24
  50. package/src/viewer/DoneButton.tsx +0 -32
@@ -1,10 +1,18 @@
1
- import { useEffect, useRef, useState, type ReactNode } from "react";
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type ReactNode,
7
+ } from "react";
2
8
  import {
3
9
  backgroundColor,
4
10
  border,
5
11
  colors,
6
12
  flex,
7
13
  flexItem,
14
+ H1,
15
+ P,
8
16
  proseHtml,
9
17
  proseMaxWidth,
10
18
  spacing,
@@ -14,9 +22,15 @@ import { style, useStyles } from "purse-styles";
14
22
  import type { ViewerDocument } from "../parseViewer.js";
15
23
  import { ComarkView } from "./ComarkView.tsx";
16
24
  import { SourceDiffPanel, type SourceSelection } from "./SourceDiffPanel.js";
17
- import { DoneButton } from "./DoneButton.tsx";
25
+ import { CloseServerButton } from "./CloseServerButton.tsx";
18
26
  import { DiffButton } from "./DiffButton.tsx";
19
- import { TableOfContents } from "./TableOfContents.tsx";
27
+ import { TocButton } from "./TocButton.tsx";
28
+ import {
29
+ hasTableOfContents,
30
+ TableOfContents,
31
+ TOC_OPEN_MIN_WIDTH_PX,
32
+ TOC_SIDE_MARGIN_PX,
33
+ } from "./TableOfContents.tsx";
20
34
  import { ViewerModeContext, type ViewerMode } from "./viewerMode.ts";
21
35
 
22
36
  type ViewerMeta = {
@@ -53,7 +67,64 @@ export function ViewerApp(props: {
53
67
  const prose = useStyles(styles.prose);
54
68
  const content = useStyles(proseHtml("md"), styles.content);
55
69
  const closed = useStyles(styles.closed);
70
+ const closedCopy = useStyles(styles.closedCopy);
71
+ const stage = useStyles(styles.stage);
56
72
  const articleRef = useRef<HTMLElement>(null);
73
+ const hasToc = hasTableOfContents(viewerDocument.headings);
74
+ const tocDismissed = useRef(false);
75
+ const tocPinned = useRef(false);
76
+ const tocFits = useRef(window.innerWidth >= TOC_OPEN_MIN_WIDTH_PX);
77
+ const remeasureToc = useRef<() => void>(() => {});
78
+ const [tocOpen, setTocOpen] = useState<TocMode | undefined>(() =>
79
+ hasToc && window.innerWidth >= TOC_OPEN_MIN_WIDTH_PX ? "space" : undefined,
80
+ );
81
+ const tocExpanded = tocOpen !== undefined;
82
+ const onDwellOpen = useCallback(() => {
83
+ setTocOpen((mode) =>
84
+ mode === "click" || mode === "space" ? mode : "dwell",
85
+ );
86
+ }, []);
87
+ const onDwellClose = useCallback(() => {
88
+ setTocOpen((mode) => {
89
+ if (mode === "click" || mode === "space") return mode;
90
+ if (tocFits.current && !tocDismissed.current) {
91
+ return tocPinned.current ? "click" : "space";
92
+ }
93
+ return undefined;
94
+ });
95
+ }, []);
96
+
97
+ useEffect(() => {
98
+ if (!hasToc) return;
99
+ const measure = () => {
100
+ const node = articleRef.current;
101
+ if (node !== null && node.clientWidth === 0) return;
102
+ const width = node?.clientWidth ?? window.innerWidth;
103
+ const next = width >= TOC_OPEN_MIN_WIDTH_PX;
104
+ const previous = tocFits.current;
105
+ tocFits.current = next;
106
+ if (previous === next) return;
107
+ if (!next) {
108
+ setTocOpen(undefined);
109
+ return;
110
+ }
111
+ setTocOpen((mode) => {
112
+ if (mode === "click" || mode === "dwell") return mode;
113
+ if (tocDismissed.current) return undefined;
114
+ return tocPinned.current ? "click" : "space";
115
+ });
116
+ };
117
+ remeasureToc.current = measure;
118
+ const observer = new ResizeObserver(measure);
119
+ const articleNode = articleRef.current;
120
+ if (articleNode) observer.observe(articleNode);
121
+ window.addEventListener("resize", measure);
122
+ return () => {
123
+ observer.disconnect();
124
+ window.removeEventListener("resize", measure);
125
+ remeasureToc.current = () => {};
126
+ };
127
+ }, [hasToc]);
57
128
 
58
129
  useEffect(() => {
59
130
  document.title = title;
@@ -66,7 +137,14 @@ export function ViewerApp(props: {
66
137
  }, []);
67
138
 
68
139
  if (shutDown) {
69
- return <main className={closed}>Closed.</main>;
140
+ return (
141
+ <main className={closed}>
142
+ <div className={closedCopy}>
143
+ <H1>Closed spec</H1>
144
+ <P>The local server stopped.</P>
145
+ </div>
146
+ </main>
147
+ );
70
148
  }
71
149
 
72
150
  return (
@@ -74,18 +152,37 @@ export function ViewerApp(props: {
74
152
  <div className={shell}>
75
153
  <header className={header}>
76
154
  <div className={heading}>
155
+ {hasToc && (
156
+ <TocButton
157
+ expanded={tocExpanded}
158
+ onClick={() => {
159
+ if (tocExpanded) {
160
+ tocDismissed.current = true;
161
+ tocPinned.current = false;
162
+ setTocOpen(undefined);
163
+ return;
164
+ }
165
+ tocDismissed.current = false;
166
+ tocPinned.current = true;
167
+ setTocOpen("click");
168
+ }}
169
+ />
170
+ )}
77
171
  <div className={titleClass}>{title}</div>
78
172
  </div>
79
173
  <div className={actions}>
80
174
  {hasSourceDiffs && (
81
175
  <DiffButton
82
176
  pressed={showDiffPanel}
83
- onClick={() => setShowDiffPanel((open) => !open)}
177
+ onClick={() => {
178
+ setShowDiffPanel((open) => !open);
179
+ window.requestAnimationFrame(() => remeasureToc.current());
180
+ }}
84
181
  />
85
182
  )}
86
183
  {props.headerActions}
87
184
  {props.mode === "local" && (
88
- <DoneButton
185
+ <CloseServerButton
89
186
  onClick={() => {
90
187
  setShutDown(true);
91
188
  // oxlint-disable-next-line typescript/no-floating-promises -- React click callbacks cannot await the server shutdown request.
@@ -95,41 +192,53 @@ export function ViewerApp(props: {
95
192
  )}
96
193
  </div>
97
194
  </header>
98
- <div className={body} data-has-source-diffs={diffPanelOpen}>
99
- <TableOfContents
100
- headings={viewerDocument.headings}
101
- articleRef={articleRef}
102
- />
103
- <article ref={articleRef} className={article}>
104
- <div className={prose}>
105
- <div className={content} data-diffmap-kind="page">
106
- <ComarkView
107
- document={viewerDocument}
108
- selectedAnnotation={selection?.annotation}
109
- onSelectAnnotation={(line) => {
110
- setShowDiffPanel(true);
111
- setSelection({
112
- annotation: line,
113
- reference: line.references[0]!,
114
- });
115
- }}
116
- />
195
+ <div className={stage}>
196
+ <div className={body} data-has-source-diffs={diffPanelOpen}>
197
+ <article ref={articleRef} className={article}>
198
+ <div className={prose} data-has-toc={hasToc ? "true" : "false"}>
199
+ <div className={content} data-diffmap-kind="page">
200
+ <ComarkView
201
+ document={viewerDocument}
202
+ selectedAnnotation={selection?.annotation}
203
+ onSelectAnnotation={(line) => {
204
+ setShowDiffPanel(true);
205
+ setSelection({
206
+ annotation: line,
207
+ reference: line.references[0]!,
208
+ });
209
+ window.requestAnimationFrame(() =>
210
+ remeasureToc.current(),
211
+ );
212
+ }}
213
+ />
214
+ </div>
215
+ {hasToc && (
216
+ <TableOfContents
217
+ headings={viewerDocument.headings}
218
+ articleRef={articleRef}
219
+ open={tocExpanded}
220
+ onDwellOpen={onDwellOpen}
221
+ onDwellClose={onDwellClose}
222
+ />
223
+ )}
117
224
  </div>
118
- </div>
119
- </article>
120
- {diffPanelOpen && (
121
- <SourceDiffPanel
122
- items={viewerDocument.sourceDiffs}
123
- selection={selection}
124
- onSelect={setSelection}
125
- />
126
- )}
225
+ </article>
226
+ {diffPanelOpen && (
227
+ <SourceDiffPanel
228
+ items={viewerDocument.sourceDiffs}
229
+ selection={selection}
230
+ onSelect={setSelection}
231
+ />
232
+ )}
233
+ </div>
127
234
  </div>
128
235
  </div>
129
236
  </ViewerModeContext.Provider>
130
237
  );
131
238
  }
132
239
 
240
+ type TocMode = "click" | "dwell" | "space";
241
+
133
242
  function useViewerMeta(enabled: boolean) {
134
243
  const [meta, setMeta] = useState<ViewerMeta>();
135
244
  useEffect(() => {
@@ -159,7 +268,7 @@ const styles = {
159
268
  backgroundColor: colors.gray[4],
160
269
  }),
161
270
  header: style(
162
- flex({ direction: "row", align: "center", justify: "between" }),
271
+ flex({ direction: "row", alignItems: "center", justifyContent: "between" }),
163
272
  spacing.padding({ x: 6, y: 3 }),
164
273
  flexItem({ size: "hug" }),
165
274
  border(["bottom"], "border"),
@@ -168,8 +277,9 @@ const styles = {
168
277
  backgroundColor: backgroundColor.app,
169
278
  },
170
279
  ),
171
- heading: style(flex({ direction: "column" }), {
280
+ heading: style(flex({ direction: "row", alignItems: "center", gap: 3 }), {
172
281
  minWidth: 0,
282
+ flex: "1 1 auto",
173
283
  }),
174
284
  title: style(text({ size: "md", fontWeight: 600, color: "highContrast" }), {
175
285
  minWidth: 0,
@@ -177,37 +287,46 @@ const styles = {
177
287
  textOverflow: "ellipsis",
178
288
  whiteSpace: "nowrap",
179
289
  }),
180
- actions: style(flex({ direction: "row", align: "center", gap: 3 }), {
290
+ actions: style(flex({ direction: "row", alignItems: "center", gap: 3 }), {
181
291
  flexShrink: 0,
182
292
  }),
293
+ stage: style(flex({ direction: "column" }), {
294
+ position: "relative",
295
+ flex: "1 1 auto",
296
+ minWidth: 0,
297
+ minHeight: 0,
298
+ }),
183
299
  body: style({
184
300
  display: "grid",
185
- gridTemplateColumns: "var(--diffmap-columns)",
186
301
  gridTemplateRows: "minmax(0, 1fr)",
187
- "--diffmap-columns": "minmax(0, max-content) minmax(0, 1fr)",
188
302
  flex: "1 1 auto",
189
303
  minHeight: 0,
190
304
  minWidth: 0,
191
305
  overflow: "hidden",
306
+ backgroundColor: backgroundColor.app,
307
+ "--diffmap-columns": "minmax(0, 1fr)",
308
+ "--diffmap-areas": '"article"',
309
+ gridTemplateColumns: "var(--diffmap-columns)",
310
+ gridTemplateAreas: "var(--diffmap-areas)",
192
311
  "&[data-has-source-diffs='true']": {
193
- "--diffmap-columns":
194
- "minmax(0, max-content) minmax(0, 1fr) minmax(0, 1fr)",
312
+ "--diffmap-columns": "minmax(0, 1fr) minmax(0, 1fr)",
313
+ "--diffmap-areas": '"article diff"',
195
314
  },
196
315
  "@media (max-width: 900px)": {
197
316
  gridTemplateColumns: "minmax(0, 1fr)",
198
317
  gridTemplateRows: "minmax(0, 1fr) auto",
318
+ gridTemplateAreas: '"article" "diff"',
199
319
  },
200
320
  }),
201
321
  article: style(spacing.padding({ x: 12, y: 12 }), {
202
- gridColumn: "2",
322
+ gridArea: "article",
203
323
  flex: "1 1 auto",
204
324
  minWidth: 0,
205
325
  minHeight: 0,
206
326
  overflowY: "auto",
207
327
  backgroundColor: backgroundColor.app,
208
- "@media (max-width: 900px)": {
209
- gridColumn: "1",
210
- },
328
+ // Scrollport size for the sticky gutter rail (`100cqh`).
329
+ containerType: "size",
211
330
  }),
212
331
  prose: style({
213
332
  display: "grid",
@@ -215,9 +334,13 @@ const styles = {
215
334
  width: "100%",
216
335
  maxWidth: "none",
217
336
  minWidth: 0,
337
+ "&[data-has-toc='true']": {
338
+ gridTemplateColumns: `minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr) minmax(0, ${proseMaxWidth}) minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr)`,
339
+ },
218
340
  }),
219
341
  content: style({
220
342
  gridColumn: "2 / 3",
343
+ gridRow: "1",
221
344
  width: "100%",
222
345
  maxWidth: "none",
223
346
  minWidth: 0,
@@ -247,11 +370,23 @@ const styles = {
247
370
  },
248
371
  }),
249
372
  closed: style(
250
- text({ size: "md", fontWeight: 500, color: "highContrast" }),
251
- spacing.padding({ all: 12 }),
373
+ flex({
374
+ direction: "column",
375
+ alignItems: "center",
376
+ justifyContent: "center",
377
+ }),
378
+ spacing.padding({ x: 12, y: 12 }),
252
379
  {
253
380
  minHeight: "100vh",
254
381
  backgroundColor: backgroundColor.app,
255
382
  },
256
383
  ),
384
+ closedCopy: style(
385
+ flex({ direction: "column", alignItems: "center", gap: 3 }),
386
+ {
387
+ width: "100%",
388
+ maxWidth: proseMaxWidth,
389
+ textAlign: "center",
390
+ },
391
+ ),
257
392
  };
@@ -1,8 +1,9 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
- import { MauiProvider } from "maui";
4
- import { viewerDocument } from "virtual:diffmap";
3
+ import { MauiProvider, P } from "maui";
4
+ import { parseError, viewerDocument } from "virtual:diffmap";
5
5
  import { ViewerApp } from "./ViewerApp.tsx";
6
+ import { GistStatus } from "../gist/GistStatus.tsx";
6
7
  import "./styles.css";
7
8
 
8
9
  const root = document.getElementById("root");
@@ -11,7 +12,13 @@ if (root === null) throw new Error("diffmap root element is missing");
11
12
  createRoot(root).render(
12
13
  <StrictMode>
13
14
  <MauiProvider>
14
- <ViewerApp document={viewerDocument} mode="local" />
15
+ {parseError !== null || viewerDocument === null ? (
16
+ <GistStatus title="Could not parse this spec">
17
+ <P>{parseError ?? "Unknown parse error."}</P>
18
+ </GistStatus>
19
+ ) : (
20
+ <ViewerApp document={viewerDocument} mode="local" />
21
+ )}
15
22
  </MauiProvider>
16
23
  </StrictMode>,
17
24
  );
@@ -0,0 +1,131 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { CodeViewDiffItem } from "@pierre/diffs";
4
+ import {
5
+ sourcePanelOrigin,
6
+ sourcePanelPath,
7
+ sourcePanelPin,
8
+ } from "./sourceHeader.ts";
9
+
10
+ const sha = "ef78bf4678d2e76a7c8bcc4087fcadf347a1fcdf";
11
+ const github = {
12
+ owner: "tanishqkancharla",
13
+ repo: "diffmap",
14
+ sha,
15
+ };
16
+ const gist = {
17
+ gistId: "a3d2fe2b2e0b1422a4fd5378baced900",
18
+ file: "walkthrough.md",
19
+ htmlUrl: "https://gist.github.com/a3d2fe2b2e0b1422a4fd5378baced900",
20
+ };
21
+
22
+ function diffItem(id: string, name: string): CodeViewDiffItem {
23
+ return {
24
+ id,
25
+ type: "diff",
26
+ fileDiff: { name } as CodeViewDiffItem["fileDiff"],
27
+ };
28
+ }
29
+
30
+ test("github file pin is owner/repo @ shortsha linking to the blob at the pin SHA", () => {
31
+ const path = "src/cli.ts";
32
+ const pin = sourcePanelPin({
33
+ origin: "file",
34
+ path,
35
+ github,
36
+ });
37
+ assert.deepEqual(pin, {
38
+ kind: "github",
39
+ label: "tanishqkancharla/diffmap @ ef78bf4",
40
+ href: `https://github.com/tanishqkancharla/diffmap/blob/${sha}/src/cli.ts`,
41
+ });
42
+ });
43
+
44
+ test("embedded source-diff says from this spec, not a GitHub blob", () => {
45
+ const items = [diffItem("cli", "src/cli.ts")];
46
+ const origin = sourcePanelOrigin({
47
+ showingFile: false,
48
+ reference: { kind: "file", path: "src/cli.ts" },
49
+ items,
50
+ linkedDiffId: "cli",
51
+ });
52
+ assert.equal(origin, "spec-diff");
53
+ assert.deepEqual(sourcePanelPin({ origin, path: "src/cli.ts", github }), {
54
+ kind: "spec",
55
+ label: "from this spec",
56
+ });
57
+ });
58
+
59
+ test("PR file diffs pin to the repo blob, not from this spec", () => {
60
+ const items = [diffItem("pr:src/cli.ts", "src/cli.ts")];
61
+ const origin = sourcePanelOrigin({
62
+ showingFile: false,
63
+ reference: { kind: "file", path: "src/cli.ts" },
64
+ items,
65
+ });
66
+ assert.equal(origin, "repo-diff");
67
+ const pin = sourcePanelPin({ origin, path: "src/cli.ts", github });
68
+ assert.equal(pin.kind, "github");
69
+ if (pin.kind !== "github") return;
70
+ assert.equal(pin.label, "tanishqkancharla/diffmap @ ef78bf4");
71
+ assert.match(
72
+ pin.href,
73
+ /\/blob\/ef78bf4678d2e76a7c8bcc4087fcadf347a1fcdf\/src\/cli.ts$/,
74
+ );
75
+ });
76
+
77
+ test("gist pin is gist id / filename, not a fake repo", () => {
78
+ const pin = sourcePanelPin({
79
+ origin: "file",
80
+ path: "src/cli.ts",
81
+ gist,
82
+ });
83
+ assert.deepEqual(pin, {
84
+ kind: "gist",
85
+ label: "a3d2fe2b2e0b1422a4fd5378baced900 / walkthrough.md",
86
+ href: "https://gist.github.com/a3d2fe2b2e0b1422a4fd5378baced900",
87
+ });
88
+ });
89
+
90
+ test("gist embedded source-diff says from this spec", () => {
91
+ const origin = sourcePanelOrigin({
92
+ showingFile: false,
93
+ items: [diffItem("serve", "src/serve.ts")],
94
+ reference: { kind: "diff", id: "serve", side: "new", start: 1, end: 3 },
95
+ });
96
+ assert.equal(origin, "spec-diff");
97
+ assert.deepEqual(sourcePanelPin({ origin, path: "src/serve.ts", gist }), {
98
+ kind: "spec",
99
+ label: "from this spec",
100
+ });
101
+ });
102
+
103
+ test("local file has no pin line", () => {
104
+ assert.deepEqual(sourcePanelPin({ origin: "file", path: "src/cli.ts" }), {
105
+ kind: "none",
106
+ });
107
+ });
108
+
109
+ test("sourcePanelPath prefers the open file, then the linked diff, then the reference", () => {
110
+ const items = [diffItem("cli", "src/cli.ts")];
111
+ assert.equal(
112
+ sourcePanelPath({ filePath: "src/registry.ts", items }),
113
+ "src/registry.ts",
114
+ );
115
+ assert.equal(
116
+ sourcePanelPath({
117
+ items,
118
+ linkedDiffId: "cli",
119
+ reference: { kind: "file", path: "src/other.ts" },
120
+ }),
121
+ "src/cli.ts",
122
+ );
123
+ assert.equal(
124
+ sourcePanelPath({
125
+ items,
126
+ reference: { kind: "file", path: "src/cli.ts" },
127
+ }),
128
+ "src/cli.ts",
129
+ );
130
+ assert.equal(sourcePanelPath({ items: [] }), undefined);
131
+ });
@@ -0,0 +1,103 @@
1
+ import type { CodeViewDiffItem } from "@pierre/diffs";
2
+ import type { SourceReference } from "../annotations.js";
3
+ import type { GistPin } from "../gist/pin.ts";
4
+ import { githubGistUrl } from "../gist/route.ts";
5
+ import type { GitHubPin } from "../github/pin.ts";
6
+ import { githubBlobUrl } from "../github/route.ts";
7
+
8
+ const PR_DIFF_PREFIX = "pr:";
9
+
10
+ export type SourcePanelOrigin = "file" | "spec-diff" | "repo-diff";
11
+
12
+ export type SourcePanelPin =
13
+ | { kind: "github"; label: string; href: string }
14
+ | { kind: "gist"; label: string; href: string }
15
+ | { kind: "spec"; label: "from this spec" }
16
+ | { kind: "none" };
17
+
18
+ /** Pull-files diffs are repo patches at the pin SHA; everything else is embedded in the spec. */
19
+ export function isSpecEmbeddedDiff(item: CodeViewDiffItem) {
20
+ return !item.id.startsWith(PR_DIFF_PREFIX);
21
+ }
22
+
23
+ export function sourcePanelPath(input: {
24
+ filePath?: string;
25
+ reference?: SourceReference;
26
+ items: CodeViewDiffItem[];
27
+ linkedDiffId?: string;
28
+ }): string | undefined {
29
+ if (input.filePath !== undefined && input.filePath.length > 0) {
30
+ return input.filePath;
31
+ }
32
+ if (input.linkedDiffId !== undefined) {
33
+ const linked = input.items.find((item) => item.id === input.linkedDiffId);
34
+ if (linked !== undefined) return linked.fileDiff.name;
35
+ }
36
+ const ref = input.reference;
37
+ if (ref === undefined) return undefined;
38
+ if (ref.kind === "file") return ref.path;
39
+ return input.items.find((item) => item.id === ref.id)?.fileDiff.name;
40
+ }
41
+
42
+ export function sourcePanelOrigin(input: {
43
+ showingFile: boolean;
44
+ reference?: SourceReference;
45
+ items: CodeViewDiffItem[];
46
+ linkedDiffId?: string;
47
+ }): SourcePanelOrigin {
48
+ if (input.showingFile) return "file";
49
+ const ref = input.reference;
50
+ const focused =
51
+ input.linkedDiffId !== undefined
52
+ ? input.items.find((item) => item.id === input.linkedDiffId)
53
+ : ref?.kind === "diff"
54
+ ? input.items.find((item) => item.id === ref.id)
55
+ : ref?.kind === "file"
56
+ ? input.items.find((item) => item.fileDiff.name === ref.path)
57
+ : undefined;
58
+ if (focused !== undefined) {
59
+ return isSpecEmbeddedDiff(focused) ? "spec-diff" : "repo-diff";
60
+ }
61
+ if (input.items.some(isSpecEmbeddedDiff)) return "spec-diff";
62
+ if (input.items.length > 0) return "repo-diff";
63
+ return "file";
64
+ }
65
+
66
+ /**
67
+ * Pin line for the source panel. A hosted spec is one SHA; blob links use that
68
+ * SHA for the open path. Embedded `source-diff` is from the spec, not a blob.
69
+ */
70
+ export function sourcePanelPin(input: {
71
+ origin: SourcePanelOrigin;
72
+ path: string | undefined;
73
+ github?: GitHubPin;
74
+ gist?: GistPin;
75
+ }): SourcePanelPin {
76
+ if (input.origin === "spec-diff") {
77
+ return { kind: "spec", label: "from this spec" };
78
+ }
79
+ if (input.github !== undefined && input.path !== undefined) {
80
+ const shortSha = input.github.sha.slice(0, 7);
81
+ return {
82
+ kind: "github",
83
+ label: `${input.github.owner}/${input.github.repo} @ ${shortSha}`,
84
+ href: githubBlobUrl(
85
+ input.github.owner,
86
+ input.github.repo,
87
+ input.github.sha,
88
+ input.path,
89
+ ),
90
+ };
91
+ }
92
+ if (input.gist !== undefined) {
93
+ return {
94
+ kind: "gist",
95
+ label: `${input.gist.gistId} / ${input.gist.file}`,
96
+ href:
97
+ input.gist.htmlUrl.length > 0
98
+ ? input.gist.htmlUrl
99
+ : githubGistUrl(input.gist.gistId),
100
+ };
101
+ }
102
+ return { kind: "none" };
103
+ }