mergie-cli 0.1.0

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 (140) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +91 -0
  3. package/bin/mergie-dev +18 -0
  4. package/bin/mergie.ts +44 -0
  5. package/dist/web/assets/index-4bq8mkyV.css +1 -0
  6. package/dist/web/assets/index-Cp_Gdwf2.js +50 -0
  7. package/dist/web/favicon.svg +12 -0
  8. package/dist/web/index.html +14 -0
  9. package/package.json +68 -0
  10. package/src/cli/args.ts +63 -0
  11. package/src/cli/constants.ts +32 -0
  12. package/src/cli/daemonClient.ts +42 -0
  13. package/src/cli/openBrowser.ts +11 -0
  14. package/src/cli/run.ts +76 -0
  15. package/src/daemon/aiReviewTracker.ts +71 -0
  16. package/src/daemon/allComments.ts +119 -0
  17. package/src/daemon/artifactCapture.ts +47 -0
  18. package/src/daemon/buildUi.ts +54 -0
  19. package/src/daemon/chatPrompt.ts +35 -0
  20. package/src/daemon/chatSocket.ts +78 -0
  21. package/src/daemon/createRegistry.ts +569 -0
  22. package/src/daemon/githubThreads.ts +92 -0
  23. package/src/daemon/inflight.ts +49 -0
  24. package/src/daemon/postMapping.ts +94 -0
  25. package/src/daemon/registry.ts +263 -0
  26. package/src/daemon/reviewPrompt.ts +22 -0
  27. package/src/daemon/reviewService.ts +243 -0
  28. package/src/daemon/router.ts +287 -0
  29. package/src/daemon/server.ts +106 -0
  30. package/src/daemon/splitView.ts +63 -0
  31. package/src/daemon/trpc.ts +20 -0
  32. package/src/db/migrate.ts +24 -0
  33. package/src/db/repositories/aiReviews.ts +113 -0
  34. package/src/db/repositories/artifacts.ts +101 -0
  35. package/src/db/repositories/chatSessions.ts +197 -0
  36. package/src/db/repositories/comments.ts +195 -0
  37. package/src/db/repositories/githubComments.ts +166 -0
  38. package/src/db/repositories/hunkViews.ts +36 -0
  39. package/src/db/repositories/reviewedRanges.ts +75 -0
  40. package/src/db/schema.ts +97 -0
  41. package/src/domain/config.ts +137 -0
  42. package/src/domain/context.ts +32 -0
  43. package/src/domain/diff.ts +178 -0
  44. package/src/domain/entities.ts +92 -0
  45. package/src/domain/fuzzy.ts +43 -0
  46. package/src/domain/generated.ts +33 -0
  47. package/src/domain/hash.ts +0 -0
  48. package/src/domain/lockfiles.ts +20 -0
  49. package/src/domain/paths.ts +59 -0
  50. package/src/domain/ranges.ts +81 -0
  51. package/src/domain/references.ts +36 -0
  52. package/src/domain/url.ts +50 -0
  53. package/src/domain/wordDiff.ts +174 -0
  54. package/src/services/ai.ts +137 -0
  55. package/src/services/exec.ts +44 -0
  56. package/src/services/ghPr.ts +102 -0
  57. package/src/services/ghSearch.ts +135 -0
  58. package/src/services/git.ts +297 -0
  59. package/src/services/github.ts +300 -0
  60. package/src/services/symbols.ts +364 -0
  61. package/src/web/App.tsx +32 -0
  62. package/src/web/components/AiReviewIndicator.tsx +63 -0
  63. package/src/web/components/AiReviewModal.tsx +101 -0
  64. package/src/web/components/AiReviewsPanel.tsx +64 -0
  65. package/src/web/components/ChatPanel.tsx +142 -0
  66. package/src/web/components/CodePreview.tsx +63 -0
  67. package/src/web/components/CommentComposer.tsx +40 -0
  68. package/src/web/components/CommentItem.tsx +59 -0
  69. package/src/web/components/CommentsPanel.tsx +309 -0
  70. package/src/web/components/CommitRail.tsx +64 -0
  71. package/src/web/components/ConfirmButton.tsx +32 -0
  72. package/src/web/components/CopyButton.tsx +33 -0
  73. package/src/web/components/CopyIconButton.tsx +38 -0
  74. package/src/web/components/DiffFrame.tsx +133 -0
  75. package/src/web/components/DiffLines.tsx +149 -0
  76. package/src/web/components/FileFrame.tsx +45 -0
  77. package/src/web/components/FileNavigator.tsx +195 -0
  78. package/src/web/components/FileTree.tsx +45 -0
  79. package/src/web/components/FileView.tsx +53 -0
  80. package/src/web/components/GithubThreadView.tsx +56 -0
  81. package/src/web/components/HunkCard.tsx +121 -0
  82. package/src/web/components/Icons.tsx +215 -0
  83. package/src/web/components/IdentifierMenuPortal.tsx +33 -0
  84. package/src/web/components/PostMenu.tsx +63 -0
  85. package/src/web/components/PrDescription.tsx +29 -0
  86. package/src/web/components/PrLoading.tsx +45 -0
  87. package/src/web/components/PrPicker.tsx +201 -0
  88. package/src/web/components/RangeSelector.tsx +141 -0
  89. package/src/web/components/ResultsList.tsx +159 -0
  90. package/src/web/components/ReviewView.tsx +308 -0
  91. package/src/web/components/RightRail.tsx +146 -0
  92. package/src/web/components/SearchRailPanel.tsx +127 -0
  93. package/src/web/components/Switch.tsx +31 -0
  94. package/src/web/components/SwitchPrModal.tsx +28 -0
  95. package/src/web/components/SymbolLookupMenu.tsx +35 -0
  96. package/src/web/components/Toolbar.tsx +79 -0
  97. package/src/web/components/Tooltip.tsx +72 -0
  98. package/src/web/index.html +13 -0
  99. package/src/web/lib/aiReviewIndicator.ts +29 -0
  100. package/src/web/lib/anchorRow.ts +25 -0
  101. package/src/web/lib/codeSearchFetch.ts +46 -0
  102. package/src/web/lib/commentFilters.ts +36 -0
  103. package/src/web/lib/commentVisibility.ts +90 -0
  104. package/src/web/lib/composerKeys.ts +24 -0
  105. package/src/web/lib/dedupeResults.ts +37 -0
  106. package/src/web/lib/diffMarks.ts +81 -0
  107. package/src/web/lib/fileStatus.ts +12 -0
  108. package/src/web/lib/filterCodeResults.ts +42 -0
  109. package/src/web/lib/filterPrs.ts +38 -0
  110. package/src/web/lib/highlight.ts +40 -0
  111. package/src/web/lib/identifierMenu.ts +41 -0
  112. package/src/web/lib/lineSelection.ts +48 -0
  113. package/src/web/lib/navRouting.ts +40 -0
  114. package/src/web/lib/navStack.ts +109 -0
  115. package/src/web/lib/persistedFlag.ts +43 -0
  116. package/src/web/lib/prPickerModel.ts +27 -0
  117. package/src/web/lib/railState.ts +19 -0
  118. package/src/web/lib/rangeCoverage.ts +27 -0
  119. package/src/web/lib/rangeMap.ts +42 -0
  120. package/src/web/lib/resultCountLabel.ts +11 -0
  121. package/src/web/lib/reviewProgress.ts +26 -0
  122. package/src/web/lib/searchInputsKey.ts +35 -0
  123. package/src/web/lib/searchToken.ts +25 -0
  124. package/src/web/lib/splitSide.ts +13 -0
  125. package/src/web/lib/time.ts +9 -0
  126. package/src/web/lib/togglePrefs.ts +81 -0
  127. package/src/web/lib/useEscToClose.ts +17 -0
  128. package/src/web/lib/useIdentifierMenu.ts +77 -0
  129. package/src/web/lib/useNavLookup.ts +74 -0
  130. package/src/web/lib/usePageTitle.ts +15 -0
  131. package/src/web/lib/visibleFiles.ts +52 -0
  132. package/src/web/main.tsx +24 -0
  133. package/src/web/public/favicon.svg +12 -0
  134. package/src/web/state/useChat.ts +181 -0
  135. package/src/web/state/useCodeSearch.ts +198 -0
  136. package/src/web/state/useReview.ts +138 -0
  137. package/src/web/styles.css +1020 -0
  138. package/src/web/trpc.ts +5 -0
  139. package/tsconfig.json +27 -0
  140. package/vite.config.ts +29 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * A small hand-rolled inline-SVG icon set (no dependency). Every icon inherits
3
+ * the current text color via `stroke="currentColor"` and sizes to the `size`
4
+ * prop (default 16px). Icons are decorative by default (aria-hidden); pass a
5
+ * `title` only where the icon stands alone as a control's label.
6
+ */
7
+ interface IconProps {
8
+ /** Pixel width/height of the square icon. Defaults to 16. */
9
+ size?: number;
10
+ /** Extra class name for spacing tweaks at a call site. */
11
+ className?: string;
12
+ }
13
+
14
+ /** Shared SVG wrapper: square viewBox, stroke-based, inherits color. */
15
+ function Svg(props: IconProps & { children: React.ReactNode }): React.JSX.Element {
16
+ const { size = 16, className, children } = props;
17
+ return (
18
+ <svg
19
+ className={className ? `icon ${className}` : "icon"}
20
+ width={size}
21
+ height={size}
22
+ viewBox="0 0 24 24"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ strokeWidth={2}
26
+ strokeLinecap="round"
27
+ strokeLinejoin="round"
28
+ aria-hidden="true"
29
+ >
30
+ {children}
31
+ </svg>
32
+ );
33
+ }
34
+
35
+ /** Circular refresh / re-fetch arrows. */
36
+ export function RefreshIcon(props: IconProps): React.JSX.Element {
37
+ return (
38
+ <Svg {...props}>
39
+ <path d="M21 12a9 9 0 1 1-2.64-6.36" />
40
+ <polyline points="21 3 21 9 15 9" />
41
+ </Svg>
42
+ );
43
+ }
44
+
45
+ /** Two-arrow sync / fetch glyph. */
46
+ export function SyncIcon(props: IconProps): React.JSX.Element {
47
+ return (
48
+ <Svg {...props}>
49
+ <polyline points="17 1 21 5 17 9" />
50
+ <path d="M3 11V9a4 4 0 0 1 4-4h14" />
51
+ <polyline points="7 23 3 19 7 15" />
52
+ <path d="M21 13v2a4 4 0 0 1-4 4H3" />
53
+ </Svg>
54
+ );
55
+ }
56
+
57
+ /** External-link (open in new tab) arrow-out-of-box. */
58
+ export function ExternalIcon(props: IconProps): React.JSX.Element {
59
+ return (
60
+ <Svg {...props}>
61
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
62
+ <polyline points="15 3 21 3 21 9" />
63
+ <line x1="10" y1="14" x2="21" y2="3" />
64
+ </Svg>
65
+ );
66
+ }
67
+
68
+ /** Copy to clipboard: two overlapping document rectangles. */
69
+ export function CopyIcon(props: IconProps): React.JSX.Element {
70
+ return (
71
+ <Svg {...props}>
72
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
73
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
74
+ </Svg>
75
+ );
76
+ }
77
+
78
+ /** Close / dismiss X. */
79
+ export function CloseIcon(props: IconProps): React.JSX.Element {
80
+ return (
81
+ <Svg {...props}>
82
+ <line x1="18" y1="6" x2="6" y2="18" />
83
+ <line x1="6" y1="6" x2="18" y2="18" />
84
+ </Svg>
85
+ );
86
+ }
87
+
88
+ /** Downward chevron (disclosure / dropdown). */
89
+ export function ChevronDownIcon(props: IconProps): React.JSX.Element {
90
+ return (
91
+ <Svg {...props}>
92
+ <polyline points="6 9 12 15 18 9" />
93
+ </Svg>
94
+ );
95
+ }
96
+
97
+ /** Rightward chevron (collapsed disclosure). */
98
+ export function ChevronRightIcon(props: IconProps): React.JSX.Element {
99
+ return (
100
+ <Svg {...props}>
101
+ <polyline points="9 6 15 12 9 18" />
102
+ </Svg>
103
+ );
104
+ }
105
+
106
+ /** Leftward chevron (collapse toward the left edge). */
107
+ export function ChevronLeftIcon(props: IconProps): React.JSX.Element {
108
+ return (
109
+ <Svg {...props}>
110
+ <polyline points="15 6 9 12 15 18" />
111
+ </Svg>
112
+ );
113
+ }
114
+
115
+ /** Sparkle / AI glyph. */
116
+ export function SparkleIcon(props: IconProps): React.JSX.Element {
117
+ return (
118
+ <Svg {...props}>
119
+ <path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" />
120
+ <path d="M19 15l.7 1.8L21.5 17.5 19.7 18.2 19 20l-.7-1.8L16.5 17.5 18.3 16.8z" />
121
+ </Svg>
122
+ );
123
+ }
124
+
125
+ /** Speech-bubble comment glyph. */
126
+ export function CommentIcon(props: IconProps): React.JSX.Element {
127
+ return (
128
+ <Svg {...props}>
129
+ <path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7A8.5 8.5 0 0 1 12.5 3 8.38 8.38 0 0 1 21 11.5z" />
130
+ </Svg>
131
+ );
132
+ }
133
+
134
+ /** Magnifier search glyph. */
135
+ export function SearchIcon(props: IconProps): React.JSX.Element {
136
+ return (
137
+ <Svg {...props}>
138
+ <circle cx="11" cy="11" r="7" />
139
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
140
+ </Svg>
141
+ );
142
+ }
143
+
144
+ /** Info glyph: a circled "i" (caveats / help). */
145
+ export function InfoIcon(props: IconProps): React.JSX.Element {
146
+ return (
147
+ <Svg {...props}>
148
+ <circle cx="12" cy="12" r="10" />
149
+ <line x1="12" y1="11" x2="12" y2="16" />
150
+ <line x1="12" y1="8" x2="12" y2="8" />
151
+ </Svg>
152
+ );
153
+ }
154
+
155
+ /** Check / done glyph. */
156
+ export function CheckIcon(props: IconProps): React.JSX.Element {
157
+ return (
158
+ <Svg {...props}>
159
+ <polyline points="20 6 9 17 4 12" />
160
+ </Svg>
161
+ );
162
+ }
163
+
164
+ /** Generic file glyph (file-status contexts). */
165
+ export function FileIcon(props: IconProps): React.JSX.Element {
166
+ return (
167
+ <Svg {...props}>
168
+ <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
169
+ <polyline points="13 2 13 9 20 9" />
170
+ </Svg>
171
+ );
172
+ }
173
+
174
+ /** Inbox / empty-state glyph. */
175
+ export function InboxIcon(props: IconProps): React.JSX.Element {
176
+ return (
177
+ <Svg {...props}>
178
+ <polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
179
+ <path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
180
+ </Svg>
181
+ );
182
+ }
183
+
184
+ /** Document / lined-page glyph (PR description). */
185
+ export function DocumentIcon(props: IconProps): React.JSX.Element {
186
+ return (
187
+ <Svg {...props}>
188
+ <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
189
+ <polyline points="13 2 13 9 20 9" />
190
+ <line x1="8" y1="13" x2="16" y2="13" />
191
+ <line x1="8" y1="17" x2="13" y2="17" />
192
+ </Svg>
193
+ );
194
+ }
195
+
196
+ /** Eye / view glyph (open the full file). */
197
+ export function EyeIcon(props: IconProps): React.JSX.Element {
198
+ return (
199
+ <Svg {...props}>
200
+ <path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
201
+ <circle cx="12" cy="12" r="3" />
202
+ </Svg>
203
+ );
204
+ }
205
+
206
+ /** Speech-bubble with a plus (add a comment on the hunk). */
207
+ export function CommentPlusIcon(props: IconProps): React.JSX.Element {
208
+ return (
209
+ <Svg {...props}>
210
+ <path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7A8.5 8.5 0 0 1 12.5 3 8.38 8.38 0 0 1 21 11.5z" />
211
+ <line x1="12" y1="8" x2="12" y2="14" />
212
+ <line x1="9" y1="11" x2="15" y2="11" />
213
+ </Svg>
214
+ );
215
+ }
@@ -0,0 +1,33 @@
1
+ import { createPortal } from "react-dom";
2
+ import { SymbolLookupMenu } from "./SymbolLookupMenu.tsx";
3
+ import type { IdentifierMenu } from "@/web/lib/useIdentifierMenu.ts";
4
+ import type { MenuOp, SearchSide } from "../state/useCodeSearch.ts";
5
+
6
+ /**
7
+ * Renders {@link SymbolLookupMenu} for an open identifier selection via a portal
8
+ * to `document.body`, so the floating menu escapes any `overflow`/`clip`
9
+ * ancestor and layers above modals (the navigator, the full-file view). Returns
10
+ * null when no menu is open.
11
+ *
12
+ * @param props.menu - The open menu descriptor, or null.
13
+ * @param props.onPick - Called with the chosen lookup op + checkout side.
14
+ * @param props.onClose - Dismiss the menu.
15
+ */
16
+ export function IdentifierMenuPortal(props: {
17
+ menu: IdentifierMenu | null;
18
+ onPick: (op: MenuOp, side: SearchSide) => void;
19
+ onClose: () => void;
20
+ }): React.JSX.Element | null {
21
+ const { menu, onPick, onClose } = props;
22
+ if (!menu) return null;
23
+ // Key by the selection so a new selection remounts the menu, re-seeding its
24
+ // side toggle from the new selection's default side.
25
+ return createPortal(
26
+ <SymbolLookupMenu
27
+ key={`${menu.term}:${menu.x}:${menu.y}`}
28
+ term={menu.term} x={menu.x} y={menu.y} defaultSide={menu.side}
29
+ onPick={onPick} onClose={onClose}
30
+ />,
31
+ document.body,
32
+ );
33
+ }
@@ -0,0 +1,63 @@
1
+ import { useState } from "react";
2
+ import { ChevronDownIcon } from "./Icons.tsx";
3
+ import type { PostPreview, PostTarget } from "@/daemon/registry.ts";
4
+
5
+ /**
6
+ * A "Post to GitHub" control offering the two targets (the reviewed range-end
7
+ * commit, or the live PR head). Each choice first previews whether the exact
8
+ * line still exists at that target and shows a warning instead of posting when
9
+ * it does not.
10
+ */
11
+ export function PostMenu(props: {
12
+ preview: (target: PostTarget) => Promise<PostPreview>;
13
+ onPost: (target: PostTarget) => void;
14
+ }): React.JSX.Element {
15
+ const { preview, onPost } = props;
16
+ const [open, setOpen] = useState(false);
17
+ const [busy, setBusy] = useState(false);
18
+ const [warning, setWarning] = useState<string | null>(null);
19
+
20
+ const choose = async (target: PostTarget): Promise<void> => {
21
+ setBusy(true);
22
+ setWarning(null);
23
+ const result: PostPreview = await preview(target);
24
+ setBusy(false);
25
+ if (!result.canPost) {
26
+ setWarning(result.warning ?? "The line no longer exists at this target.");
27
+ return;
28
+ }
29
+ onPost(target);
30
+ setOpen(false);
31
+ };
32
+
33
+ if (!open) {
34
+ return (
35
+ <button type="button" className="btn btn-ghost btn-sm" onClick={() => setOpen(true)}>
36
+ Post to GitHub <ChevronDownIcon size={12} />
37
+ </button>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <span className="post-menu" role="menu">
43
+ <span className="post-menu-hint">Post as an inline comment on:</span>
44
+ <button
45
+ type="button"
46
+ className="btn btn-sm"
47
+ disabled={busy}
48
+ title="Pins the comment to the exact code you reviewed (the range's end commit)."
49
+ onClick={() => void choose("end")}
50
+ >Reviewed commit</button>
51
+ <button
52
+ type="button"
53
+ className="btn btn-sm"
54
+ disabled={busy}
55
+ title="Attaches to the latest code on the PR — relocated if the line moved."
56
+ onClick={() => void choose("head")}
57
+ >Latest PR head</button>
58
+ <button type="button" className="btn btn-ghost btn-sm" disabled={busy} onClick={() => { setOpen(false); setWarning(null); }}>Cancel</button>
59
+ {busy && <span className="post-busy">Checking target…</span>}
60
+ {warning && <span className="post-warning">{warning}</span>}
61
+ </span>
62
+ );
63
+ }
@@ -0,0 +1,29 @@
1
+ import Markdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
3
+
4
+ /** Force any link in the rendered markdown to open in a new tab. */
5
+ const MARKDOWN_COMPONENTS = {
6
+ a: (props: React.ComponentPropsWithoutRef<"a">) => (
7
+ <a {...props} target="_blank" rel="noreferrer" />
8
+ ),
9
+ } as const;
10
+
11
+ /**
12
+ * The PR description (body) rendered as GitHub-flavored markdown. Hosted in the
13
+ * right-rail "PR description" panel. An empty body shows a muted "No description
14
+ * provided." note.
15
+ */
16
+ export function PrDescription(props: { body: string }): React.JSX.Element {
17
+ const hasBody: boolean = props.body.trim().length > 0;
18
+ return (
19
+ <div className="pr-description-body comment-body">
20
+ {hasBody ? (
21
+ <Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
22
+ {props.body}
23
+ </Markdown>
24
+ ) : (
25
+ <p className="notice">No description provided.</p>
26
+ )}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,45 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { trpc } from "../trpc.ts";
3
+
4
+ /**
5
+ * The loading gate shown after picking a not-yet-loaded PR. Fires `loadPr`
6
+ * once on mount (idempotent server-side) and, once it resolves, replaces the
7
+ * URL with the review deep-link so App routes into the review screen. Failures
8
+ * are shown here with a way back to the picker.
9
+ */
10
+ export function PrLoading(props: { url: string }): React.JSX.Element {
11
+ const load = trpc.loadPr.useMutation();
12
+ const started = useRef(false);
13
+ const { mutate } = load;
14
+
15
+ useEffect(() => {
16
+ if (started.current) return;
17
+ started.current = true;
18
+ mutate(
19
+ { url: props.url },
20
+ { onSuccess: (pr) => window.location.replace(`/?pr=${encodeURIComponent(pr.id)}`) },
21
+ );
22
+ }, [mutate, props.url]);
23
+
24
+ if (load.isError) {
25
+ return (
26
+ <main className="landing">
27
+ <div className="pr-loading-fail" role="alert">
28
+ <p className="empty-state-title">Couldn’t load that pull request</p>
29
+ <p className="empty-state-hint">{load.error.message}</p>
30
+ <a className="btn" href="/">Back to pull requests</a>
31
+ </div>
32
+ </main>
33
+ );
34
+ }
35
+
36
+ return (
37
+ <main className="landing">
38
+ <div className="pr-loading">
39
+ <span className="chat-spinner" aria-hidden="true" />
40
+ <p className="empty-state-title">Loading pull request…</p>
41
+ <p className="empty-state-hint">Fetching from GitHub and preparing the diff.</p>
42
+ </div>
43
+ </main>
44
+ );
45
+ }
@@ -0,0 +1,201 @@
1
+ import { useState } from "react";
2
+ import { trpc } from "../trpc.ts";
3
+ import { parsePrUrl } from "@/domain/url.ts";
4
+ import { filterPrs } from "@/web/lib/filterPrs.ts";
5
+ import { excludeLoaded } from "@/web/lib/prPickerModel.ts";
6
+ import { RefreshIcon, SearchIcon, InboxIcon } from "./Icons.tsx";
7
+ import type { MyPrSummary, PrRelationship } from "@/services/ghSearch.ts";
8
+ import type { LoadedPr } from "@/daemon/registry.ts";
9
+
10
+ /** Human labels for each PR relationship, shown as row chips. */
11
+ const RELATION_LABEL: Record<PrRelationship, string> = {
12
+ authored: "Created by me",
13
+ assigned: "Assigned",
14
+ "review-requested": "Review requested",
15
+ };
16
+
17
+ /** Navigate the browser, letting App re-route on the new query string. */
18
+ function go(href: string): void {
19
+ window.location.assign(href);
20
+ }
21
+
22
+ /** Deep-link for an already-loaded PR (opens straight into review). */
23
+ function loadedHref(id: string): string {
24
+ return `/?pr=${encodeURIComponent(id)}`;
25
+ }
26
+
27
+ /** Deep-link that routes through the loading gate for a not-yet-loaded PR. */
28
+ function loadHref(url: string): string {
29
+ return `/?load=${encodeURIComponent(url)}`;
30
+ }
31
+
32
+ /**
33
+ * The PR picker: a custom-URL input, a text filter, the PRs already loaded in
34
+ * mergie ("Recently reviewed"), and the viewer's open PRs from GitHub. Used
35
+ * both as the home screen and inside the in-review "Switch PR" overlay.
36
+ */
37
+ export function PrPicker(props: { currentPrId?: string }): React.JSX.Element {
38
+ const [query, setQuery] = useState("");
39
+ const health = trpc.health.useQuery();
40
+ const mine = trpc.listMyPrs.useQuery(undefined, { staleTime: Infinity, retry: false });
41
+
42
+ const loaded: LoadedPr[] = health.data?.prs ?? [];
43
+ const loadedShown: LoadedPr[] = filterPrs(query, loaded);
44
+ const searchShown: MyPrSummary[] = filterPrs(query, excludeLoaded(mine.data ?? [], loaded));
45
+
46
+ return (
47
+ <div className="picker">
48
+ <UrlForm />
49
+ <div className="picker-filter">
50
+ <SearchIcon size={14} />
51
+ <input
52
+ type="text"
53
+ className="picker-filter-input"
54
+ placeholder="Filter by repo, owner, title, or #number"
55
+ value={query}
56
+ onChange={(e) => setQuery(e.target.value)}
57
+ aria-label="Filter pull requests"
58
+ />
59
+ </div>
60
+
61
+ {loadedShown.length > 0 && (
62
+ <section className="picker-section">
63
+ <h3 className="picker-section-title">Recently reviewed</h3>
64
+ <ul className="pr-cards">
65
+ {loadedShown.map((p) => (
66
+ <LoadedRow key={p.id} pr={p} isCurrent={p.id === props.currentPrId} />
67
+ ))}
68
+ </ul>
69
+ </section>
70
+ )}
71
+
72
+ <section className="picker-section">
73
+ <div className="picker-section-head">
74
+ <h3 className="picker-section-title">From GitHub</h3>
75
+ <button
76
+ type="button"
77
+ className="btn btn-ghost btn-sm"
78
+ onClick={() => void mine.refetch()}
79
+ disabled={mine.isFetching}
80
+ title="Refresh the list from GitHub"
81
+ >
82
+ <RefreshIcon size={13} /> {mine.isFetching ? "Refreshing…" : "Refresh"}
83
+ </button>
84
+ </div>
85
+ <GithubResults query={query} loading={mine.isLoading} error={mine.error?.message ?? null} prs={searchShown} />
86
+ </section>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ /** The "load a PR by URL" input; validates the URL before navigating. */
92
+ function UrlForm(): React.JSX.Element {
93
+ const [url, setUrl] = useState("");
94
+ const [error, setError] = useState<string | null>(null);
95
+
96
+ const submit = (): void => {
97
+ const trimmed: string = url.trim();
98
+ if (trimmed.length === 0) return;
99
+ try {
100
+ parsePrUrl(trimmed);
101
+ } catch {
102
+ setError("Enter a GitHub pull-request URL, e.g. https://github.com/owner/repo/pull/123");
103
+ return;
104
+ }
105
+ go(loadHref(trimmed));
106
+ };
107
+
108
+ return (
109
+ <form className="picker-url" onSubmit={(e) => { e.preventDefault(); submit(); }}>
110
+ <input
111
+ type="text"
112
+ className="picker-url-input"
113
+ placeholder="Paste a pull-request URL…"
114
+ value={url}
115
+ onChange={(e) => { setUrl(e.target.value); setError(null); }}
116
+ aria-label="Pull request URL"
117
+ />
118
+ <button type="submit" className="btn btn-accent" disabled={url.trim().length === 0}>Open</button>
119
+ {error && <p className="picker-url-error" role="alert">{error}</p>}
120
+ </form>
121
+ );
122
+ }
123
+
124
+ /** Render the GitHub-search section body: spinner, error, empty, or rows. */
125
+ function GithubResults(props: {
126
+ query: string;
127
+ loading: boolean;
128
+ error: string | null;
129
+ prs: MyPrSummary[];
130
+ }): React.JSX.Element {
131
+ if (props.loading) {
132
+ return <div className="picker-status"><span className="chat-spinner" aria-hidden="true" /> Finding your pull requests…</div>;
133
+ }
134
+ if (props.error !== null) {
135
+ return (
136
+ <div className="picker-status picker-error" role="alert">
137
+ Couldn’t reach GitHub: {props.error}
138
+ </div>
139
+ );
140
+ }
141
+ if (props.prs.length === 0) {
142
+ return (
143
+ <div className="empty-state">
144
+ <InboxIcon size={32} />
145
+ <p className="empty-state-title">{props.query ? "No matching pull requests" : "No open pull requests"}</p>
146
+ <p className="empty-state-hint">
147
+ {props.query ? "Try a different filter, or paste a URL above." : "PRs you created, are assigned to, or are asked to review show up here."}
148
+ </p>
149
+ </div>
150
+ );
151
+ }
152
+ return (
153
+ <ul className="pr-cards">
154
+ {props.prs.map((p) => <GithubRow key={p.url} pr={p} />)}
155
+ </ul>
156
+ );
157
+ }
158
+
159
+ /** One already-loaded PR row. */
160
+ function LoadedRow(props: { pr: LoadedPr; isCurrent: boolean }): React.JSX.Element {
161
+ const { pr, isCurrent } = props;
162
+ const meta = (
163
+ <span className="pr-card-meta">
164
+ <span className="pr-card-repo">{pr.owner}/{pr.repo}</span>
165
+ <span className="pr-card-num">#{pr.number}</span>
166
+ {isCurrent && <span className="pr-chip pr-chip-current">Reviewing now</span>}
167
+ </span>
168
+ );
169
+ if (isCurrent) {
170
+ return <li><div className="pr-card pr-card-current">{meta}<span className="pr-card-title">{pr.title}</span></div></li>;
171
+ }
172
+ return (
173
+ <li>
174
+ <a className="pr-card" href={loadedHref(pr.id)}>
175
+ {meta}
176
+ <span className="pr-card-title">{pr.title}</span>
177
+ </a>
178
+ </li>
179
+ );
180
+ }
181
+
182
+ /** One GitHub-search PR row, tagged with the viewer's relationship(s). */
183
+ function GithubRow(props: { pr: MyPrSummary }): React.JSX.Element {
184
+ const { pr } = props;
185
+ return (
186
+ <li>
187
+ <a className="pr-card" href={loadHref(pr.url)}>
188
+ <span className="pr-card-meta">
189
+ <span className="pr-card-repo">{pr.owner}/{pr.repo}</span>
190
+ <span className="pr-card-num">#{pr.number}</span>
191
+ {pr.isDraft && <span className="pr-chip pr-chip-draft">Draft</span>}
192
+ {pr.relationships.map((r) => (
193
+ <span key={r} className="pr-chip">{RELATION_LABEL[r]}</span>
194
+ ))}
195
+ </span>
196
+ <span className="pr-card-title">{pr.title}</span>
197
+ {pr.author && <span className="pr-card-author">by {pr.author}</span>}
198
+ </a>
199
+ </li>
200
+ );
201
+ }