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,195 @@
|
|
|
1
|
+
import { useEffect, useReducer, useState } from "react";
|
|
2
|
+
import { DiffFrame } from "./DiffFrame.tsx";
|
|
3
|
+
import { FileFrame } from "./FileFrame.tsx";
|
|
4
|
+
import { ResultsList, type OpenLocation, type ResultsHeader } from "./ResultsList.tsx";
|
|
5
|
+
import { IdentifierMenuPortal } from "./IdentifierMenuPortal.tsx";
|
|
6
|
+
import { useIdentifierMenu } from "@/web/lib/useIdentifierMenu.ts";
|
|
7
|
+
import { useNavLookup } from "@/web/lib/useNavLookup.ts";
|
|
8
|
+
import { opLabel } from "@/web/lib/navRouting.ts";
|
|
9
|
+
import {
|
|
10
|
+
navStackReducer, initNavStack, currentFrame, canGoBack, canGoForward, frameKey,
|
|
11
|
+
type NavFrame, type NavStack, type NavStackAction,
|
|
12
|
+
} from "@/web/lib/navStack.ts";
|
|
13
|
+
import { CloseIcon, FileIcon, ChevronLeftIcon, ChevronRightIcon } from "./Icons.tsx";
|
|
14
|
+
import type { CodeResultFilters } from "@/web/lib/filterCodeResults.ts";
|
|
15
|
+
import type { MenuOp, SearchSide } from "../state/useCodeSearch.ts";
|
|
16
|
+
|
|
17
|
+
/** Breadcrumb-style title for the frame currently on screen. */
|
|
18
|
+
function frameTitle(frame: NavFrame): React.JSX.Element {
|
|
19
|
+
if (frame.kind === "diff") return <><FileIcon size={14} />{frame.path} <span className="split-base-head">diff</span></>;
|
|
20
|
+
if (frame.kind === "file") return <><FileIcon size={14} />{frame.path}:{frame.line} <span className="split-base-head">@ <code>{frame.sha.slice(0, 7)}</code></span></>;
|
|
21
|
+
return <><strong>{opLabel(frame.op)}</strong> <code>{frame.term}</code> <span className="symbol-side-tag">{frame.side}</span></>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve the SHA to run a lookup against for a given side + range. */
|
|
25
|
+
function shaForSide(side: SearchSide, range: { start: string; end: string }): string {
|
|
26
|
+
return side === "base" ? range.start : range.end;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A stackable file/results overlay with Back/Forward history. Hosts a frame
|
|
31
|
+
* stack (a starting diff or file frame, plus frames pushed by double-clicking a
|
|
32
|
+
* symbol inside a diff/file frame or by opening a hit from a results frame).
|
|
33
|
+
* Every visited frame stays mounted (hidden when not current) so returning to a
|
|
34
|
+
* file restores the scroll position rather than re-centering.
|
|
35
|
+
*
|
|
36
|
+
* Double-click routing: Usages/Search and multi-result Definition push a
|
|
37
|
+
* results frame; an *unscoped* single-result Definition and a results-hit "View
|
|
38
|
+
* file" push a file frame (a *scoped* single-result Definition stays a results
|
|
39
|
+
* frame so its scope chip + broaden control remain reachable). The navigator is
|
|
40
|
+
* the topmost overlay: it owns Esc (closes the whole navigator) and — via the
|
|
41
|
+
* results list — ↑/↓/Enter, without collapsing the rail behind it. An open
|
|
42
|
+
* symbol menu takes Esc precedence over it.
|
|
43
|
+
*
|
|
44
|
+
* @param props.prId - PR id everything runs against.
|
|
45
|
+
* @param props.origin - The frame to seed the history with.
|
|
46
|
+
* @param props.range - The current review range (base/head SHAs for diffs + lookups).
|
|
47
|
+
* @param props.hideWhitespace - When true, collapse whitespace-only changes in
|
|
48
|
+
* the split diff (mirrors the main diff's toggle).
|
|
49
|
+
* @param props.onClose - Close the whole navigator.
|
|
50
|
+
*/
|
|
51
|
+
export function FileNavigator(props: {
|
|
52
|
+
prId: string;
|
|
53
|
+
origin: NavFrame;
|
|
54
|
+
range: { start: string; end: string };
|
|
55
|
+
hideWhitespace?: boolean;
|
|
56
|
+
onClose: () => void;
|
|
57
|
+
}): React.JSX.Element {
|
|
58
|
+
const { prId, origin, range, hideWhitespace, onClose } = props;
|
|
59
|
+
const [state, dispatch] = useReducer(navStackReducer, origin, initNavStack);
|
|
60
|
+
const push = (frame: NavFrame): void => dispatch({ type: "push", frame });
|
|
61
|
+
const menu = useIdentifierMenu();
|
|
62
|
+
const lookup = useNavLookup(prId, push);
|
|
63
|
+
const [filters, setFilters] = useState<CodeResultFilters>({});
|
|
64
|
+
|
|
65
|
+
useOverlayEsc(menu.menu !== null, menu.close, onClose);
|
|
66
|
+
|
|
67
|
+
const frame: NavFrame = currentFrame(state);
|
|
68
|
+
const pick = (op: MenuOp, side: SearchSide): void => {
|
|
69
|
+
// Scope the lookup to the file being viewed. The diff/file frame's own path
|
|
70
|
+
// is the reliable scope hint here (navigator frames have no `.file-section`
|
|
71
|
+
// for the selection-based hint to read), so prefer it.
|
|
72
|
+
if (menu.menu) {
|
|
73
|
+
const file: string = frame.kind === "results" ? menu.menu.file : frame.path;
|
|
74
|
+
lookup.run({ op, term: menu.menu.term, side, file, sha: shaForSide(side, range) });
|
|
75
|
+
}
|
|
76
|
+
menu.close();
|
|
77
|
+
};
|
|
78
|
+
const openHit = (loc: OpenLocation): void => push({ kind: "file", path: loc.path, sha: loc.sha, line: loc.line });
|
|
79
|
+
// Re-run a scoped results frame's lookup repo-wide (the "search everywhere" chip action).
|
|
80
|
+
const broaden = (f: NavFrame): void => {
|
|
81
|
+
if (f.kind !== "results" || f.scopeFile === "") return;
|
|
82
|
+
lookup.run({ op: f.op, term: f.term, side: f.side, file: "", sha: f.sha });
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
<div className="modal-overlay" onClick={onClose}>
|
|
88
|
+
<div className="modal full-file file-navigator" onClick={(e) => e.stopPropagation()}>
|
|
89
|
+
<NavControls state={state} dispatch={dispatch} onClose={onClose} title={frameTitle(frame)} />
|
|
90
|
+
{lookup.loading && (
|
|
91
|
+
<div className="diff-loading"><span className="chat-spinner" aria-hidden="true" /> Looking up…</div>
|
|
92
|
+
)}
|
|
93
|
+
{lookup.error && <p className="notice results-error">{lookup.error}</p>}
|
|
94
|
+
{/* All visited frames stay mounted; only the current one is visible.
|
|
95
|
+
Keys combine the history index with the frame identity so going
|
|
96
|
+
Back/Forward reuses the same mounted instance (preserving scroll),
|
|
97
|
+
while pushing a new frame after going Back replaces the slot. */}
|
|
98
|
+
{state.stack.map((f, i) => (
|
|
99
|
+
<NavFrameBody
|
|
100
|
+
key={`${i}:${frameKey(f)}`}
|
|
101
|
+
visible={i === state.index}
|
|
102
|
+
prId={prId} frame={f} range={range} menu={menu}
|
|
103
|
+
filters={filters} onFilters={setFilters} onOpenHit={openHit}
|
|
104
|
+
onClose={onClose} onBroaden={() => broaden(f)}
|
|
105
|
+
hideWhitespace={hideWhitespace}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
{/* The menu portals to document.body but React re-dispatches its events
|
|
111
|
+
through this parent; keeping it a SIBLING of the overlay (not a child)
|
|
112
|
+
stops a menu click from bubbling into the overlay's close-on-click. */}
|
|
113
|
+
<IdentifierMenuPortal menu={menu.menu} onPick={pick} onClose={menu.close} />
|
|
114
|
+
</>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Back/Forward + breadcrumb title + close, along the navigator's top edge. */
|
|
119
|
+
function NavControls(props: {
|
|
120
|
+
state: NavStack;
|
|
121
|
+
dispatch: (a: NavStackAction) => void;
|
|
122
|
+
title: React.ReactNode;
|
|
123
|
+
onClose: () => void;
|
|
124
|
+
}): React.JSX.Element {
|
|
125
|
+
const { state, dispatch, title, onClose } = props;
|
|
126
|
+
return (
|
|
127
|
+
<header className="modal-header nav-header">
|
|
128
|
+
<span className="nav-nav">
|
|
129
|
+
<button type="button" className="btn btn-ghost btn-sm" disabled={!canGoBack(state)} onClick={() => dispatch({ type: "back" })} aria-label="Back"><ChevronLeftIcon size={16} /></button>
|
|
130
|
+
<button type="button" className="btn btn-ghost btn-sm" disabled={!canGoForward(state)} onClick={() => dispatch({ type: "forward" })} aria-label="Forward"><ChevronRightIcon size={16} /></button>
|
|
131
|
+
</span>
|
|
132
|
+
<span className="modal-title nav-title">{title}</span>
|
|
133
|
+
<button type="button" className="modal-close" onClick={onClose} aria-label="Close"><CloseIcon size={18} /></button>
|
|
134
|
+
</header>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Render the body for one frame, attaching the identifier menu to code frames.
|
|
140
|
+
* Hidden (rather than unmounted) when not the current frame so its query result
|
|
141
|
+
* and scroll position survive Back/Forward.
|
|
142
|
+
*/
|
|
143
|
+
function NavFrameBody(props: {
|
|
144
|
+
prId: string;
|
|
145
|
+
frame: NavFrame;
|
|
146
|
+
range: { start: string; end: string };
|
|
147
|
+
menu: ReturnType<typeof useIdentifierMenu>;
|
|
148
|
+
filters: CodeResultFilters;
|
|
149
|
+
onFilters: (f: CodeResultFilters) => void;
|
|
150
|
+
onOpenHit: (loc: OpenLocation) => void;
|
|
151
|
+
onClose: () => void;
|
|
152
|
+
onBroaden: () => void;
|
|
153
|
+
visible: boolean;
|
|
154
|
+
hideWhitespace?: boolean;
|
|
155
|
+
}): React.JSX.Element {
|
|
156
|
+
const { prId, frame, range, menu, filters, onFilters, onOpenHit, onClose, onBroaden, visible, hideWhitespace } = props;
|
|
157
|
+
const hidden: string = visible ? "" : " hidden";
|
|
158
|
+
if (frame.kind === "results") {
|
|
159
|
+
const header: ResultsHeader = { op: opLabel(frame.op), term: frame.term, scope: frame.side, scopeFile: frame.scopeFile };
|
|
160
|
+
return (
|
|
161
|
+
<div className={`nav-slot${hidden}`}>
|
|
162
|
+
<ResultsList
|
|
163
|
+
results={frame.results} loading={false} error={null}
|
|
164
|
+
filters={filters} onFilters={onFilters} sha={frame.sha}
|
|
165
|
+
header={header} onOpen={onOpenHit} onClose={onClose} onBroaden={onBroaden}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return (
|
|
171
|
+
<div className={`nav-code${hidden}`} onMouseUp={menu.onMouseUp} onDoubleClick={menu.onDoubleClick}>
|
|
172
|
+
{frame.kind === "diff"
|
|
173
|
+
? <DiffFrame prId={prId} path={frame.path} start={range.start} end={range.end} anchorLine={frame.anchorLine ?? null} hideWhitespace={hideWhitespace} />
|
|
174
|
+
: <FileFrame prId={prId} path={frame.path} sha={frame.sha} line={frame.line} side={frame.sha === range.start ? "base" : "head"} />}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Single topmost-overlay keyboard owner. On Escape: if the symbol menu is open,
|
|
181
|
+
* close it; otherwise close the navigator. Either way the event is stopped
|
|
182
|
+
* (capture-phase) so the rail behind never collapses on the same keypress.
|
|
183
|
+
*/
|
|
184
|
+
function useOverlayEsc(menuOpen: boolean, closeMenu: () => void, closeNav: () => void): void {
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
187
|
+
if (e.key !== "Escape") return;
|
|
188
|
+
e.stopPropagation();
|
|
189
|
+
if (menuOpen) closeMenu();
|
|
190
|
+
else closeNav();
|
|
191
|
+
};
|
|
192
|
+
window.addEventListener("keydown", onKey, true);
|
|
193
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
194
|
+
}, [menuOpen, closeMenu, closeNav]);
|
|
195
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CheckIcon, FileIcon, SearchIcon } from "./Icons.tsx";
|
|
2
|
+
import { fileStatusClass } from "@/web/lib/fileStatus.ts";
|
|
3
|
+
import type { FileView } from "@/daemon/reviewService.ts";
|
|
4
|
+
|
|
5
|
+
/** DOM id for a file's section in the main diff area (for scroll-to). */
|
|
6
|
+
export function fileSectionId(path: string): string {
|
|
7
|
+
return `file-${path}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Left-column file list with fuzzy search and viewed indicators. */
|
|
11
|
+
export function FileTree(props: {
|
|
12
|
+
files: FileView[];
|
|
13
|
+
query: string;
|
|
14
|
+
onQuery: (q: string) => void;
|
|
15
|
+
}): React.JSX.Element {
|
|
16
|
+
const { files, query, onQuery } = props;
|
|
17
|
+
return (
|
|
18
|
+
<nav className="file-tree">
|
|
19
|
+
<div className="file-search-wrap">
|
|
20
|
+
<SearchIcon size={14} />
|
|
21
|
+
<input
|
|
22
|
+
className="file-search"
|
|
23
|
+
type="search"
|
|
24
|
+
placeholder="Filter files…"
|
|
25
|
+
value={query}
|
|
26
|
+
onChange={(e) => onQuery(e.target.value)}
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
<ul>
|
|
30
|
+
{files.map((f) => (
|
|
31
|
+
<li key={f.newPath} className={f.viewed ? "file-item viewed" : "file-item"}>
|
|
32
|
+
<a href={`#${fileSectionId(f.newPath)}`} title={f.newPath}>
|
|
33
|
+
<span className="file-flag">{f.viewed && <CheckIcon size={13} />}</span>
|
|
34
|
+
<span className={`file-status-icon ${fileStatusClass(f.status)}`} title={f.status}>
|
|
35
|
+
<FileIcon size={13} />
|
|
36
|
+
</span>
|
|
37
|
+
<span className="file-name">{f.newPath}</span>
|
|
38
|
+
<span className="file-hunkcount">{f.hunks.length}</span>
|
|
39
|
+
</a>
|
|
40
|
+
</li>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
</nav>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { FileFrame } from "./FileFrame.tsx";
|
|
3
|
+
import { CloseIcon, FileIcon } from "./Icons.tsx";
|
|
4
|
+
|
|
5
|
+
/** A single-version, full-file view of a file at one commit, centered on a line. */
|
|
6
|
+
export interface FileTarget {
|
|
7
|
+
/** Repo-relative file path. */
|
|
8
|
+
path: string;
|
|
9
|
+
/** Commit SHA to read the file at. */
|
|
10
|
+
sha: string;
|
|
11
|
+
/** 1-based line to center and briefly highlight. */
|
|
12
|
+
line: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A modal showing the full text of a file at a single commit, centered on a
|
|
17
|
+
* line. A thin shell around {@link FileFrame}: the modal chrome plus a
|
|
18
|
+
* capture-phase Esc handler so closing this modal does not also collapse the
|
|
19
|
+
* rail behind it.
|
|
20
|
+
*
|
|
21
|
+
* @param props.prId - PR id the file belongs to.
|
|
22
|
+
* @param props.target - The file/sha/line to show.
|
|
23
|
+
* @param props.onClose - Called to dismiss the modal.
|
|
24
|
+
*/
|
|
25
|
+
export function FileView(props: { prId: string; target: FileTarget; onClose: () => void }): React.JSX.Element {
|
|
26
|
+
const { prId, target, onClose } = props;
|
|
27
|
+
const { path, sha, line } = target;
|
|
28
|
+
|
|
29
|
+
// Capture-phase + stopPropagation so Esc closes only this modal, not the
|
|
30
|
+
// rail sidebar behind it, on the same keypress.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
33
|
+
if (e.key !== "Escape") return;
|
|
34
|
+
e.stopPropagation();
|
|
35
|
+
onClose();
|
|
36
|
+
};
|
|
37
|
+
window.addEventListener("keydown", onKey, true);
|
|
38
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
39
|
+
}, [onClose]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="modal-overlay" onClick={onClose}>
|
|
43
|
+
<div className="modal full-file" onClick={(e) => e.stopPropagation()}>
|
|
44
|
+
<header className="modal-header">
|
|
45
|
+
<span className="modal-title"><FileIcon size={15} />{path}</span>
|
|
46
|
+
<span className="split-base-head">@ <code>{sha.slice(0, 7)}</code></span>
|
|
47
|
+
<button type="button" className="modal-close" onClick={onClose} aria-label="Close"><CloseIcon size={18} /></button>
|
|
48
|
+
</header>
|
|
49
|
+
<FileFrame prId={prId} path={path} sha={sha} line={line} side="head" />
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { CommentIcon } from "./Icons.tsx";
|
|
8
|
+
import { githubCommentDomId } from "@/web/lib/commentVisibility.ts";
|
|
9
|
+
import type { AnchoredGithubThread } from "@/daemon/reviewService.ts";
|
|
10
|
+
import type { ThreadComment } from "@/daemon/githubThreads.ts";
|
|
11
|
+
|
|
12
|
+
/** Render a single GitHub comment (author, time, GitHub link, markdown body). */
|
|
13
|
+
function GhComment(props: { comment: ThreadComment }): React.JSX.Element {
|
|
14
|
+
const { comment } = props;
|
|
15
|
+
return (
|
|
16
|
+
<div className="gh-comment">
|
|
17
|
+
<div className="comment-meta">
|
|
18
|
+
<strong>{comment.author || "unknown"}</strong>
|
|
19
|
+
{comment.createdAt !== null && (
|
|
20
|
+
<span className="comment-time">{formatCommitTime(new Date(comment.createdAt).toISOString())}</span>
|
|
21
|
+
)}
|
|
22
|
+
<a href={comment.htmlUrl} target="_blank" rel="noreferrer">on GitHub</a>
|
|
23
|
+
<span className="comment-tools"><CopyButton text={comment.body} /></span>
|
|
24
|
+
</div>
|
|
25
|
+
<div className="comment-body">
|
|
26
|
+
<Markdown remarkPlugins={[remarkGfm]}>{comment.body}</Markdown>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A synced GitHub inline thread with its replies and a reply composer. */
|
|
33
|
+
export function GithubThreadView(props: {
|
|
34
|
+
thread: AnchoredGithubThread;
|
|
35
|
+
onReply: (body: string) => void;
|
|
36
|
+
}): React.JSX.Element {
|
|
37
|
+
const { thread, onReply } = props;
|
|
38
|
+
const [replying, setReplying] = useState(false);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="gh-thread" id={githubCommentDomId(thread.root.githubId)}>
|
|
42
|
+
<div className="gh-thread-tag"><CommentIcon size={11} /> GitHub thread</div>
|
|
43
|
+
<GhComment comment={thread.root} />
|
|
44
|
+
{thread.replies.map((r) => <GhComment key={r.githubId} comment={r} />)}
|
|
45
|
+
{replying ? (
|
|
46
|
+
<CommentComposer
|
|
47
|
+
submitLabel="Reply"
|
|
48
|
+
onSubmit={(body) => { onReply(body); setReplying(false); }}
|
|
49
|
+
onCancel={() => setReplying(false)}
|
|
50
|
+
/>
|
|
51
|
+
) : (
|
|
52
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setReplying(true)}>Reply</button>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { DiffLines } from "./DiffLines.tsx";
|
|
3
|
+
import { CommentComposer } from "./CommentComposer.tsx";
|
|
4
|
+
import { CommentItem } from "./CommentItem.tsx";
|
|
5
|
+
import { Tooltip } from "./Tooltip.tsx";
|
|
6
|
+
import { ChevronDownIcon, ChevronRightIcon, SparkleIcon, EyeIcon, CommentPlusIcon } from "./Icons.tsx";
|
|
7
|
+
import type { HunkView } from "@/daemon/reviewService.ts";
|
|
8
|
+
import type { PostPreview, PostTarget } from "@/daemon/registry.ts";
|
|
9
|
+
import type { AddCommentArgs } from "../state/useReview.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A single hunk card: collapse toggle, viewed checkbox (auto-collapse on
|
|
13
|
+
* viewed), whole-hunk comments, and the diff lines with inline line comments.
|
|
14
|
+
*/
|
|
15
|
+
export function HunkCard(props: {
|
|
16
|
+
path: string;
|
|
17
|
+
hunk: HunkView;
|
|
18
|
+
endSha: string;
|
|
19
|
+
onToggleViewed: (viewed: boolean) => void;
|
|
20
|
+
addComment: (args: AddCommentArgs) => void;
|
|
21
|
+
editComment: (id: number, body: string) => void;
|
|
22
|
+
deleteComment: (id: number) => void;
|
|
23
|
+
onViewFile: (anchorLine: number) => void;
|
|
24
|
+
previewPost: (commentId: number, target: PostTarget) => Promise<PostPreview>;
|
|
25
|
+
postComment: (commentId: number, target: PostTarget) => void;
|
|
26
|
+
replyToThread: (rootGithubId: string, body: string) => void;
|
|
27
|
+
onAskAi: () => void;
|
|
28
|
+
/** When true, force the hunk expanded (e.g. jumped-to from the comments panel). */
|
|
29
|
+
revealed?: boolean;
|
|
30
|
+
}): React.JSX.Element {
|
|
31
|
+
const { path, hunk, endSha, onToggleViewed, addComment, editComment, deleteComment, onViewFile } = props;
|
|
32
|
+
const { previewPost, postComment, replyToThread, onAskAi, revealed } = props;
|
|
33
|
+
const [collapsed, setCollapsed] = useState<boolean>(hunk.viewed);
|
|
34
|
+
const [composingHunk, setComposingHunk] = useState(false);
|
|
35
|
+
useEffect(() => setCollapsed(hunk.viewed), [hunk.viewed]);
|
|
36
|
+
// Expand when this hunk is revealed (jumped to) so its comment is on screen.
|
|
37
|
+
useEffect(() => { if (revealed) setCollapsed(false); }, [revealed]);
|
|
38
|
+
|
|
39
|
+
const hunkComments = hunk.comments.filter((c) => c.lineIndex < 0);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<section className={hunk.viewed ? "hunk-card viewed" : "hunk-card"}>
|
|
43
|
+
<header className="hunk-header">
|
|
44
|
+
<button type="button" className="hunk-collapse" aria-label={collapsed ? "Expand hunk" : "Collapse hunk"} onClick={() => setCollapsed((c) => !c)}>
|
|
45
|
+
{collapsed ? <ChevronRightIcon size={14} /> : <ChevronDownIcon size={14} />}
|
|
46
|
+
</button>
|
|
47
|
+
<code className="hunk-title">{hunk.header}</code>
|
|
48
|
+
<div className="hunk-actions">
|
|
49
|
+
<HunkAction label="View file" onClick={() => onViewFile(hunk.newStart)}><EyeIcon size={14} /></HunkAction>
|
|
50
|
+
<HunkAction label="Ask AI" onClick={onAskAi}><SparkleIcon size={14} /></HunkAction>
|
|
51
|
+
<HunkAction label="Comment on hunk" onClick={() => setComposingHunk(true)}><CommentPlusIcon size={14} /></HunkAction>
|
|
52
|
+
</div>
|
|
53
|
+
<label className="hunk-viewed">
|
|
54
|
+
<input type="checkbox" checked={hunk.viewed} onChange={(e) => onToggleViewed(e.target.checked)} />
|
|
55
|
+
Viewed
|
|
56
|
+
</label>
|
|
57
|
+
</header>
|
|
58
|
+
{!collapsed && (
|
|
59
|
+
<>
|
|
60
|
+
{hunkComments.map((c) => (
|
|
61
|
+
<div key={c.id} className="hunk-level-comment">
|
|
62
|
+
<CommentItem
|
|
63
|
+
comment={c}
|
|
64
|
+
onEdit={(body) => editComment(c.id, body)}
|
|
65
|
+
onDelete={() => deleteComment(c.id)}
|
|
66
|
+
previewPost={(target) => previewPost(c.id, target)}
|
|
67
|
+
onPost={(target) => postComment(c.id, target)}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
{composingHunk && (
|
|
72
|
+
<div className="hunk-level-comment">
|
|
73
|
+
<CommentComposer
|
|
74
|
+
onSubmit={(body) => {
|
|
75
|
+
addComment({ kind: "hunk", side: "RIGHT", path, body, madeAtSha: endSha, hunkHash: hunk.hash });
|
|
76
|
+
setComposingHunk(false);
|
|
77
|
+
}}
|
|
78
|
+
onCancel={() => setComposingHunk(false)}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<DiffLines
|
|
83
|
+
lines={hunk.lines}
|
|
84
|
+
path={path}
|
|
85
|
+
comments={hunk.comments}
|
|
86
|
+
githubThreads={hunk.githubThreads}
|
|
87
|
+
endSha={endSha}
|
|
88
|
+
onAddComment={addComment}
|
|
89
|
+
onEditComment={editComment}
|
|
90
|
+
onDeleteComment={deleteComment}
|
|
91
|
+
onReplyToThread={replyToThread}
|
|
92
|
+
previewPost={previewPost}
|
|
93
|
+
onPostComment={postComment}
|
|
94
|
+
/>
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</section>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A hunk-header action rendered icon-only, with a near-instant tooltip (below
|
|
103
|
+
* the icon) revealing its label on hover and keyboard focus. The label is always
|
|
104
|
+
* present for assistive tech via aria-label.
|
|
105
|
+
*/
|
|
106
|
+
function HunkAction(props: {
|
|
107
|
+
/** Accessible name + the tooltip text. */
|
|
108
|
+
label: string;
|
|
109
|
+
/** Click handler for the action. */
|
|
110
|
+
onClick: () => void;
|
|
111
|
+
/** The action's icon. */
|
|
112
|
+
children: React.ReactNode;
|
|
113
|
+
}): React.JSX.Element {
|
|
114
|
+
return (
|
|
115
|
+
<Tooltip label={props.label} placement="bottom">
|
|
116
|
+
<button type="button" className="hunk-action" aria-label={props.label} onClick={props.onClick}>
|
|
117
|
+
{props.children}
|
|
118
|
+
</button>
|
|
119
|
+
</Tooltip>
|
|
120
|
+
);
|
|
121
|
+
}
|