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,141 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { toInclusive, toRange } from "@/web/lib/rangeMap.ts";
|
|
3
|
+
import { rangeCoverageLabel } from "@/web/lib/rangeCoverage.ts";
|
|
4
|
+
import { formatCommitTime } from "@/web/lib/time.ts";
|
|
5
|
+
import { useEscToClose } from "@/web/lib/useEscToClose.ts";
|
|
6
|
+
import { isRangeReviewed, isStale } from "@/domain/ranges.ts";
|
|
7
|
+
import { CommitRail } from "./CommitRail.tsx";
|
|
8
|
+
import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "./Icons.tsx";
|
|
9
|
+
import type { CommitInfo } from "@/services/git.ts";
|
|
10
|
+
import type { ReviewedRangeRow } from "@/db/repositories/reviewedRanges.ts";
|
|
11
|
+
import type { RangeSel } from "../state/useReview.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Commit-range selector. A compact summary chip shows the current range as a
|
|
15
|
+
* coverage label ("All N commits" / "K of N commits") plus the newest commit's
|
|
16
|
+
* subject; hovering the chip reveals the SHA range. Clicking it opens a panel
|
|
17
|
+
* with a visual commit rail for changing the selection (applied live). The
|
|
18
|
+
* panel closes on outside-click or Esc, keeping the current selection.
|
|
19
|
+
* Presented inclusively (first..last commit included) and mapped internally to
|
|
20
|
+
* the exclusive baseline→end range.
|
|
21
|
+
*/
|
|
22
|
+
export function RangeSelector(props: {
|
|
23
|
+
baselineSha: string | null;
|
|
24
|
+
commits: CommitInfo[];
|
|
25
|
+
range: RangeSel | null;
|
|
26
|
+
onChange: (r: RangeSel) => void;
|
|
27
|
+
onMarkReviewed: () => void;
|
|
28
|
+
onUnmarkReviewed: () => void;
|
|
29
|
+
reviewedRanges: ReviewedRangeRow[];
|
|
30
|
+
}): React.JSX.Element {
|
|
31
|
+
const { baselineSha, commits, range, onChange, onMarkReviewed, onUnmarkReviewed, reviewedRanges } = props;
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
const onEsc = useCallback(() => setOpen((o) => (o ? false : o)), []);
|
|
35
|
+
useEscToClose(onEsc);
|
|
36
|
+
|
|
37
|
+
// Close when clicking anywhere outside this selector (chip + panel). Bound
|
|
38
|
+
// only while open; a pointerdown listener fires before the click, so a
|
|
39
|
+
// selection inside the panel is never swallowed.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!open) return;
|
|
42
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
43
|
+
const target: Node | null = e.target instanceof Node ? e.target : null;
|
|
44
|
+
if (target && rootRef.current?.contains(target)) return;
|
|
45
|
+
setOpen(false);
|
|
46
|
+
};
|
|
47
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
48
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
49
|
+
}, [open]);
|
|
50
|
+
|
|
51
|
+
if (!range || baselineSha === null || commits.length === 0) {
|
|
52
|
+
return <div className="range-selector">Loading range…</div>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const shas: string[] = commits.map((c) => c.sha);
|
|
56
|
+
const sel = toInclusive(range, shas, baselineSha);
|
|
57
|
+
const fromC = commits[sel.fromIndex];
|
|
58
|
+
const toC = commits[sel.toIndex];
|
|
59
|
+
if (!fromC || !toC) return <div className="range-selector">Loading range…</div>;
|
|
60
|
+
|
|
61
|
+
const count: number = sel.toIndex - sel.fromIndex + 1;
|
|
62
|
+
const coverage: string = rangeCoverageLabel({ fromIndex: sel.fromIndex, toIndex: sel.toIndex, total: commits.length });
|
|
63
|
+
const shaRange = `${fromC.shortSha} → ${toC.shortSha}`;
|
|
64
|
+
const alreadyReviewed: boolean = isRangeReviewed(
|
|
65
|
+
{ startSha: range.start, endSha: range.end },
|
|
66
|
+
reviewedRanges.map((r) => ({ startSha: r.startSha, endSha: r.endSha, createdAt: r.createdAt })),
|
|
67
|
+
);
|
|
68
|
+
const select = (fromIndex: number, toIndex: number): void =>
|
|
69
|
+
onChange(toRange({ fromIndex, toIndex }, shas, baselineSha));
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="range-selector" ref={rootRef}>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
className="range-chip"
|
|
76
|
+
title={`${shaRange}\n${toC.subject}`}
|
|
77
|
+
onClick={() => setOpen((o) => !o)}
|
|
78
|
+
>
|
|
79
|
+
<span className="range-chip-coverage">{coverage}</span>
|
|
80
|
+
<span className="range-chip-sep">·</span>
|
|
81
|
+
<span className="range-chip-subject">{toC.subject}</span>
|
|
82
|
+
<span className="chip-caret">{open ? <ChevronDownIcon size={13} /> : <ChevronRightIcon size={13} />}</span>
|
|
83
|
+
</button>
|
|
84
|
+
|
|
85
|
+
{open && (
|
|
86
|
+
<div className="range-panel">
|
|
87
|
+
<CommitRail commits={commits} fromIndex={sel.fromIndex} toIndex={sel.toIndex} onSelect={select} />
|
|
88
|
+
<p className="range-caption">
|
|
89
|
+
{count === 1 ? (
|
|
90
|
+
<>
|
|
91
|
+
This commit: <span className="range-caption-subject">“{toC.subject}”</span>{" "}
|
|
92
|
+
({toC.authorName}, {formatCommitTime(toC.isoDate)}) <code>{toC.shortSha}</code>.
|
|
93
|
+
</>
|
|
94
|
+
) : (
|
|
95
|
+
<>
|
|
96
|
+
From <span className="range-caption-subject">“{fromC.subject}”</span> ({fromC.authorName},{" "}
|
|
97
|
+
{formatCommitTime(fromC.isoDate)}) <code>{fromC.shortSha}</code>{" "}
|
|
98
|
+
to <span className="range-caption-subject">“{toC.subject}”</span>{" "}
|
|
99
|
+
({formatCommitTime(toC.isoDate)}) <code>{toC.shortSha}</code>.
|
|
100
|
+
</>
|
|
101
|
+
)}
|
|
102
|
+
{sel.fromIndex === 0
|
|
103
|
+
? " Diff is from the start of the PR."
|
|
104
|
+
: ` Diff shows changes made after ${commits[sel.fromIndex - 1]?.shortSha ?? "the baseline"}.`}
|
|
105
|
+
</p>
|
|
106
|
+
<div className="range-panel-actions">
|
|
107
|
+
{alreadyReviewed ? (
|
|
108
|
+
<button type="button" className="range-reviewed-badge" title="Reviewed — click to un-mark this range" onClick={onUnmarkReviewed}>
|
|
109
|
+
<CheckIcon size={14} /> Range reviewed
|
|
110
|
+
</button>
|
|
111
|
+
) : (
|
|
112
|
+
<button type="button" className="btn btn-sm" onClick={onMarkReviewed}>Mark range reviewed</button>
|
|
113
|
+
)}
|
|
114
|
+
<span className="reviewed-count">{reviewedRanges.length} reviewed</span>
|
|
115
|
+
</div>
|
|
116
|
+
{reviewedRanges.length > 0 && (
|
|
117
|
+
<ul className="reviewed-list">
|
|
118
|
+
{reviewedRanges.map((r) => {
|
|
119
|
+
const stale: boolean = isStale({ startSha: r.startSha, endSha: r.endSha }, { baselineSha, commits: shas });
|
|
120
|
+
return (
|
|
121
|
+
<li key={`${r.startSha}-${r.endSha}`}>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
className="reviewed-range"
|
|
125
|
+
disabled={stale}
|
|
126
|
+
title={stale ? "A commit in this range no longer exists (force-push/rebase)" : "Select this reviewed range"}
|
|
127
|
+
onClick={() => onChange({ start: r.startSha, end: r.endSha })}
|
|
128
|
+
>
|
|
129
|
+
<code>{r.startSha.slice(0, 7)}</code> → <code>{r.endSha.slice(0, 7)}</code>
|
|
130
|
+
</button>
|
|
131
|
+
{stale && <span className="badge stale">stale</span>}
|
|
132
|
+
</li>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
</ul>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { CodePreview } from "./CodePreview.tsx";
|
|
3
|
+
import { FileIcon, InfoIcon } from "./Icons.tsx";
|
|
4
|
+
import { Tooltip } from "./Tooltip.tsx";
|
|
5
|
+
import { filterCodeResults, type CodeResultFilters } from "@/web/lib/filterCodeResults.ts";
|
|
6
|
+
import { dedupeResults } from "@/web/lib/dedupeResults.ts";
|
|
7
|
+
import { resultCountLabel } from "@/web/lib/resultCountLabel.ts";
|
|
8
|
+
import type { CodeResult } from "@/services/symbols.ts";
|
|
9
|
+
|
|
10
|
+
/** A location to open in a file view. */
|
|
11
|
+
export interface OpenLocation {
|
|
12
|
+
/** Repo-relative file path. */
|
|
13
|
+
path: string;
|
|
14
|
+
/** 1-based line to center on. */
|
|
15
|
+
line: number;
|
|
16
|
+
/** Commit SHA the result was found at. */
|
|
17
|
+
sha: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Header context describing what produced the results. */
|
|
21
|
+
export interface ResultsHeader {
|
|
22
|
+
/** Human label of the action, e.g. "Definition", "Usages", "Search". */
|
|
23
|
+
op: string;
|
|
24
|
+
/** The looked-up term / query. */
|
|
25
|
+
term: string;
|
|
26
|
+
/** Optional scope label (checkout side, e.g. "head"). */
|
|
27
|
+
scope?: string;
|
|
28
|
+
/** The file the lookup was scoped to ("" / undefined = repo-wide). */
|
|
29
|
+
scopeFile?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Longer explanation shown in the usages-accuracy tooltip. */
|
|
33
|
+
const USAGES_CAVEAT =
|
|
34
|
+
"Usages are found by scanning for references and may be incomplete — re-exports, dynamic calls, and references in other files can be missed. Treat the list as a starting point, not an exhaustive set.";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The shared results surface: a header, a filters row, and a keyboard-navigable
|
|
38
|
+
* list of {@link CodePreview} items each with a "View file" action. Results are
|
|
39
|
+
* deduped by `(path, line)` (scopes joined) before display, then narrowed by
|
|
40
|
+
* the active filters with a live count. Handles empty / loading / error states,
|
|
41
|
+
* including a bad-regex message. Reused by the rail Search tab and (Phase D) the
|
|
42
|
+
* navigator results frame.
|
|
43
|
+
*/
|
|
44
|
+
export function ResultsList(props: {
|
|
45
|
+
/** The raw results to show. */
|
|
46
|
+
results: CodeResult[];
|
|
47
|
+
/** True while a lookup is running. */
|
|
48
|
+
loading: boolean;
|
|
49
|
+
/** A user-facing error, or null. */
|
|
50
|
+
error: string | null;
|
|
51
|
+
/** The active filters (controlled). */
|
|
52
|
+
filters: CodeResultFilters;
|
|
53
|
+
/** Update the filters. */
|
|
54
|
+
onFilters: (f: CodeResultFilters) => void;
|
|
55
|
+
/** The SHA results were found at (passed through to `onOpen`). */
|
|
56
|
+
sha: string;
|
|
57
|
+
/** Header context. */
|
|
58
|
+
header: ResultsHeader;
|
|
59
|
+
/** Open a result's location in a file view. */
|
|
60
|
+
onOpen: (loc: OpenLocation) => void;
|
|
61
|
+
/** Dismiss the surface (Esc). */
|
|
62
|
+
onClose: () => void;
|
|
63
|
+
/** Re-run the lookup repo-wide (shown as "search everywhere" when scoped). */
|
|
64
|
+
onBroaden?: () => void;
|
|
65
|
+
}): React.JSX.Element {
|
|
66
|
+
const { results, loading, error, filters, onFilters, sha, header, onOpen, onClose, onBroaden } = props;
|
|
67
|
+
const deduped: CodeResult[] = dedupeResults(results);
|
|
68
|
+
const shown: CodeResult[] = filterCodeResults(deduped, filters);
|
|
69
|
+
const [focus, setFocus] = useState(0);
|
|
70
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
71
|
+
|
|
72
|
+
// Keep focus in range as the filtered list changes.
|
|
73
|
+
useEffect(() => { setFocus((f) => Math.min(f, Math.max(0, shown.length - 1))); }, [shown.length]);
|
|
74
|
+
|
|
75
|
+
const open = (r: CodeResult): void => onOpen({ path: r.path, line: r.line, sha });
|
|
76
|
+
|
|
77
|
+
const onKey = (e: React.KeyboardEvent): void => {
|
|
78
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setFocus((f) => Math.min(f + 1, shown.length - 1)); }
|
|
79
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); setFocus((f) => Math.max(f - 1, 0)); }
|
|
80
|
+
else if (e.key === "Enter") { const r = shown[focus]; if (r) open(r); }
|
|
81
|
+
else if (e.key === "Escape") { onClose(); }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="results-list" onKeyDown={onKey} tabIndex={-1}>
|
|
86
|
+
<header className="results-header">
|
|
87
|
+
<strong>{header.op}</strong>
|
|
88
|
+
<code>{header.term}</code>
|
|
89
|
+
{header.scope && <span className="symbol-side-tag">{header.scope}</span>}
|
|
90
|
+
{header.scopeFile && (
|
|
91
|
+
<span className="results-scope">
|
|
92
|
+
in <code>{header.scopeFile}</code>
|
|
93
|
+
{onBroaden && (
|
|
94
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={onBroaden}>Search everywhere</button>
|
|
95
|
+
)}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
<span className="results-count">{resultCountLabel(deduped.length, shown.length)}</span>
|
|
99
|
+
</header>
|
|
100
|
+
{header.op === "Usages" && (
|
|
101
|
+
<p className="results-note">
|
|
102
|
+
<Tooltip label={USAGES_CAVEAT} placement="bottom">
|
|
103
|
+
<button type="button" className="results-note-info" aria-label="About usages accuracy"><InfoIcon size={13} /></button>
|
|
104
|
+
</Tooltip>
|
|
105
|
+
Usages are best-effort — some references may be missing.
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
108
|
+
<div className="results-filters">
|
|
109
|
+
<input
|
|
110
|
+
type="text"
|
|
111
|
+
placeholder="Filter by path…"
|
|
112
|
+
value={filters.pathText ?? ""}
|
|
113
|
+
onChange={(e) => onFilters({ ...filters, pathText: e.target.value })}
|
|
114
|
+
/>
|
|
115
|
+
<input
|
|
116
|
+
type="text"
|
|
117
|
+
placeholder="Filter by code…"
|
|
118
|
+
value={filters.codeText ?? ""}
|
|
119
|
+
onChange={(e) => onFilters({ ...filters, codeText: e.target.value })}
|
|
120
|
+
/>
|
|
121
|
+
<label className="results-toggle">
|
|
122
|
+
<input
|
|
123
|
+
type="checkbox"
|
|
124
|
+
checked={filters.excludeTestsGenerated ?? false}
|
|
125
|
+
onChange={(e) => onFilters({ ...filters, excludeTestsGenerated: e.target.checked })}
|
|
126
|
+
/>
|
|
127
|
+
Exclude tests/generated
|
|
128
|
+
</label>
|
|
129
|
+
</div>
|
|
130
|
+
{loading && <div className="diff-loading"><span className="chat-spinner" aria-hidden="true" /> Running…</div>}
|
|
131
|
+
{!loading && error && <p className="notice results-error">{error}</p>}
|
|
132
|
+
{!loading && !error && shown.length === 0 && (
|
|
133
|
+
<div className="empty-state">
|
|
134
|
+
<FileIcon size={32} />
|
|
135
|
+
<p className="empty-state-title">{deduped.length === 0 ? "No results" : "No matches"}</p>
|
|
136
|
+
{deduped.length > 0 && <p className="empty-state-hint">No results match these filters.</p>}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
{!loading && !error && shown.length > 0 && (
|
|
140
|
+
<ul className="results-items" ref={listRef}>
|
|
141
|
+
{shown.map((r, i) => (
|
|
142
|
+
<li
|
|
143
|
+
key={`${r.path}:${r.line}`}
|
|
144
|
+
className={i === focus ? "results-item focused" : "results-item"}
|
|
145
|
+
onMouseEnter={() => setFocus(i)}
|
|
146
|
+
>
|
|
147
|
+
<div className="results-item-loc">
|
|
148
|
+
<code>{r.path}:{r.line}</code>
|
|
149
|
+
{r.scope && <span className="results-item-scope">{r.scope}</span>}
|
|
150
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => open(r)}>View file</button>
|
|
151
|
+
</div>
|
|
152
|
+
<CodePreview result={r} />
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { trpc } from "../trpc.ts";
|
|
3
|
+
import { useReview, type ReviewState, type RangeSel } from "../state/useReview.ts";
|
|
4
|
+
import { useCodeSearch, type MenuOp, type SearchSide } from "../state/useCodeSearch.ts";
|
|
5
|
+
import { useChat, type ChatState } from "../state/useChat.ts";
|
|
6
|
+
import { visibleFiles } from "@/web/lib/visibleFiles.ts";
|
|
7
|
+
import { reviewProgress } from "@/web/lib/reviewProgress.ts";
|
|
8
|
+
import { usePersistedToggles, hideWhitespaceStorageKey } from "@/web/lib/togglePrefs.ts";
|
|
9
|
+
import { usePersistedFlag } from "@/web/lib/persistedFlag.ts";
|
|
10
|
+
import { usePageTitle } from "@/web/lib/usePageTitle.ts";
|
|
11
|
+
import { Toolbar } from "./Toolbar.tsx";
|
|
12
|
+
import { Tooltip } from "./Tooltip.tsx";
|
|
13
|
+
import { RangeSelector } from "./RangeSelector.tsx";
|
|
14
|
+
import { FileTree, fileSectionId } from "./FileTree.tsx";
|
|
15
|
+
import { HunkCard } from "./HunkCard.tsx";
|
|
16
|
+
import { FileNavigator } from "./FileNavigator.tsx";
|
|
17
|
+
import { IdentifierMenuPortal } from "./IdentifierMenuPortal.tsx";
|
|
18
|
+
import { useIdentifierMenu } from "@/web/lib/useIdentifierMenu.ts";
|
|
19
|
+
import type { NavFrame } from "@/web/lib/navStack.ts";
|
|
20
|
+
import { ChatPanel } from "./ChatPanel.tsx";
|
|
21
|
+
import { AiReviewModal } from "./AiReviewModal.tsx";
|
|
22
|
+
import { AiReviewIndicator } from "./AiReviewIndicator.tsx";
|
|
23
|
+
import { RightRail } from "./RightRail.tsx";
|
|
24
|
+
import { SwitchPrModal } from "./SwitchPrModal.tsx";
|
|
25
|
+
import { CopyIconButton } from "./CopyIconButton.tsx";
|
|
26
|
+
import type { RailTab } from "@/web/lib/railState.ts";
|
|
27
|
+
import {
|
|
28
|
+
RefreshIcon, SparkleIcon, ExternalIcon, FileIcon, ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon,
|
|
29
|
+
} from "./Icons.tsx";
|
|
30
|
+
import type { FileView } from "@/daemon/reviewService.ts";
|
|
31
|
+
|
|
32
|
+
/** localStorage key for the global left-sidebar collapsed layout preference. */
|
|
33
|
+
const LEFT_SIDEBAR_COLLAPSED_KEY = "mergie:leftSidebarCollapsed";
|
|
34
|
+
|
|
35
|
+
/** Circumference of the progress ring (radius 14) — used to drive its fill. */
|
|
36
|
+
const RING_CIRCUMFERENCE = 2 * Math.PI * 14;
|
|
37
|
+
|
|
38
|
+
/** Read an initial range from the URL (`?start=&end=`), if both are present. */
|
|
39
|
+
function initialRangeFromUrl(): { start: string; end: string } | null {
|
|
40
|
+
const params = new URLSearchParams(window.location.search);
|
|
41
|
+
const start = params.get("start");
|
|
42
|
+
const end = params.get("end");
|
|
43
|
+
return start && end ? { start, end } : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The full review-core screen for one PR. */
|
|
47
|
+
export function ReviewView(props: { prId: string }): React.JSX.Element {
|
|
48
|
+
const [hideWhitespace, setHideWhitespace] = usePersistedFlag(hideWhitespaceStorageKey(props.prId), false);
|
|
49
|
+
const review = useReview(props.prId, initialRangeFromUrl(), hideWhitespace);
|
|
50
|
+
const codeSearch = useCodeSearch(props.prId, {
|
|
51
|
+
headSha: () => review.range?.end ?? "",
|
|
52
|
+
baseSha: () => review.range?.start ?? "",
|
|
53
|
+
});
|
|
54
|
+
const chat = useChat(props.prId, () => review.range);
|
|
55
|
+
const [query, setQuery] = useState("");
|
|
56
|
+
const [toggles, setToggles] = usePersistedToggles(props.prId);
|
|
57
|
+
const [sidebarCollapsed, setSidebarCollapsed] = usePersistedFlag(LEFT_SIDEBAR_COLLAPSED_KEY, false);
|
|
58
|
+
// The frame the file navigator is seeded with; null = navigator closed.
|
|
59
|
+
const [navOrigin, setNavOrigin] = useState<NavFrame | null>(null);
|
|
60
|
+
const diffMenu = useIdentifierMenu();
|
|
61
|
+
// The AI-review popup target: null = closed. Holds the range to review, which
|
|
62
|
+
// is normally the current range but can be a different range when opened from
|
|
63
|
+
// the "review ready" header indicator.
|
|
64
|
+
const [reviewing, setReviewing] = useState<RangeSel | null>(null);
|
|
65
|
+
const [railTab, setRailTab] = useState<RailTab | null>(null);
|
|
66
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
67
|
+
// Hunks temporarily forced visible (jumped to from the comments panel even
|
|
68
|
+
// though a view toggle would otherwise hide them). Does not touch the range.
|
|
69
|
+
const [revealedHunks, setRevealedHunks] = useState<ReadonlySet<string>>(new Set());
|
|
70
|
+
const revealHunk = (hunkHash: string): void => setRevealedHunks((s) => new Set(s).add(hunkHash));
|
|
71
|
+
// Hunk/file "View file" opens the navigator on a diff frame; the rail's
|
|
72
|
+
// results "View file" opens it on a file frame (see onOpenNavFile below).
|
|
73
|
+
const openFile = (path: string, anchorLine: number | null): void => setNavOrigin({ kind: "diff", path, anchorLine });
|
|
74
|
+
|
|
75
|
+
// A reveal is transient: once the user changes the range or their view
|
|
76
|
+
// toggles, normal visibility rules reapply and the revealed hunk hides again.
|
|
77
|
+
const rangeKey: string = `${review.range?.start ?? ""}:${review.range?.end ?? ""}`;
|
|
78
|
+
const toggleKey: string = JSON.stringify(toggles);
|
|
79
|
+
useEffect(() => { setRevealedHunks(new Set()); }, [rangeKey, toggleKey]);
|
|
80
|
+
|
|
81
|
+
const pickSymbol = (op: MenuOp, side: SearchSide): void => {
|
|
82
|
+
if (diffMenu.menu) {
|
|
83
|
+
setRailTab("search");
|
|
84
|
+
codeSearch.runFromMenu(op, diffMenu.menu.term, side, diffMenu.menu.file);
|
|
85
|
+
}
|
|
86
|
+
diffMenu.close();
|
|
87
|
+
};
|
|
88
|
+
const health = trpc.health.useQuery();
|
|
89
|
+
const pr = health.data?.prs.find((p) => p.id === props.prId);
|
|
90
|
+
usePageTitle(pr ? `${pr.owner}/${pr.repo} #${pr.number} — ${pr.title}` : null);
|
|
91
|
+
const files: FileView[] = visibleFiles(review.files, query, toggles, revealedHunks);
|
|
92
|
+
// Progress counts the whole on-screen range, so view filters don't skew it.
|
|
93
|
+
const progress = reviewProgress(review.files);
|
|
94
|
+
const remaining: number = progress.total - progress.viewed;
|
|
95
|
+
const progressPct: number = progress.total > 0 ? progress.viewed / progress.total : 0;
|
|
96
|
+
const allReviewed: boolean = progress.total > 0 && remaining === 0;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="review">
|
|
100
|
+
<header className="review-header">
|
|
101
|
+
<div className="review-header-bar">
|
|
102
|
+
<div className="review-title">
|
|
103
|
+
<div className="review-title-row">
|
|
104
|
+
<strong>{pr ? `${pr.owner}/${pr.repo} #${pr.number}` : props.prId}</strong>
|
|
105
|
+
{pr && (
|
|
106
|
+
<span className="pr-link-group">
|
|
107
|
+
<a
|
|
108
|
+
className="review-nav-link"
|
|
109
|
+
href={`https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`}
|
|
110
|
+
target="_blank"
|
|
111
|
+
rel="noopener noreferrer"
|
|
112
|
+
>
|
|
113
|
+
Open on GitHub <ExternalIcon size={12} />
|
|
114
|
+
</a>
|
|
115
|
+
<CopyIconButton
|
|
116
|
+
text={`https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`}
|
|
117
|
+
label="Copy PR URL"
|
|
118
|
+
/>
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
<span className="pr-subtitle">{pr?.title}</span>
|
|
123
|
+
{pr && (
|
|
124
|
+
<div className="pr-branches">
|
|
125
|
+
<code className="branch-name">{pr.baseRef}</code>
|
|
126
|
+
<CopyIconButton text={pr.baseRef} label="Copy base branch name" size={12} />
|
|
127
|
+
<span className="branch-arrow" aria-label="merges from">←</span>
|
|
128
|
+
<code className="branch-name">{pr.headRef}</code>
|
|
129
|
+
<CopyIconButton text={pr.headRef} label="Copy head branch name" size={12} />
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
<div className="review-actions">
|
|
134
|
+
<div className="review-action-group">
|
|
135
|
+
<button type="button" className="btn" onClick={() => setPickerOpen(true)} title="Switch to another pull request">
|
|
136
|
+
<ChevronDownIcon size={12} /> Switch PR
|
|
137
|
+
</button>
|
|
138
|
+
<button type="button" className="btn" disabled={review.refreshing} onClick={review.refreshPr} title="Re-fetch the PR for new commits">
|
|
139
|
+
<RefreshIcon size={14} /> {review.refreshing ? "Refreshing…" : "Refresh PR"}
|
|
140
|
+
</button>
|
|
141
|
+
<button type="button" className="btn btn-accent" disabled={!review.range} onClick={() => review.range && setReviewing(review.range)}>
|
|
142
|
+
<SparkleIcon size={14} /> AI review
|
|
143
|
+
</button>
|
|
144
|
+
<AiReviewIndicator prId={props.prId} onOpen={(r) => setReviewing(r)} />
|
|
145
|
+
</div>
|
|
146
|
+
<span className="review-action-divider" />
|
|
147
|
+
<RangeSelector
|
|
148
|
+
baselineSha={review.baselineSha}
|
|
149
|
+
commits={review.commits}
|
|
150
|
+
range={review.range}
|
|
151
|
+
onChange={review.setRange}
|
|
152
|
+
onMarkReviewed={review.markReviewed}
|
|
153
|
+
onUnmarkReviewed={review.unmarkReviewed}
|
|
154
|
+
reviewedRanges={review.reviewedRanges}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</header>
|
|
159
|
+
<div className="review-body">
|
|
160
|
+
<aside className={sidebarCollapsed ? "sidebar collapsed" : "sidebar"}>
|
|
161
|
+
{/* Always-present top row: the "View" heading (hidden when collapsed)
|
|
162
|
+
shares one line with the collapse/expand chevron, so there is no
|
|
163
|
+
empty band above the switches and the toggle stays visible in both
|
|
164
|
+
states. */}
|
|
165
|
+
<div className="sidebar-header">
|
|
166
|
+
{progress.total > 0 ? (
|
|
167
|
+
<div
|
|
168
|
+
className="review-ring-group"
|
|
169
|
+
role="img"
|
|
170
|
+
aria-label={allReviewed
|
|
171
|
+
? "All hunks reviewed"
|
|
172
|
+
: `${remaining} of ${progress.total} hunks left to review`}
|
|
173
|
+
>
|
|
174
|
+
<div className={allReviewed ? "review-ring done" : "review-ring"}>
|
|
175
|
+
<svg width="34" height="34" viewBox="0 0 34 34" aria-hidden="true">
|
|
176
|
+
<circle className="review-ring-track" cx="17" cy="17" r="14" strokeWidth="3.5" />
|
|
177
|
+
<circle
|
|
178
|
+
className="review-ring-fill"
|
|
179
|
+
cx="17" cy="17" r="14" strokeWidth="3.5"
|
|
180
|
+
style={{ strokeDasharray: RING_CIRCUMFERENCE, strokeDashoffset: RING_CIRCUMFERENCE * (1 - progressPct) }}
|
|
181
|
+
/>
|
|
182
|
+
</svg>
|
|
183
|
+
<span className="review-ring-num">{allReviewed ? "✓" : remaining}</span>
|
|
184
|
+
</div>
|
|
185
|
+
<span className="review-ring-caption">
|
|
186
|
+
{allReviewed ? "All reviewed" : `${remaining === 1 ? "hunk" : "hunks"} left`}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
) : (
|
|
190
|
+
<h2 className="sidebar-heading">View</h2>
|
|
191
|
+
)}
|
|
192
|
+
<Tooltip label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} placement="bottom">
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
className="sidebar-toggle"
|
|
196
|
+
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
197
|
+
aria-expanded={!sidebarCollapsed}
|
|
198
|
+
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
199
|
+
>
|
|
200
|
+
{sidebarCollapsed ? <ChevronRightIcon size={16} /> : <ChevronLeftIcon size={16} />}
|
|
201
|
+
</button>
|
|
202
|
+
</Tooltip>
|
|
203
|
+
</div>
|
|
204
|
+
{/* Kept mounted while collapsed (hidden via CSS) so the filter text and
|
|
205
|
+
switch states are never lost across a collapse/expand. */}
|
|
206
|
+
<div className="sidebar-content">
|
|
207
|
+
<Toolbar toggles={toggles} onChange={setToggles} hideWhitespace={hideWhitespace} onHideWhitespaceChange={setHideWhitespace} />
|
|
208
|
+
<FileTree files={files} query={query} onQuery={setQuery} />
|
|
209
|
+
</div>
|
|
210
|
+
</aside>
|
|
211
|
+
<main className="diff-area" onMouseUp={diffMenu.onMouseUp}>
|
|
212
|
+
{review.loading && (
|
|
213
|
+
<div className="diff-loading"><span className="chat-spinner" aria-hidden="true" /> Loading diff…</div>
|
|
214
|
+
)}
|
|
215
|
+
{!review.loading && files.length === 0 && (
|
|
216
|
+
<div className="empty-state">
|
|
217
|
+
<FileIcon size={36} />
|
|
218
|
+
<p className="empty-state-title">No files to show</p>
|
|
219
|
+
<p className="empty-state-hint">Nothing matches the current range and view filters.</p>
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
{files.map((f) => (
|
|
223
|
+
<section key={f.newPath} id={fileSectionId(f.newPath)} className="file-section">
|
|
224
|
+
<h2 className="file-heading">
|
|
225
|
+
<span className="file-heading-name">{f.newPath}</span>
|
|
226
|
+
<CopyIconButton text={f.newPath} label="Copy file path" size={12} />
|
|
227
|
+
<small>{f.status}{f.isLockfile ? " · lock" : ""}</small>
|
|
228
|
+
<span className="file-heading-actions">
|
|
229
|
+
{!f.isBinary && f.hunks.length > 0 && (
|
|
230
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openFile(f.newPath, null)}>View file</button>
|
|
231
|
+
)}
|
|
232
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => chat.open({ kind: "file", ref: f.newPath, label: f.newPath })}>
|
|
233
|
+
<SparkleIcon size={12} /> Ask AI
|
|
234
|
+
</button>
|
|
235
|
+
</span>
|
|
236
|
+
</h2>
|
|
237
|
+
{renderFileBody(f, review, openFile, chat, revealedHunks)}
|
|
238
|
+
</section>
|
|
239
|
+
))}
|
|
240
|
+
</main>
|
|
241
|
+
<ChatPanel chat={chat} />
|
|
242
|
+
<RightRail
|
|
243
|
+
prId={props.prId}
|
|
244
|
+
active={railTab}
|
|
245
|
+
onActiveChange={setRailTab}
|
|
246
|
+
rangeFiles={review.files}
|
|
247
|
+
renderedFiles={files}
|
|
248
|
+
baselineSha={review.baselineSha ?? ""}
|
|
249
|
+
onReveal={revealHunk}
|
|
250
|
+
prBody={pr?.body ?? ""}
|
|
251
|
+
codeSearch={codeSearch}
|
|
252
|
+
onOpenFile={(f) => setNavOrigin({ kind: "file", path: f.path, sha: f.sha, line: f.line })}
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
<IdentifierMenuPortal menu={diffMenu.menu} onPick={pickSymbol} onClose={diffMenu.close} />
|
|
256
|
+
{pickerOpen && (
|
|
257
|
+
<SwitchPrModal currentPrId={props.prId} onClose={() => setPickerOpen(false)} />
|
|
258
|
+
)}
|
|
259
|
+
{reviewing && (
|
|
260
|
+
<AiReviewModal prId={props.prId} range={reviewing} onClose={() => setReviewing(null)} />
|
|
261
|
+
)}
|
|
262
|
+
{navOrigin && review.range && (
|
|
263
|
+
<FileNavigator
|
|
264
|
+
prId={props.prId}
|
|
265
|
+
origin={navOrigin}
|
|
266
|
+
range={review.range}
|
|
267
|
+
hideWhitespace={hideWhitespace}
|
|
268
|
+
onClose={() => setNavOrigin(null)}
|
|
269
|
+
/>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Render a file's hunks, or a placeholder for binary / no-change files. */
|
|
276
|
+
function renderFileBody(
|
|
277
|
+
file: FileView,
|
|
278
|
+
review: ReviewState,
|
|
279
|
+
openFile: (path: string, anchorLine: number | null) => void,
|
|
280
|
+
chat: ChatState,
|
|
281
|
+
revealedHunks: ReadonlySet<string>,
|
|
282
|
+
): React.JSX.Element {
|
|
283
|
+
if (file.isBinary) return <p className="notice">Binary file — not shown.</p>;
|
|
284
|
+
if (file.hunks.length === 0) return <p className="notice">No textual changes.</p>;
|
|
285
|
+
const endSha: string = review.range?.end ?? "";
|
|
286
|
+
return (
|
|
287
|
+
<>
|
|
288
|
+
{file.hunks.map((h) => (
|
|
289
|
+
<HunkCard
|
|
290
|
+
key={h.hash}
|
|
291
|
+
path={file.newPath}
|
|
292
|
+
hunk={h}
|
|
293
|
+
endSha={endSha}
|
|
294
|
+
revealed={revealedHunks.has(h.hash)}
|
|
295
|
+
onToggleViewed={(v) => review.toggleHunkViewed(h.hash, v)}
|
|
296
|
+
addComment={review.addComment}
|
|
297
|
+
editComment={review.editComment}
|
|
298
|
+
deleteComment={review.deleteComment}
|
|
299
|
+
onViewFile={(line) => openFile(file.newPath, line)}
|
|
300
|
+
previewPost={review.previewPost}
|
|
301
|
+
postComment={review.postComment}
|
|
302
|
+
replyToThread={review.replyToThread}
|
|
303
|
+
onAskAi={() => chat.open({ kind: "hunk", ref: h.hash, label: `hunk · ${file.newPath}` })}
|
|
304
|
+
/>
|
|
305
|
+
))}
|
|
306
|
+
</>
|
|
307
|
+
);
|
|
308
|
+
}
|