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,142 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import Markdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import { CloseIcon, SparkleIcon } from "./Icons.tsx";
5
+ import type { ChatState } from "../state/useChat.ts";
6
+
7
+ /** Format a millisecond timestamp as a short local time (e.g. "2:45 PM"). */
8
+ function shortTime(ms: number): string {
9
+ return new Date(ms).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
10
+ }
11
+
12
+ /** One rendered chat bubble (persisted or streaming). */
13
+ function Bubble(props: { role: "user" | "assistant"; content: string; time?: number; children?: React.ReactNode }): React.JSX.Element {
14
+ const { role, content, time, children } = props;
15
+ return (
16
+ <div className={`chat-msg chat-${role}`}>
17
+ <div className="chat-msg-head">
18
+ <span className="chat-role">{role === "user" ? "You" : "AI"}</span>
19
+ {time !== undefined && <span className="chat-time">{shortTime(time)}</span>}
20
+ {content.length > 0 && (
21
+ <button type="button" className="chat-copy btn btn-ghost btn-sm" title="Copy" onClick={() => void navigator.clipboard.writeText(content)}>Copy</button>
22
+ )}
23
+ </div>
24
+ {content.length > 0 && <div className="comment-body"><Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown></div>}
25
+ {children}
26
+ </div>
27
+ );
28
+ }
29
+
30
+ /** Animated "the agent is working" footer: spinner, current activity, elapsed time. */
31
+ function Working(props: { activity: string | null; elapsed: number }): React.JSX.Element {
32
+ return (
33
+ <div className="chat-working" role="status">
34
+ <span className="chat-spinner" aria-hidden="true" />
35
+ <span className="chat-working-label">{props.activity ?? "Thinking"}</span>
36
+ <span className="chat-elapsed">{props.elapsed}s</span>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ /**
42
+ * The dockable AI chat panel: session list + model picker, the transcript with
43
+ * a live-streaming reply and agent-activity indicator, and the prompt box.
44
+ */
45
+ export function ChatPanel(props: { chat: ChatState }): React.JSX.Element | null {
46
+ const { chat } = props;
47
+ const [draft, setDraft] = useState("");
48
+ const [elapsed, setElapsed] = useState(0);
49
+ const endRef = useRef<HTMLDivElement>(null);
50
+ const taRef = useRef<HTMLTextAreaElement>(null);
51
+
52
+ // Tick an elapsed-seconds counter while a turn is streaming.
53
+ useEffect(() => {
54
+ if (!chat.streaming) { setElapsed(0); return; }
55
+ const start = Date.now();
56
+ const t = setInterval(() => setElapsed(Math.round((Date.now() - start) / 1000)), 1000);
57
+ return () => clearInterval(t);
58
+ }, [chat.streaming]);
59
+
60
+ // Keep the latest content in view as the transcript grows / streams.
61
+ useEffect(() => {
62
+ endRef.current?.scrollIntoView({ block: "end" });
63
+ }, [chat.messages, chat.streamText, chat.pending, chat.activity, chat.scope]);
64
+
65
+ if (!chat.scope) return null;
66
+
67
+ const turnInFlight: boolean = chat.pending !== null;
68
+ const submit = (): void => {
69
+ const text = draft.trim();
70
+ if (text.length === 0 || chat.streaming) return;
71
+ chat.send(text);
72
+ setDraft("");
73
+ taRef.current?.focus();
74
+ };
75
+
76
+ return (
77
+ <aside className="chat-panel">
78
+ <header className="chat-panel-header">
79
+ <strong><SparkleIcon size={15} /> AI chat</strong>
80
+ <span className="chat-scope" title={chat.scope.label}>{chat.scope.label}</span>
81
+ <button type="button" className="symbol-panel-close" onClick={chat.close} title="Close" aria-label="Close"><CloseIcon size={16} /></button>
82
+ </header>
83
+ <div className="chat-controls">
84
+ <select value={chat.activeId ?? "new"} onChange={(e) => e.target.value === "new" ? chat.newSession() : chat.selectSession(Number(e.target.value))}>
85
+ <option value="new">+ New session</option>
86
+ {chat.sessions.map((s) => <option key={s.id} value={s.id}>{s.title}</option>)}
87
+ </select>
88
+ <select
89
+ value={chat.model}
90
+ onChange={(e) => chat.setModel(e.target.value)}
91
+ disabled={chat.activeId !== null}
92
+ title={chat.activeId !== null ? "Model is fixed for a session — start a new session to switch" : "Choose the model"}
93
+ >
94
+ {chat.models.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
95
+ </select>
96
+ </div>
97
+ <div className="chat-transcript">
98
+ {chat.messages.map((m) => <Bubble key={m.id} role={m.role} content={m.content} time={m.createdAt} />)}
99
+ {turnInFlight && <Bubble role="user" content={chat.pending ?? ""} />}
100
+ {turnInFlight && (
101
+ <Bubble role="assistant" content={chat.streamText}>
102
+ {chat.streaming && <Working activity={chat.activity} elapsed={elapsed} />}
103
+ </Bubble>
104
+ )}
105
+ {chat.error && <p className="notice chat-error">{chat.error}</p>}
106
+ {chat.messages.length === 0 && !turnInFlight && (
107
+ <div className="empty-state">
108
+ <SparkleIcon size={32} />
109
+ <p className="empty-state-title">Ask about this {chat.scope.kind}</p>
110
+ <p className="empty-state-hint">The AI can read the full code at both versions of the range.</p>
111
+ </div>
112
+ )}
113
+ <div ref={endRef} />
114
+ </div>
115
+ {chat.artifacts.length > 0 && (
116
+ <div className="chat-artifacts">
117
+ <div className="chat-role">Artifacts (this range)</div>
118
+ <ul>
119
+ {chat.artifacts.map((a) => (
120
+ <li key={a.id}>
121
+ <a href={chat.artifactUrl(a.relPath)} target="_blank" rel="noreferrer">{a.title}</a>
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ </div>
126
+ )}
127
+ <div className="chat-input">
128
+ <textarea
129
+ ref={taRef}
130
+ className="comment-textarea"
131
+ value={draft}
132
+ placeholder="Ask the AI (⌘/Ctrl+Enter to send)…"
133
+ onChange={(e) => setDraft(e.target.value)}
134
+ onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submit(); }}
135
+ />
136
+ <button type="button" className="btn btn-primary" onClick={submit} disabled={chat.streaming || draft.trim().length === 0}>
137
+ {chat.streaming ? "Working…" : "Send"}
138
+ </button>
139
+ </div>
140
+ </aside>
141
+ );
142
+ }
@@ -0,0 +1,63 @@
1
+ import { highlightToHtml, languageForPath } from "@/web/lib/highlight.ts";
2
+ import type { CodeResult } from "@/services/symbols.ts";
3
+
4
+ /** Beyond this many lines, render plain (no highlighting) to avoid jank. */
5
+ const HIGHLIGHT_LINE_CAP = 400;
6
+
7
+ /** One rendered code line: its 1-based number, text, and whether it is the hit. */
8
+ interface PreviewLine {
9
+ /** 1-based line number in the source file. */
10
+ no: number;
11
+ /** The line's text. */
12
+ text: string;
13
+ /** True for the matched/definition-start line (visually emphasized). */
14
+ matched: boolean;
15
+ }
16
+
17
+ /** Build the display lines for a result, numbered from its real file lines. */
18
+ function previewLines(r: CodeResult): PreviewLine[] {
19
+ if (r.kind === "definition") {
20
+ const body: string[] = (r.body ?? r.matched).split("\n");
21
+ return body.map((text, i) => ({ no: r.line + i, text, matched: i === 0 }));
22
+ }
23
+ const before: PreviewLine[] = r.before.map((text, i) => ({ no: r.line - r.before.length + i, text, matched: false }));
24
+ const after: PreviewLine[] = r.after.map((text, i) => ({ no: r.line + 1 + i, text, matched: false }));
25
+ return [...before, { no: r.line, text: r.matched, matched: true }, ...after];
26
+ }
27
+
28
+ /**
29
+ * Render one {@link CodeResult} as a compact, syntax-highlighted code block.
30
+ * For a definition the full `body` is shown; otherwise the `before/matched/
31
+ * after` context is shown with the matched line emphasized. The language is
32
+ * inferred from the file path. Blocks longer than {@link HIGHLIGHT_LINE_CAP}
33
+ * render as plain (escaped) text to avoid highlighter jank on huge bodies.
34
+ *
35
+ * @param props.result - The result to render.
36
+ */
37
+ export function CodePreview(props: { result: CodeResult }): React.JSX.Element {
38
+ const { result } = props;
39
+ const lines: PreviewLine[] = previewLines(result);
40
+ const language: string | undefined = languageForPath(result.path);
41
+ const highlight: boolean = lines.length <= HIGHLIGHT_LINE_CAP;
42
+
43
+ return (
44
+ <table className="code-preview">
45
+ <tbody>
46
+ {lines.map((l, i) => (
47
+ <tr key={i} className={l.matched ? "code-preview-line matched" : "code-preview-line"}>
48
+ <td className="code-preview-no">{l.no}</td>
49
+ <td
50
+ className="code-preview-code"
51
+ dangerouslySetInnerHTML={{ __html: highlight ? highlightToHtml(l.text, language) : escapePlain(l.text) }}
52
+ />
53
+ </tr>
54
+ ))}
55
+ </tbody>
56
+ </table>
57
+ );
58
+ }
59
+
60
+ /** Minimal HTML escape for the uncapped plain-render path. */
61
+ function escapePlain(text: string): string {
62
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
63
+ }
@@ -0,0 +1,40 @@
1
+ import { useState } from "react";
2
+ import { composerKeyIntent } from "@/web/lib/composerKeys.ts";
3
+
4
+ /** A small textarea composer for creating or editing a comment. */
5
+ export function CommentComposer(props: {
6
+ initial?: string;
7
+ submitLabel?: string;
8
+ onSubmit: (body: string) => void;
9
+ onCancel: () => void;
10
+ }): React.JSX.Element {
11
+ const [draft, setDraft] = useState<string>(props.initial ?? "");
12
+ const empty: boolean = draft.trim().length === 0;
13
+ const submit = (): void => {
14
+ const trimmed: string = draft.trim();
15
+ if (trimmed.length > 0) props.onSubmit(trimmed);
16
+ };
17
+ const onKeyDown = (e: React.KeyboardEvent): void => {
18
+ const intent = composerKeyIntent(e);
19
+ if (intent === "submit") { e.preventDefault(); submit(); }
20
+ else if (intent === "cancel") { e.preventDefault(); props.onCancel(); }
21
+ };
22
+ return (
23
+ <div className="comment-composer">
24
+ <textarea
25
+ className="comment-textarea"
26
+ value={draft}
27
+ placeholder="Leave a comment (markdown · ⌘/Ctrl+Enter to submit · Esc to cancel)…"
28
+ autoFocus
29
+ onChange={(e) => setDraft(e.target.value)}
30
+ onKeyDown={onKeyDown}
31
+ />
32
+ <div className="comment-actions">
33
+ <button type="button" className="btn btn-primary btn-sm" onClick={submit} disabled={empty} title="⌘/Ctrl+Enter">
34
+ {props.submitLabel ?? "Comment"}
35
+ </button>
36
+ <button type="button" className="btn btn-sm" onClick={props.onCancel} title="Esc">Cancel</button>
37
+ </div>
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,59 @@
1
+ import { useState } from "react";
2
+ import Markdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import { formatCommitTime } from "@/web/lib/time.ts";
5
+ import { CommentComposer } from "./CommentComposer.tsx";
6
+ import { CopyButton } from "./CopyButton.tsx";
7
+ import { PostMenu } from "./PostMenu.tsx";
8
+ import { ConfirmButton } from "./ConfirmButton.tsx";
9
+ import { localCommentDomId } from "@/web/lib/commentVisibility.ts";
10
+ import type { AnchoredComment } from "@/daemon/reviewService.ts";
11
+ import type { PostPreview, PostTarget } from "@/daemon/registry.ts";
12
+
13
+ /** A single rendered comment with markdown, edit/delete, copy, and post actions. */
14
+ export function CommentItem(props: {
15
+ comment: AnchoredComment;
16
+ onEdit: (body: string) => void;
17
+ onDelete: () => void;
18
+ /** Preview where this comment would post; enables the post control when set. */
19
+ previewPost?: (target: PostTarget) => Promise<PostPreview>;
20
+ /** Post this comment to GitHub at the chosen target. */
21
+ onPost?: (target: PostTarget) => void;
22
+ }): React.JSX.Element {
23
+ const { comment, onEdit, onDelete, previewPost, onPost } = props;
24
+ const [editing, setEditing] = useState(false);
25
+ const posted: boolean = comment.githubUrl !== null && comment.githubUrl !== undefined;
26
+ const canPost: boolean = !posted && previewPost !== undefined && onPost !== undefined;
27
+
28
+ if (editing) {
29
+ return (
30
+ <div className="comment-item" id={localCommentDomId(comment.id)}>
31
+ <CommentComposer
32
+ initial={comment.body}
33
+ submitLabel="Save"
34
+ onSubmit={(body) => { onEdit(body); setEditing(false); }}
35
+ onCancel={() => setEditing(false)}
36
+ />
37
+ </div>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <div className="comment-item" id={localCommentDomId(comment.id)}>
43
+ <div className="comment-meta">
44
+ <span className={`badge origin-${posted ? "posted" : "local"}`}>{posted ? "posted to GitHub" : "local draft"}</span>
45
+ <span className="comment-time">{formatCommitTime(new Date(comment.createdAt).toISOString())}</span>
46
+ {comment.githubUrl && <a href={comment.githubUrl} target="_blank" rel="noreferrer">on GitHub</a>}
47
+ <span className="comment-tools">
48
+ {canPost && previewPost && onPost && <PostMenu preview={previewPost} onPost={onPost} />}
49
+ <button type="button" className="btn btn-ghost btn-sm" onClick={() => setEditing(true)}>Edit</button>
50
+ <ConfirmButton warning={posted ? "Also deletes it on GitHub." : undefined} onConfirm={onDelete} />
51
+ <CopyButton text={comment.body} className="btn btn-ghost btn-sm" />
52
+ </span>
53
+ </div>
54
+ <div className="comment-body">
55
+ <Markdown remarkPlugins={[remarkGfm]}>{comment.body}</Markdown>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,309 @@
1
+ import { useEffect, useState } from "react";
2
+ import { trpc } from "../trpc.ts";
3
+ import { formatCommitTime } from "@/web/lib/time.ts";
4
+ import { fileSectionId } from "./FileTree.tsx";
5
+ import { PostMenu } from "./PostMenu.tsx";
6
+ import { CopyButton } from "./CopyButton.tsx";
7
+ import { ConfirmButton } from "./ConfirmButton.tsx";
8
+ import { CommentIcon, ExternalIcon, SyncIcon } from "./Icons.tsx";
9
+ import { filterAllComments, type AuthorFilter, type SourceFilter } from "@/web/lib/commentFilters.ts";
10
+ import { classifyCommentClick, commentHunkHash, commentDomIdCandidates } from "@/web/lib/commentVisibility.ts";
11
+ import type { AllCommentEntry, CommentOrigin } from "@/daemon/allComments.ts";
12
+ import type { FileView } from "@/daemon/reviewService.ts";
13
+ import type { PostTarget } from "@/daemon/registry.ts";
14
+
15
+ /** A truncated single-line preview of a comment body. */
16
+ function preview(body: string): string {
17
+ const flat: string = body.replace(/\s+/g, " ").trim();
18
+ return flat.length > 120 ? `${flat.slice(0, 119)}…` : flat;
19
+ }
20
+
21
+ /** Short label for an origin badge. */
22
+ const ORIGIN_LABEL: Record<CommentOrigin, string> = {
23
+ local: "local draft",
24
+ posted: "posted to GitHub",
25
+ github: "from GitHub",
26
+ };
27
+
28
+ /**
29
+ * Scroll to the first rendered element matching one of the candidate ids and
30
+ * flash a transient highlight on it. Returns whether a target was found.
31
+ */
32
+ function scrollToComment(candidateIds: string[]): boolean {
33
+ for (const id of candidateIds) {
34
+ const el = document.getElementById(id);
35
+ if (!el) continue;
36
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
37
+ el.classList.remove("comment-flash");
38
+ // Force reflow so re-adding the class restarts the animation.
39
+ void el.offsetWidth;
40
+ el.classList.add("comment-flash");
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /**
47
+ * Scroll to a just-revealed comment once the diff has re-rendered it. The
48
+ * reveal is a React state change, so the element isn't in the DOM on this tick;
49
+ * retry over a few animation frames until it appears (or we give up).
50
+ */
51
+ function scrollWhenReady(candidateIds: string[], attempts = 8): void {
52
+ if (scrollToComment(candidateIds) || attempts <= 0) return;
53
+ requestAnimationFrame(() => scrollWhenReady(candidateIds, attempts - 1));
54
+ }
55
+
56
+ /**
57
+ * The "All comments" surface hosted in the right-rail sidebar. Lists every
58
+ * comment on the PR (local drafts, mergie-posted, and fetched GitHub — mine and
59
+ * others'), deduped, with author/source/file filters, counts, and the same
60
+ * copy / post / edit / delete affordances as the diff. Clicking a comment that
61
+ * is rendered in the current range scrolls the diff to it (without changing the
62
+ * range); if it is in the current range but hidden by a view toggle it is first
63
+ * revealed, then scrolled to; clicking one truly outside the range opens a
64
+ * confirmation offering to view its own range in a new tab. The rail supplies
65
+ * the panel chrome (title + close); this component renders the body.
66
+ */
67
+ export function CommentsPanel(props: {
68
+ /** PR id. */
69
+ prId: string;
70
+ /** All files/hunks in the selected range (before view toggles). */
71
+ rangeFiles: FileView[];
72
+ /** The subset currently rendered on screen (after view toggles). */
73
+ renderedFiles: FileView[];
74
+ /** Baseline SHA for the "view in context" new-tab link (whole-PR start). */
75
+ baselineSha: string;
76
+ /** Force a hunk visible so a jumped-to comment can be scrolled into view. */
77
+ onReveal: (hunkHash: string) => void;
78
+ }): React.JSX.Element {
79
+ const { prId, rangeFiles, renderedFiles, baselineSha, onReveal } = props;
80
+ const comments = trpc.listAllComments.useQuery({ id: prId });
81
+ const utils = trpc.useUtils();
82
+ const invalidate = (): void => {
83
+ void utils.listAllComments.invalidate();
84
+ void utils.listComments.invalidate();
85
+ void utils.rangeView.invalidate();
86
+ };
87
+ const del = trpc.deleteComment.useMutation({ onSuccess: invalidate });
88
+ const post = trpc.postComment.useMutation({ onSuccess: invalidate });
89
+ const sync = trpc.syncGithub.useMutation({ onSuccess: invalidate });
90
+ const editLocal = trpc.editComment.useMutation({ onSuccess: invalidate });
91
+ const editGh = trpc.editGithubComment.useMutation({ onSuccess: invalidate });
92
+ const delGh = trpc.deleteGithubComment.useMutation({ onSuccess: invalidate });
93
+ const previewPost = (commentId: number, target: PostTarget) =>
94
+ utils.postCommentPreview.fetch({ id: prId, commentId, target });
95
+
96
+ const [fileFilter, setFileFilter] = useState("");
97
+ const [author, setAuthor] = useState<AuthorFilter>("all");
98
+ const [source, setSource] = useState<SourceFilter>("all");
99
+ const [editingKey, setEditingKey] = useState<string | null>(null);
100
+ const [editDraft, setEditDraft] = useState("");
101
+ const [outOfRange, setOutOfRange] = useState<AllCommentEntry | null>(null);
102
+
103
+ // Esc dismisses the out-of-range confirmation before the rail collapses the
104
+ // sidebar. Capture-phase + stopPropagation so the rail's own Esc handler does
105
+ // not also fire on the same keypress while the prompt is open.
106
+ useEffect(() => {
107
+ if (!outOfRange) return;
108
+ const onKey = (e: KeyboardEvent): void => {
109
+ if (e.key !== "Escape") return;
110
+ e.stopPropagation();
111
+ setOutOfRange(null);
112
+ };
113
+ window.addEventListener("keydown", onKey, true);
114
+ return () => window.removeEventListener("keydown", onKey, true);
115
+ }, [outOfRange]);
116
+
117
+ const removeEntry = (c: AllCommentEntry): void => {
118
+ if (c.localId !== null) del.mutate({ id: prId, commentId: c.localId });
119
+ else if (c.githubId !== null) delGh.mutate({ id: prId, githubId: c.githubId });
120
+ };
121
+ const saveEdit = (c: AllCommentEntry): void => {
122
+ const body = editDraft.trim();
123
+ if (body.length > 0) {
124
+ if (c.localId !== null) editLocal.mutate({ id: prId, commentId: c.localId, body });
125
+ else if (c.githubId !== null) editGh.mutate({ id: prId, githubId: c.githubId, body });
126
+ }
127
+ setEditingKey(null);
128
+ };
129
+
130
+ const all: AllCommentEntry[] = comments.data ?? [];
131
+ const fileNames: string[] = [...new Set(all.map((c) => c.path).filter((p): p is string => p !== null))].sort();
132
+ const shown: AllCommentEntry[] = filterAllComments(all, { author, source, file: fileFilter });
133
+
134
+ const contextHref = (c: AllCommentEntry): string =>
135
+ `/?pr=${prId}&start=${baselineSha}&end=${c.madeAtSha}#${c.path ? fileSectionId(c.path) : ""}`;
136
+
137
+ // Handle a click on a comment row:
138
+ // - shown now → scroll to it;
139
+ // - in the range but not on screen (hidden by a toggle, or in an auto-collapsed
140
+ // viewed hunk) → reveal + expand its hunk, then scroll once the diff has
141
+ // re-rendered (retry across a few frames);
142
+ // - truly out of range → confirm before leaving the range.
143
+ const openComment = (c: AllCommentEntry): void => {
144
+ const action = classifyCommentClick(c, rangeFiles, renderedFiles);
145
+ if (action.kind === "out-of-range") { setOutOfRange(c); return; }
146
+ // "scroll" may still fail if the hunk is rendered but collapsed; fall back
147
+ // to the reveal path (which also force-expands the hunk).
148
+ if (action.kind === "scroll" && scrollToComment(commentDomIdCandidates(c))) return;
149
+ const hunkHash: string | null = commentHunkHash(c, rangeFiles);
150
+ if (hunkHash === null) { setOutOfRange(c); return; }
151
+ onReveal(hunkHash);
152
+ scrollWhenReady(commentDomIdCandidates(c));
153
+ };
154
+
155
+ return (
156
+ <div className="comments-panel-content">
157
+ <div className="comments-panel-count reviewed-count">
158
+ {shown.length === all.length
159
+ ? `${all.length} comment${all.length === 1 ? "" : "s"}`
160
+ : `${shown.length} of ${all.length} comments`}
161
+ </div>
162
+ <div className="comments-panel-filters">
163
+ <div className="segmented" role="group" aria-label="Filter by author">
164
+ {AUTHOR_OPTIONS.map((opt) => (
165
+ <button
166
+ key={opt.value}
167
+ type="button"
168
+ className={author === opt.value ? "segmented-item active" : "segmented-item"}
169
+ aria-pressed={author === opt.value}
170
+ onClick={() => setAuthor(opt.value)}
171
+ >
172
+ {opt.label}
173
+ </button>
174
+ ))}
175
+ </div>
176
+ <label>Source{" "}
177
+ <select value={source} onChange={(e) => setSource(asSource(e.target.value))}>
178
+ <option value="all">All sources</option>
179
+ <option value="local">Local drafts (never posted)</option>
180
+ <option value="github">On GitHub</option>
181
+ </select>
182
+ </label>
183
+ <label>File{" "}
184
+ <select value={fileFilter} onChange={(e) => setFileFilter(e.target.value)}>
185
+ <option value="">All files</option>
186
+ {fileNames.map((f) => <option key={f} value={f}>{f}</option>)}
187
+ </select>
188
+ </label>
189
+ <button type="button" className="btn btn-sm" disabled={sync.isPending} onClick={() => sync.mutate({ id: prId })}>
190
+ <SyncIcon size={13} /> {sync.isPending ? "Fetching…" : "Fetch GitHub comments"}
191
+ </button>
192
+ </div>
193
+ <ul className="comment-list">
194
+ {shown.map((c) => (
195
+ <li key={c.key} className="comment-list-item">
196
+ <button type="button" className="comment-jump" onClick={() => openComment(c)} title="Go to this comment in the diff">
197
+ <div className="comment-list-meta">
198
+ <code>{c.path ?? "(no file)"}</code>
199
+ <span>{c.location}</span>
200
+ {c.side && <span>{c.side}</span>}
201
+ <span className="comment-author">{c.mine ? "You" : c.author}</span>
202
+ <span className={`badge origin-${c.origin}`}>{ORIGIN_LABEL[c.origin]}</span>
203
+ {c.replyCount > 0 && <span className="reply-count">{c.replyCount} repl{c.replyCount === 1 ? "y" : "ies"}</span>}
204
+ {c.createdAt !== null && <span className="comment-time">{formatCommitTime(new Date(c.createdAt).toISOString())}</span>}
205
+ </div>
206
+ </button>
207
+ {editingKey === c.key ? (
208
+ <div className="comment-edit">
209
+ <textarea className="comment-textarea" value={editDraft} onChange={(e) => setEditDraft(e.target.value)} />
210
+ <div className="comment-edit-actions">
211
+ <button type="button" className="btn btn-primary btn-sm" onClick={() => saveEdit(c)} disabled={editDraft.trim().length === 0}>Save</button>
212
+ <button type="button" className="btn btn-sm" onClick={() => setEditingKey(null)}>Cancel</button>
213
+ {c.origin !== "local" && <span className="notice">Edits the comment on GitHub.</span>}
214
+ </div>
215
+ </div>
216
+ ) : (
217
+ <div className="comment-list-body">{preview(c.body)}</div>
218
+ )}
219
+ <div className="comment-list-actions">
220
+ {c.origin === "local" && c.localId !== null && (
221
+ <PostMenu
222
+ preview={(target) => previewPost(c.localId ?? -1, target)}
223
+ onPost={(target) => post.mutate({ id: prId, commentId: c.localId ?? -1, target })}
224
+ />
225
+ )}
226
+ <CopyButton text={c.body} />
227
+ {c.mine && editingKey !== c.key && (
228
+ <button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditingKey(c.key); setEditDraft(c.body); }}>Edit</button>
229
+ )}
230
+ {c.mine && (
231
+ <ConfirmButton
232
+ warning={c.origin === "local" ? undefined : "Also deletes it on GitHub."}
233
+ onConfirm={() => removeEntry(c)}
234
+ />
235
+ )}
236
+ </div>
237
+ </li>
238
+ ))}
239
+ {shown.length === 0 && (
240
+ <li>
241
+ {comments.isLoading ? (
242
+ <p className="notice">Loading comments…</p>
243
+ ) : (
244
+ <div className="empty-state">
245
+ <CommentIcon size={32} />
246
+ <p className="empty-state-title">{all.length === 0 ? "No comments yet" : "No matches"}</p>
247
+ <p className="empty-state-hint">
248
+ {all.length === 0
249
+ ? "Add one in the diff, or use “Fetch GitHub comments” to pull existing ones."
250
+ : "No comments match these filters."}
251
+ </p>
252
+ </div>
253
+ )}
254
+ </li>
255
+ )}
256
+ </ul>
257
+ {outOfRange && (
258
+ <OutOfRangePrompt
259
+ entry={outOfRange}
260
+ href={outOfRange.madeAtSha ? contextHref(outOfRange) : outOfRange.githubUrl}
261
+ onDismiss={() => setOutOfRange(null)}
262
+ />
263
+ )}
264
+ </div>
265
+ );
266
+ }
267
+
268
+ /**
269
+ * The confirmation shown when a clicked comment is not in the current range.
270
+ * Explains the situation and offers to open the comment's own range in a new
271
+ * tab (or GitHub, for comments with no local anchor) without touching the
272
+ * current review's range.
273
+ */
274
+ function OutOfRangePrompt(props: {
275
+ entry: AllCommentEntry;
276
+ href: string | null;
277
+ onDismiss: () => void;
278
+ }): React.JSX.Element {
279
+ const { entry, href, onDismiss } = props;
280
+ return (
281
+ <div className="oor-prompt" role="dialog" aria-label="Comment outside current range">
282
+ <p className="oor-text">
283
+ This comment on <code>{entry.path ?? "another location"}</code> belongs to a different commit
284
+ range than the one you’re viewing. Showing it here would change your current range, so open
285
+ it separately instead.
286
+ </p>
287
+ <div className="oor-actions">
288
+ {href && (
289
+ <a className="oor-open" href={href} target="_blank" rel="noreferrer" onClick={onDismiss}>
290
+ Open its diff in a new tab <ExternalIcon size={12} />
291
+ </a>
292
+ )}
293
+ <button type="button" className="btn btn-sm" onClick={onDismiss}>Cancel</button>
294
+ </div>
295
+ </div>
296
+ );
297
+ }
298
+
299
+ /** Author-filter choices rendered as a segmented control. */
300
+ const AUTHOR_OPTIONS: ReadonlyArray<{ value: AuthorFilter; label: string }> = [
301
+ { value: "all", label: "Everyone" },
302
+ { value: "mine", label: "Me" },
303
+ { value: "others", label: "Others" },
304
+ ] as const;
305
+
306
+ /** Narrow a string to a SourceFilter. */
307
+ function asSource(v: string): SourceFilter {
308
+ return v === "local" || v === "github" ? v : "all";
309
+ }