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,64 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { formatCommitTime } from "@/web/lib/time.ts";
|
|
3
|
+
import type { CommitInfo } from "@/services/git.ts";
|
|
4
|
+
|
|
5
|
+
/** Truncate a commit subject for display. */
|
|
6
|
+
function truncate(text: string, max = 60): string {
|
|
7
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Class name for a commit row given its position in the selected range. */
|
|
11
|
+
function rowClass(i: number, fromIndex: number, toIndex: number): string {
|
|
12
|
+
const parts: string[] = ["rail-row"];
|
|
13
|
+
if (i >= fromIndex && i <= toIndex) parts.push("in-range");
|
|
14
|
+
if (i === fromIndex) parts.push("range-start");
|
|
15
|
+
if (i === toIndex) parts.push("range-end");
|
|
16
|
+
return parts.join(" ");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A vertical list of the PR's commits (oldest → newest) with timestamps. The
|
|
21
|
+
* currently-selected inclusive range is shown as a highlighted band. Click a
|
|
22
|
+
* commit to start a selection, then click another to complete the range.
|
|
23
|
+
*/
|
|
24
|
+
export function CommitRail(props: {
|
|
25
|
+
commits: CommitInfo[];
|
|
26
|
+
fromIndex: number;
|
|
27
|
+
toIndex: number;
|
|
28
|
+
onSelect: (fromIndex: number, toIndex: number) => void;
|
|
29
|
+
}): React.JSX.Element {
|
|
30
|
+
const { commits, fromIndex, toIndex, onSelect } = props;
|
|
31
|
+
const [anchor, setAnchor] = useState<number | null>(null);
|
|
32
|
+
|
|
33
|
+
function clickRow(i: number): void {
|
|
34
|
+
if (anchor === null) {
|
|
35
|
+
setAnchor(i);
|
|
36
|
+
onSelect(i, i);
|
|
37
|
+
} else {
|
|
38
|
+
onSelect(Math.min(anchor, i), Math.max(anchor, i));
|
|
39
|
+
setAnchor(null);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="commit-rail">
|
|
45
|
+
<p className="rail-hint">
|
|
46
|
+
{anchor === null
|
|
47
|
+
? "Click a commit to start, then click another to set the range."
|
|
48
|
+
: "Now click the other end of the range."}
|
|
49
|
+
</p>
|
|
50
|
+
<div className="rail-base">◦ base — before the PR</div>
|
|
51
|
+
<ol className="rail-list">
|
|
52
|
+
{commits.map((c, i) => (
|
|
53
|
+
<li key={c.sha}>
|
|
54
|
+
<button type="button" className={rowClass(i, fromIndex, toIndex)} onClick={() => clickRow(i)}>
|
|
55
|
+
<code className="rail-sha">{c.shortSha}</code>
|
|
56
|
+
<span className="rail-subject">{truncate(c.subject)}</span>
|
|
57
|
+
<span className="rail-time">{formatCommitTime(c.isoDate)}</span>
|
|
58
|
+
</button>
|
|
59
|
+
</li>
|
|
60
|
+
))}
|
|
61
|
+
</ol>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A destructive-action button that requires a second click to confirm. The
|
|
5
|
+
* first click "arms" it, swapping in a confirm/cancel pair (with an optional
|
|
6
|
+
* warning line); confirming fires `onConfirm`, cancelling reverts.
|
|
7
|
+
*/
|
|
8
|
+
export function ConfirmButton(props: {
|
|
9
|
+
/** Label for the initial (unarmed) button. */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Label for the armed confirm button. */
|
|
12
|
+
confirmLabel?: string;
|
|
13
|
+
/** Extra warning shown once armed (e.g. "also deletes on GitHub"). */
|
|
14
|
+
warning?: string;
|
|
15
|
+
/** Called when the user confirms. */
|
|
16
|
+
onConfirm: () => void;
|
|
17
|
+
}): React.JSX.Element {
|
|
18
|
+
const [armed, setArmed] = useState(false);
|
|
19
|
+
|
|
20
|
+
if (!armed) {
|
|
21
|
+
return <button type="button" className="btn btn-ghost btn-sm" onClick={() => setArmed(true)}>{props.label ?? "Delete"}</button>;
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<span className="confirm-inline">
|
|
25
|
+
{props.warning && <span className="confirm-warn">{props.warning}</span>}
|
|
26
|
+
<button type="button" className="btn btn-danger btn-sm" onClick={() => { setArmed(false); props.onConfirm(); }}>
|
|
27
|
+
{props.confirmLabel ?? "Confirm delete"}
|
|
28
|
+
</button>
|
|
29
|
+
<button type="button" className="btn btn-sm" onClick={() => setArmed(false)}>Cancel</button>
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A copy-to-clipboard button that briefly confirms the copy by swapping its
|
|
5
|
+
* label to "Copied!" for ~1.2s, then reverting — so the user gets feedback that
|
|
6
|
+
* an otherwise-silent clipboard write actually happened.
|
|
7
|
+
*/
|
|
8
|
+
export function CopyButton(props: {
|
|
9
|
+
/** Text to place on the clipboard. */
|
|
10
|
+
text: string;
|
|
11
|
+
/** Optional class name for styling parity with sibling buttons. */
|
|
12
|
+
className?: string;
|
|
13
|
+
}): React.JSX.Element {
|
|
14
|
+
const [copied, setCopied] = useState(false);
|
|
15
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
|
18
|
+
|
|
19
|
+
const onClick = (): void => {
|
|
20
|
+
// Guard: navigator.clipboard is undefined outside secure contexts; the
|
|
21
|
+
// confirmation should still show rather than the click throwing silently.
|
|
22
|
+
if (navigator.clipboard) void navigator.clipboard.writeText(props.text);
|
|
23
|
+
setCopied(true);
|
|
24
|
+
if (timer.current) clearTimeout(timer.current);
|
|
25
|
+
timer.current = setTimeout(() => setCopied(false), 1200);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<button type="button" className={props.className ?? "btn btn-ghost btn-sm"} onClick={onClick}>
|
|
30
|
+
{copied ? "Copied!" : "Copy"}
|
|
31
|
+
</button>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { CopyIcon, CheckIcon } from "./Icons.tsx";
|
|
3
|
+
import { Tooltip } from "./Tooltip.tsx";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An icon-only copy-to-clipboard button: shows a copy glyph, briefly swaps to a
|
|
7
|
+
* check on click to confirm the (otherwise silent) clipboard write, and exposes
|
|
8
|
+
* its purpose via a hover tooltip + `aria-label`. Matches the app's small
|
|
9
|
+
* ghost-icon button style.
|
|
10
|
+
*
|
|
11
|
+
* @param props.text - Text to place on the clipboard.
|
|
12
|
+
* @param props.label - Tooltip + accessible label (e.g. "Copy PR URL").
|
|
13
|
+
* @param props.size - Icon pixel size (default 13).
|
|
14
|
+
*/
|
|
15
|
+
export function CopyIconButton(props: { text: string; label: string; size?: number }): React.JSX.Element {
|
|
16
|
+
const { text, label, size = 13 } = props;
|
|
17
|
+
const [copied, setCopied] = useState(false);
|
|
18
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
|
21
|
+
|
|
22
|
+
const onClick = (): void => {
|
|
23
|
+
// navigator.clipboard is undefined outside secure contexts; still confirm
|
|
24
|
+
// rather than let the click throw silently.
|
|
25
|
+
if (navigator.clipboard) void navigator.clipboard.writeText(text);
|
|
26
|
+
setCopied(true);
|
|
27
|
+
if (timer.current) clearTimeout(timer.current);
|
|
28
|
+
timer.current = setTimeout(() => setCopied(false), 1200);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Tooltip label={copied ? "Copied!" : label} placement="bottom">
|
|
33
|
+
<button type="button" className="icon-copy-btn" aria-label={label} onClick={onClick}>
|
|
34
|
+
{copied ? <CheckIcon size={size} /> : <CopyIcon size={size} />}
|
|
35
|
+
</button>
|
|
36
|
+
</Tooltip>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { trpc } from "../trpc.ts";
|
|
3
|
+
import { firstChangedIndexFrom } from "@/web/lib/anchorRow.ts";
|
|
4
|
+
import { highlightToHtml, languageForPath } from "@/web/lib/highlight.ts";
|
|
5
|
+
import { applyDiffMarks } from "@/web/lib/diffMarks.ts";
|
|
6
|
+
import { splitSideIsEmpty } from "@/web/lib/splitSide.ts";
|
|
7
|
+
import { FileIcon } from "./Icons.tsx";
|
|
8
|
+
import type { SplitCell, SplitRow } from "@/daemon/splitView.ts";
|
|
9
|
+
|
|
10
|
+
/** Render one side's code cell (blank for padding). The `side` (left=base,
|
|
11
|
+
* right=head) is stamped as `data-side` so a symbol selected here defaults its
|
|
12
|
+
* lookup to that checkout. */
|
|
13
|
+
function codeCell(cell: SplitCell, language: string | undefined, side: "base" | "head"): React.JSX.Element {
|
|
14
|
+
const html: string = cell.kind === "empty" ? "" : applyDiffMarks(highlightToHtml(cell.text, language), cell.changes ?? []);
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<td className="split-no">{cell.no ?? ""}</td>
|
|
18
|
+
<td className={`split-code split-${cell.kind}`} data-side={side} dangerouslySetInnerHTML={{ __html: html }} />
|
|
19
|
+
</>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Row index whose left/right line number equals `anchorLine`, or -1. */
|
|
24
|
+
function anchorMatchIndex(rows: SplitRow[], anchorLine: number | null): number {
|
|
25
|
+
if (anchorLine === null) return -1;
|
|
26
|
+
return rows.findIndex((r) => r.right.no === anchorLine || r.left.no === anchorLine);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The split base/head body of a file over a range: the synced-scroll table
|
|
31
|
+
* (single table so both columns scroll together), the anchor-line nudge onto
|
|
32
|
+
* the first real +/- line, the one-shot open flash, and the single-column
|
|
33
|
+
* placeholder when one side is empty (added/deleted file). Presentational:
|
|
34
|
+
* owns only the `fileSplit` query + the anchor/flash effect. Reused by the
|
|
35
|
+
* full-file modal and the file navigator.
|
|
36
|
+
*
|
|
37
|
+
* @param props.prId - PR id the file belongs to.
|
|
38
|
+
* @param props.path - Repo-relative file path.
|
|
39
|
+
* @param props.start - Base SHA of the range.
|
|
40
|
+
* @param props.end - Head SHA of the range.
|
|
41
|
+
* @param props.anchorLine - 1-based line to center + flash, or null.
|
|
42
|
+
* @param props.hideWhitespace - When true, collapse whitespace-only changes
|
|
43
|
+
* (keeps the split view consistent with the main diff's toggle).
|
|
44
|
+
*/
|
|
45
|
+
export function DiffFrame(props: {
|
|
46
|
+
prId: string;
|
|
47
|
+
path: string;
|
|
48
|
+
start: string;
|
|
49
|
+
end: string;
|
|
50
|
+
anchorLine: number | null;
|
|
51
|
+
hideWhitespace?: boolean;
|
|
52
|
+
}): React.JSX.Element {
|
|
53
|
+
const { prId, path, start, end, anchorLine, hideWhitespace } = props;
|
|
54
|
+
const language: string | undefined = languageForPath(path);
|
|
55
|
+
const split = trpc.fileSplit.useQuery({ id: prId, path, start, end, ignoreWhitespace: hideWhitespace ?? false });
|
|
56
|
+
const rows: SplitRow[] = split.data ?? [];
|
|
57
|
+
const anchorRef = useRef<HTMLTableRowElement>(null);
|
|
58
|
+
// Nudge the anchor off the context line it was opened at (e.g. a `}`) onto the
|
|
59
|
+
// first real +/- line at or below it.
|
|
60
|
+
const anchorIdx: number = firstChangedIndexFrom(rows, anchorMatchIndex(rows, anchorLine));
|
|
61
|
+
const baseEmpty: boolean = splitSideIsEmpty(rows, "left");
|
|
62
|
+
const headEmpty: boolean = splitSideIsEmpty(rows, "right");
|
|
63
|
+
// A one-shot flash: highlight the anchor on open, then fade out (see the
|
|
64
|
+
// `.split-anchor` animation). Reset to false first so the animation re-runs
|
|
65
|
+
// whenever the anchor changes (reopening View file / a different hunk).
|
|
66
|
+
const [flashing, setFlashing] = useState(false);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (anchorRef.current) anchorRef.current.scrollIntoView({ block: "center" });
|
|
70
|
+
if (anchorIdx < 0) return;
|
|
71
|
+
setFlashing(false);
|
|
72
|
+
const id = requestAnimationFrame(() => setFlashing(true));
|
|
73
|
+
return () => cancelAnimationFrame(id);
|
|
74
|
+
}, [rows.length, anchorIdx]);
|
|
75
|
+
|
|
76
|
+
const anchorClass = (i: number): string | undefined => (i === anchorIdx && flashing ? "split-anchor" : undefined);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="full-file-body">
|
|
80
|
+
{split.isLoading && <p className="notice">Loading file…</p>}
|
|
81
|
+
{baseEmpty && !split.isLoading && (
|
|
82
|
+
<FileBody rows={rows} side="right" language={language} anchorIdx={anchorIdx} anchorRef={anchorRef} anchorClass={anchorClass}
|
|
83
|
+
placeholder="This file was added — there is no base version to compare against." />
|
|
84
|
+
)}
|
|
85
|
+
{headEmpty && !split.isLoading && (
|
|
86
|
+
<FileBody rows={rows} side="left" language={language} anchorIdx={anchorIdx} anchorRef={anchorRef} anchorClass={anchorClass}
|
|
87
|
+
placeholder="This file was deleted — there is no head version to compare against." />
|
|
88
|
+
)}
|
|
89
|
+
{!baseEmpty && !headEmpty && (
|
|
90
|
+
<table className="split-table">
|
|
91
|
+
<tbody>
|
|
92
|
+
{rows.map((r, i) => (
|
|
93
|
+
<tr key={i} ref={i === anchorIdx ? anchorRef : undefined} className={anchorClass(i)}>
|
|
94
|
+
{codeCell(r.left, language, "base")}
|
|
95
|
+
{codeCell(r.right, language, "head")}
|
|
96
|
+
</tr>
|
|
97
|
+
))}
|
|
98
|
+
</tbody>
|
|
99
|
+
</table>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A single-column body used when the other side is empty: renders only the
|
|
107
|
+
* side that has content, with a placeholder banner explaining the empty side.
|
|
108
|
+
*/
|
|
109
|
+
function FileBody(props: {
|
|
110
|
+
rows: SplitRow[];
|
|
111
|
+
side: "left" | "right";
|
|
112
|
+
language: string | undefined;
|
|
113
|
+
anchorIdx: number;
|
|
114
|
+
anchorRef: React.RefObject<HTMLTableRowElement | null>;
|
|
115
|
+
anchorClass: (i: number) => string | undefined;
|
|
116
|
+
placeholder: string;
|
|
117
|
+
}): React.JSX.Element {
|
|
118
|
+
const { rows, side, language, anchorIdx, anchorRef, anchorClass, placeholder } = props;
|
|
119
|
+
return (
|
|
120
|
+
<>
|
|
121
|
+
<div className="modal-empty-side"><FileIcon size={28} />{placeholder}</div>
|
|
122
|
+
<table className="split-table">
|
|
123
|
+
<tbody>
|
|
124
|
+
{rows.map((r, i) => (
|
|
125
|
+
<tr key={i} ref={i === anchorIdx ? anchorRef : undefined} className={anchorClass(i)}>
|
|
126
|
+
{codeCell(r[side], language, side === "left" ? "base" : "head")}
|
|
127
|
+
</tr>
|
|
128
|
+
))}
|
|
129
|
+
</tbody>
|
|
130
|
+
</table>
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Fragment, useEffect, useState } from "react";
|
|
2
|
+
import { highlightToHtml, languageForPath } from "@/web/lib/highlight.ts";
|
|
3
|
+
import { applyDiffMarks } from "@/web/lib/diffMarks.ts";
|
|
4
|
+
import { buildLinesAnchor } from "@/web/lib/lineSelection.ts";
|
|
5
|
+
import { sideForLineKind } from "@/web/lib/identifierMenu.ts";
|
|
6
|
+
import { CommentComposer } from "./CommentComposer.tsx";
|
|
7
|
+
import { CommentItem } from "./CommentItem.tsx";
|
|
8
|
+
import { GithubThreadView } from "./GithubThreadView.tsx";
|
|
9
|
+
import type { DiffLine } from "@/domain/diff.ts";
|
|
10
|
+
import type { AnchoredComment, AnchoredGithubThread } from "@/daemon/reviewService.ts";
|
|
11
|
+
import type { PostPreview, PostTarget } from "@/daemon/registry.ts";
|
|
12
|
+
import type { AddCommentArgs } from "../state/useReview.ts";
|
|
13
|
+
|
|
14
|
+
/** Marker character for a diff line kind. */
|
|
15
|
+
function marker(kind: DiffLine["kind"]): string {
|
|
16
|
+
return kind === "add" ? "+" : kind === "del" ? "-" : " ";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Group a hunk's line-level comments by their line index. */
|
|
20
|
+
function commentsByLine(comments: AnchoredComment[]): Map<number, AnchoredComment[]> {
|
|
21
|
+
const map = new Map<number, AnchoredComment[]>();
|
|
22
|
+
for (const c of comments) {
|
|
23
|
+
if (c.lineIndex < 0) continue;
|
|
24
|
+
map.set(c.lineIndex, [...(map.get(c.lineIndex) ?? []), c]);
|
|
25
|
+
}
|
|
26
|
+
return map;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Group synced GitHub threads by their anchored line index. */
|
|
30
|
+
function threadsByLine(threads: AnchoredGithubThread[]): Map<number, AnchoredGithubThread[]> {
|
|
31
|
+
const map = new Map<number, AnchoredGithubThread[]>();
|
|
32
|
+
for (const t of threads) {
|
|
33
|
+
if (t.lineIndex < 0) continue;
|
|
34
|
+
map.set(t.lineIndex, [...(map.get(t.lineIndex) ?? []), t]);
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** An inclusive range of line indices. */
|
|
40
|
+
interface Span {
|
|
41
|
+
start: number;
|
|
42
|
+
end: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Normalise a span to [lo, hi]. */
|
|
46
|
+
function bounds(span: Span | null): { lo: number; hi: number } {
|
|
47
|
+
if (!span) return { lo: -1, hi: -1 };
|
|
48
|
+
return { lo: Math.min(span.start, span.end), hi: Math.max(span.start, span.end) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render a hunk's lines with gutters, markers, highlighting, and inline
|
|
53
|
+
* comments. Press the `+` on a line and drag over lines (GitHub-style) to
|
|
54
|
+
* select a range, then release to open a comment composer for that range.
|
|
55
|
+
*/
|
|
56
|
+
export function DiffLines(props: {
|
|
57
|
+
lines: DiffLine[];
|
|
58
|
+
path: string;
|
|
59
|
+
comments: AnchoredComment[];
|
|
60
|
+
githubThreads: AnchoredGithubThread[];
|
|
61
|
+
endSha: string;
|
|
62
|
+
onAddComment: (args: AddCommentArgs) => void;
|
|
63
|
+
onEditComment: (id: number, body: string) => void;
|
|
64
|
+
onDeleteComment: (id: number) => void;
|
|
65
|
+
onReplyToThread: (rootGithubId: string, body: string) => void;
|
|
66
|
+
previewPost: (commentId: number, target: PostTarget) => Promise<PostPreview>;
|
|
67
|
+
onPostComment: (commentId: number, target: PostTarget) => void;
|
|
68
|
+
}): React.JSX.Element {
|
|
69
|
+
const { lines, path, comments, githubThreads, endSha, onAddComment, onEditComment, onDeleteComment } = props;
|
|
70
|
+
const { onReplyToThread, previewPost, onPostComment } = props;
|
|
71
|
+
const language: string | undefined = languageForPath(path);
|
|
72
|
+
const byLine = commentsByLine(comments);
|
|
73
|
+
const threadLines = threadsByLine(githubThreads);
|
|
74
|
+
const [dragging, setDragging] = useState<Span | null>(null);
|
|
75
|
+
const [composing, setComposing] = useState<Span | null>(null);
|
|
76
|
+
|
|
77
|
+
// Finish the drag on mouse release anywhere in the window.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!dragging) return;
|
|
80
|
+
const onUp = (): void => { setComposing(dragging); setDragging(null); };
|
|
81
|
+
window.addEventListener("mouseup", onUp);
|
|
82
|
+
return () => window.removeEventListener("mouseup", onUp);
|
|
83
|
+
}, [dragging]);
|
|
84
|
+
|
|
85
|
+
const submit = (body: string): void => {
|
|
86
|
+
if (composing) {
|
|
87
|
+
const anchor = buildLinesAnchor(lines, composing.start, composing.end);
|
|
88
|
+
if (anchor) onAddComment({ kind: "lines", path, body, madeAtSha: endSha, ...anchor });
|
|
89
|
+
}
|
|
90
|
+
setComposing(null);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const active = bounds(dragging ?? composing);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<table className={dragging ? "diff-lines dragging" : "diff-lines"}>
|
|
97
|
+
<tbody>
|
|
98
|
+
{lines.map((line, i) => (
|
|
99
|
+
<Fragment key={i}>
|
|
100
|
+
<tr
|
|
101
|
+
className={`line line-${line.kind}${i >= active.lo && i <= active.hi ? " selected" : ""}`}
|
|
102
|
+
onMouseEnter={() => setDragging((d) => (d ? { start: d.start, end: i } : d))}
|
|
103
|
+
>
|
|
104
|
+
<td className="line-act">
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
className="add-comment"
|
|
108
|
+
title="Comment — drag over lines to select a range"
|
|
109
|
+
onMouseDown={(e) => { e.preventDefault(); setComposing(null); setDragging({ start: i, end: i }); }}
|
|
110
|
+
>+</button>
|
|
111
|
+
</td>
|
|
112
|
+
<td className="gutter old">{line.oldNo ?? ""}</td>
|
|
113
|
+
<td className="gutter new">{line.newNo ?? ""}</td>
|
|
114
|
+
<td className="marker">{marker(line.kind)}</td>
|
|
115
|
+
<td className="code" data-side={sideForLineKind(line.kind)} dangerouslySetInnerHTML={{ __html: applyDiffMarks(highlightToHtml(line.text, language), line.changes ?? []) }} />
|
|
116
|
+
</tr>
|
|
117
|
+
{(byLine.get(i) ?? []).map((c) => (
|
|
118
|
+
<tr key={`c${c.id}`} className="comment-row">
|
|
119
|
+
<td colSpan={5}>
|
|
120
|
+
<CommentItem
|
|
121
|
+
comment={c}
|
|
122
|
+
onEdit={(body) => onEditComment(c.id, body)}
|
|
123
|
+
onDelete={() => onDeleteComment(c.id)}
|
|
124
|
+
previewPost={(target) => previewPost(c.id, target)}
|
|
125
|
+
onPost={(target) => onPostComment(c.id, target)}
|
|
126
|
+
/>
|
|
127
|
+
</td>
|
|
128
|
+
</tr>
|
|
129
|
+
))}
|
|
130
|
+
{(threadLines.get(i) ?? []).map((t) => (
|
|
131
|
+
<tr key={`t${t.root.githubId}`} className="comment-row">
|
|
132
|
+
<td colSpan={5}>
|
|
133
|
+
<GithubThreadView thread={t} onReply={(body) => onReplyToThread(t.root.githubId, body)} />
|
|
134
|
+
</td>
|
|
135
|
+
</tr>
|
|
136
|
+
))}
|
|
137
|
+
{composing && bounds(composing).hi === i && (
|
|
138
|
+
<tr className="comment-row">
|
|
139
|
+
<td colSpan={5}>
|
|
140
|
+
<CommentComposer onSubmit={submit} onCancel={() => setComposing(null)} />
|
|
141
|
+
</td>
|
|
142
|
+
</tr>
|
|
143
|
+
)}
|
|
144
|
+
</Fragment>
|
|
145
|
+
))}
|
|
146
|
+
</tbody>
|
|
147
|
+
</table>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { trpc } from "../trpc.ts";
|
|
3
|
+
import { highlightToHtml, languageForPath } from "@/web/lib/highlight.ts";
|
|
4
|
+
import type { SearchSide } from "../state/useCodeSearch.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The single-version body of a file at one commit: the full text with the
|
|
8
|
+
* target line centered on open and flashed via `.split-anchor`. Presentational;
|
|
9
|
+
* owns only the `fileAt` query + the center-on-open effect. Reused by the
|
|
10
|
+
* plain file modal and the file navigator.
|
|
11
|
+
*
|
|
12
|
+
* @param props.prId - PR id the file belongs to.
|
|
13
|
+
* @param props.path - Repo-relative file path.
|
|
14
|
+
* @param props.sha - Commit SHA to read the file at.
|
|
15
|
+
* @param props.line - 1-based line to center and briefly highlight.
|
|
16
|
+
* @param props.side - Which checkout this version is (base/head), stamped as
|
|
17
|
+
* `data-side` so a symbol selected here defaults its lookup to that side.
|
|
18
|
+
*/
|
|
19
|
+
export function FileFrame(props: { prId: string; path: string; sha: string; line: number; side: SearchSide }): React.JSX.Element {
|
|
20
|
+
const { prId, path, sha, line, side } = props;
|
|
21
|
+
const query = trpc.fileAt.useQuery({ id: prId, sha, path });
|
|
22
|
+
const lines: string[] = (query.data ?? "").split("\n");
|
|
23
|
+
const language: string | undefined = languageForPath(path);
|
|
24
|
+
const anchor = useRef<HTMLTableRowElement>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (anchor.current) anchor.current.scrollIntoView({ block: "center" });
|
|
28
|
+
}, [query.data, line]);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="full-file-body">
|
|
32
|
+
{query.isLoading && <p className="notice">Loading file…</p>}
|
|
33
|
+
<table className="split-table">
|
|
34
|
+
<tbody>
|
|
35
|
+
{lines.map((text, i) => (
|
|
36
|
+
<tr key={i} ref={i + 1 === line ? anchor : undefined} className={i + 1 === line ? "split-anchor" : undefined}>
|
|
37
|
+
<td className="split-no">{i + 1}</td>
|
|
38
|
+
<td className="split-code" data-side={side} dangerouslySetInnerHTML={{ __html: highlightToHtml(text, language) }} />
|
|
39
|
+
</tr>
|
|
40
|
+
))}
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|