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.
- package/LICENSE +674 -0
- package/README.md +91 -0
- package/bin/mergie-dev +18 -0
- package/bin/mergie.ts +44 -0
- package/dist/web/assets/index-4bq8mkyV.css +1 -0
- package/dist/web/assets/index-Cp_Gdwf2.js +50 -0
- package/dist/web/favicon.svg +12 -0
- package/dist/web/index.html +14 -0
- package/package.json +68 -0
- package/src/cli/args.ts +63 -0
- package/src/cli/constants.ts +32 -0
- package/src/cli/daemonClient.ts +42 -0
- package/src/cli/openBrowser.ts +11 -0
- package/src/cli/run.ts +76 -0
- package/src/daemon/aiReviewTracker.ts +71 -0
- package/src/daemon/allComments.ts +119 -0
- package/src/daemon/artifactCapture.ts +47 -0
- package/src/daemon/buildUi.ts +54 -0
- package/src/daemon/chatPrompt.ts +35 -0
- package/src/daemon/chatSocket.ts +78 -0
- package/src/daemon/createRegistry.ts +569 -0
- package/src/daemon/githubThreads.ts +92 -0
- package/src/daemon/inflight.ts +49 -0
- package/src/daemon/postMapping.ts +94 -0
- package/src/daemon/registry.ts +263 -0
- package/src/daemon/reviewPrompt.ts +22 -0
- package/src/daemon/reviewService.ts +243 -0
- package/src/daemon/router.ts +287 -0
- package/src/daemon/server.ts +106 -0
- package/src/daemon/splitView.ts +63 -0
- package/src/daemon/trpc.ts +20 -0
- package/src/db/migrate.ts +24 -0
- package/src/db/repositories/aiReviews.ts +113 -0
- package/src/db/repositories/artifacts.ts +101 -0
- package/src/db/repositories/chatSessions.ts +197 -0
- package/src/db/repositories/comments.ts +195 -0
- package/src/db/repositories/githubComments.ts +166 -0
- package/src/db/repositories/hunkViews.ts +36 -0
- package/src/db/repositories/reviewedRanges.ts +75 -0
- package/src/db/schema.ts +97 -0
- package/src/domain/config.ts +137 -0
- package/src/domain/context.ts +32 -0
- package/src/domain/diff.ts +178 -0
- package/src/domain/entities.ts +92 -0
- package/src/domain/fuzzy.ts +43 -0
- package/src/domain/generated.ts +33 -0
- package/src/domain/hash.ts +0 -0
- package/src/domain/lockfiles.ts +20 -0
- package/src/domain/paths.ts +59 -0
- package/src/domain/ranges.ts +81 -0
- package/src/domain/references.ts +36 -0
- package/src/domain/url.ts +50 -0
- package/src/domain/wordDiff.ts +174 -0
- package/src/services/ai.ts +137 -0
- package/src/services/exec.ts +44 -0
- package/src/services/ghPr.ts +102 -0
- package/src/services/ghSearch.ts +135 -0
- package/src/services/git.ts +297 -0
- package/src/services/github.ts +300 -0
- package/src/services/symbols.ts +364 -0
- package/src/web/App.tsx +32 -0
- package/src/web/components/AiReviewIndicator.tsx +63 -0
- package/src/web/components/AiReviewModal.tsx +101 -0
- package/src/web/components/AiReviewsPanel.tsx +64 -0
- package/src/web/components/ChatPanel.tsx +142 -0
- package/src/web/components/CodePreview.tsx +63 -0
- package/src/web/components/CommentComposer.tsx +40 -0
- package/src/web/components/CommentItem.tsx +59 -0
- package/src/web/components/CommentsPanel.tsx +309 -0
- package/src/web/components/CommitRail.tsx +64 -0
- package/src/web/components/ConfirmButton.tsx +32 -0
- package/src/web/components/CopyButton.tsx +33 -0
- package/src/web/components/CopyIconButton.tsx +38 -0
- package/src/web/components/DiffFrame.tsx +133 -0
- package/src/web/components/DiffLines.tsx +149 -0
- package/src/web/components/FileFrame.tsx +45 -0
- package/src/web/components/FileNavigator.tsx +195 -0
- package/src/web/components/FileTree.tsx +45 -0
- package/src/web/components/FileView.tsx +53 -0
- package/src/web/components/GithubThreadView.tsx +56 -0
- package/src/web/components/HunkCard.tsx +121 -0
- package/src/web/components/Icons.tsx +215 -0
- package/src/web/components/IdentifierMenuPortal.tsx +33 -0
- package/src/web/components/PostMenu.tsx +63 -0
- package/src/web/components/PrDescription.tsx +29 -0
- package/src/web/components/PrLoading.tsx +45 -0
- package/src/web/components/PrPicker.tsx +201 -0
- package/src/web/components/RangeSelector.tsx +141 -0
- package/src/web/components/ResultsList.tsx +159 -0
- package/src/web/components/ReviewView.tsx +308 -0
- package/src/web/components/RightRail.tsx +146 -0
- package/src/web/components/SearchRailPanel.tsx +127 -0
- package/src/web/components/Switch.tsx +31 -0
- package/src/web/components/SwitchPrModal.tsx +28 -0
- package/src/web/components/SymbolLookupMenu.tsx +35 -0
- package/src/web/components/Toolbar.tsx +79 -0
- package/src/web/components/Tooltip.tsx +72 -0
- package/src/web/index.html +13 -0
- package/src/web/lib/aiReviewIndicator.ts +29 -0
- package/src/web/lib/anchorRow.ts +25 -0
- package/src/web/lib/codeSearchFetch.ts +46 -0
- package/src/web/lib/commentFilters.ts +36 -0
- package/src/web/lib/commentVisibility.ts +90 -0
- package/src/web/lib/composerKeys.ts +24 -0
- package/src/web/lib/dedupeResults.ts +37 -0
- package/src/web/lib/diffMarks.ts +81 -0
- package/src/web/lib/fileStatus.ts +12 -0
- package/src/web/lib/filterCodeResults.ts +42 -0
- package/src/web/lib/filterPrs.ts +38 -0
- package/src/web/lib/highlight.ts +40 -0
- package/src/web/lib/identifierMenu.ts +41 -0
- package/src/web/lib/lineSelection.ts +48 -0
- package/src/web/lib/navRouting.ts +40 -0
- package/src/web/lib/navStack.ts +109 -0
- package/src/web/lib/persistedFlag.ts +43 -0
- package/src/web/lib/prPickerModel.ts +27 -0
- package/src/web/lib/railState.ts +19 -0
- package/src/web/lib/rangeCoverage.ts +27 -0
- package/src/web/lib/rangeMap.ts +42 -0
- package/src/web/lib/resultCountLabel.ts +11 -0
- package/src/web/lib/reviewProgress.ts +26 -0
- package/src/web/lib/searchInputsKey.ts +35 -0
- package/src/web/lib/searchToken.ts +25 -0
- package/src/web/lib/splitSide.ts +13 -0
- package/src/web/lib/time.ts +9 -0
- package/src/web/lib/togglePrefs.ts +81 -0
- package/src/web/lib/useEscToClose.ts +17 -0
- package/src/web/lib/useIdentifierMenu.ts +77 -0
- package/src/web/lib/useNavLookup.ts +74 -0
- package/src/web/lib/usePageTitle.ts +15 -0
- package/src/web/lib/visibleFiles.ts +52 -0
- package/src/web/main.tsx +24 -0
- package/src/web/public/favicon.svg +12 -0
- package/src/web/state/useChat.ts +181 -0
- package/src/web/state/useCodeSearch.ts +198 -0
- package/src/web/state/useReview.ts +138 -0
- package/src/web/styles.css +1020 -0
- package/src/web/trpc.ts +5 -0
- package/tsconfig.json +27 -0
- 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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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
|
+
}
|