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,181 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { trpc } from "../trpc.ts";
|
|
3
|
+
import type { ChatMessageRow, ChatScopeKind, ChatSessionRow } from "@/db/repositories/chatSessions.ts";
|
|
4
|
+
import type { ArtifactRow } from "@/db/repositories/artifacts.ts";
|
|
5
|
+
import type { ModelChoice } from "@/domain/config.ts";
|
|
6
|
+
|
|
7
|
+
/** A commit range accessor (the range currently in view). */
|
|
8
|
+
export type RangeAccessor = () => { start: string; end: string } | null;
|
|
9
|
+
|
|
10
|
+
/** What a chat session is scoped to. */
|
|
11
|
+
export interface ChatScope {
|
|
12
|
+
/** Hunk or file. */
|
|
13
|
+
kind: ChatScopeKind;
|
|
14
|
+
/** Hunk hash or file path. */
|
|
15
|
+
ref: string;
|
|
16
|
+
/** Human label for the panel header. */
|
|
17
|
+
label: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Chat state + actions for the AI chat panel. */
|
|
21
|
+
export interface ChatState {
|
|
22
|
+
scope: ChatScope | null;
|
|
23
|
+
open: (scope: ChatScope) => void;
|
|
24
|
+
close: () => void;
|
|
25
|
+
sessions: ChatSessionRow[];
|
|
26
|
+
activeId: number | null;
|
|
27
|
+
selectSession: (id: number) => void;
|
|
28
|
+
newSession: () => void;
|
|
29
|
+
messages: ChatMessageRow[];
|
|
30
|
+
models: ModelChoice[];
|
|
31
|
+
model: string;
|
|
32
|
+
setModel: (m: string) => void;
|
|
33
|
+
streaming: boolean;
|
|
34
|
+
streamText: string;
|
|
35
|
+
pending: string | null;
|
|
36
|
+
/** The agent's most recent activity note (e.g. "Reading src/app.ts"), or null. */
|
|
37
|
+
activity: string | null;
|
|
38
|
+
error: string | null;
|
|
39
|
+
send: (prompt: string) => void;
|
|
40
|
+
/** Artifacts generated under the range currently in view. */
|
|
41
|
+
artifacts: ArtifactRow[];
|
|
42
|
+
/** Build a URL to open an artifact file in a new tab. */
|
|
43
|
+
artifactUrl: (relPath: string) => string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The daemon's chat WebSocket URL, derived from the page origin. */
|
|
47
|
+
function chatWsUrl(): string {
|
|
48
|
+
const proto: string = window.location.protocol === "https:" ? "wss" : "ws";
|
|
49
|
+
return `${proto}://${window.location.host}/ws/chat`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A parsed chat stream event from the server. */
|
|
53
|
+
function parseEvent(raw: string): { type: string; text?: string; message?: string } | null {
|
|
54
|
+
try {
|
|
55
|
+
const v: unknown = JSON.parse(raw);
|
|
56
|
+
return typeof v === "object" && v !== null ? (v as { type: string; text?: string; message?: string }) : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Manage the AI chat panel for a PR: scope, sessions, the selected model, and
|
|
64
|
+
* streaming a turn over the chat WebSocket while persisting via the daemon.
|
|
65
|
+
*/
|
|
66
|
+
export function useChat(prId: string, getRange: RangeAccessor): ChatState {
|
|
67
|
+
const utils = trpc.useUtils();
|
|
68
|
+
const [scope, setScope] = useState<ChatScope | null>(null);
|
|
69
|
+
const [activeId, setActiveId] = useState<number | null>(null);
|
|
70
|
+
const [model, setModel] = useState<string>("");
|
|
71
|
+
const [streaming, setStreaming] = useState(false);
|
|
72
|
+
const [streamText, setStreamText] = useState("");
|
|
73
|
+
const [pending, setPending] = useState<string | null>(null);
|
|
74
|
+
const [activity, setActivity] = useState<string | null>(null);
|
|
75
|
+
const [error, setError] = useState<string | null>(null);
|
|
76
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
77
|
+
// Set when a turn completes; the next persisted-messages refetch hands off.
|
|
78
|
+
const awaitingPersistRef = useRef(false);
|
|
79
|
+
|
|
80
|
+
const config = trpc.config.useQuery({ id: prId });
|
|
81
|
+
const models: ModelChoice[] = useMemo(() => config.data?.models ?? [], [config.data]);
|
|
82
|
+
const sessionsQuery = trpc.listChatSessions.useQuery(
|
|
83
|
+
{ id: prId, scopeKind: scope?.kind, scopeRef: scope?.ref },
|
|
84
|
+
{ enabled: scope !== null },
|
|
85
|
+
);
|
|
86
|
+
const messagesQuery = trpc.listChatMessages.useQuery(
|
|
87
|
+
{ id: prId, sessionId: activeId ?? -1 },
|
|
88
|
+
{ enabled: activeId !== null },
|
|
89
|
+
);
|
|
90
|
+
const create = trpc.createChatSession.useMutation();
|
|
91
|
+
const range = getRange();
|
|
92
|
+
const artifactsQuery = trpc.listArtifacts.useQuery(
|
|
93
|
+
{ id: prId, start: range?.start, end: range?.end },
|
|
94
|
+
{ enabled: scope !== null },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Default the model to the first configured choice once loaded.
|
|
98
|
+
useEffect(() => { if (!model && models[0]) setModel(models[0].id); }, [models, model]);
|
|
99
|
+
|
|
100
|
+
// Tear down any open socket on unmount.
|
|
101
|
+
useEffect(() => () => wsRef.current?.close(), []);
|
|
102
|
+
|
|
103
|
+
const stream = (sessionId: number, prompt: string): void => {
|
|
104
|
+
setPending(prompt);
|
|
105
|
+
setStreamText("");
|
|
106
|
+
setActivity(null);
|
|
107
|
+
setStreaming(true);
|
|
108
|
+
setError(null);
|
|
109
|
+
const ws = new WebSocket(chatWsUrl());
|
|
110
|
+
wsRef.current = ws;
|
|
111
|
+
const current = getRange();
|
|
112
|
+
ws.onopen = () => ws.send(JSON.stringify({ id: prId, sessionId, prompt, range: current ?? undefined }));
|
|
113
|
+
ws.onmessage = (ev: MessageEvent) => {
|
|
114
|
+
const msg = parseEvent(String(ev.data));
|
|
115
|
+
if (!msg) return;
|
|
116
|
+
if (msg.type === "chunk") setStreamText((t) => t + (msg.text ?? ""));
|
|
117
|
+
else if (msg.type === "activity") setActivity(msg.text ?? null);
|
|
118
|
+
else if (msg.type === "done") { finish(true); ws.close(); }
|
|
119
|
+
else if (msg.type === "error") { setError(msg.message ?? "Chat failed."); finish(false); ws.close(); }
|
|
120
|
+
};
|
|
121
|
+
ws.onerror = () => { setError("Chat connection failed."); finish(false); };
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// End the turn's "working" state. On success, keep the streamed reply + prompt
|
|
125
|
+
// on screen and mark that we're awaiting the persisted copy, so the assistant
|
|
126
|
+
// bubble never blinks out before the refetch lands. On failure, clear now.
|
|
127
|
+
const finish = (persisted: boolean): void => {
|
|
128
|
+
setStreaming(false);
|
|
129
|
+
setActivity(null);
|
|
130
|
+
if (persisted) awaitingPersistRef.current = true;
|
|
131
|
+
else { setPending(null); setStreamText(""); }
|
|
132
|
+
void utils.listChatMessages.invalidate();
|
|
133
|
+
void utils.listChatSessions.invalidate();
|
|
134
|
+
void utils.listArtifacts.invalidate();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// When the persisted messages refetch lands after a completed turn, hand off
|
|
138
|
+
// from the transient stream state to the stored transcript (no flicker). Keyed
|
|
139
|
+
// only on the data change so a mid-turn refetch (e.g. a new session's first
|
|
140
|
+
// fetch) never clears an in-flight bubble.
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!awaitingPersistRef.current) return;
|
|
143
|
+
awaitingPersistRef.current = false;
|
|
144
|
+
setPending(null);
|
|
145
|
+
setStreamText("");
|
|
146
|
+
}, [messagesQuery.data]);
|
|
147
|
+
|
|
148
|
+
const send = async (prompt: string): Promise<void> => {
|
|
149
|
+
let sessionId: number | null = activeId;
|
|
150
|
+
if (sessionId === null) {
|
|
151
|
+
if (!scope || !model) return;
|
|
152
|
+
const res = await create.mutateAsync({ id: prId, scopeKind: scope.kind, scopeRef: scope.ref, model });
|
|
153
|
+
sessionId = res.sessionId;
|
|
154
|
+
setActiveId(sessionId);
|
|
155
|
+
await utils.listChatSessions.invalidate();
|
|
156
|
+
}
|
|
157
|
+
stream(sessionId, prompt);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
scope,
|
|
162
|
+
open: (s) => { setScope(s); setActiveId(null); },
|
|
163
|
+
close: () => { setScope(null); setActiveId(null); wsRef.current?.close(); },
|
|
164
|
+
sessions: sessionsQuery.data ?? [],
|
|
165
|
+
activeId,
|
|
166
|
+
selectSession: (id) => setActiveId(id),
|
|
167
|
+
newSession: () => setActiveId(null),
|
|
168
|
+
messages: messagesQuery.data ?? [],
|
|
169
|
+
models,
|
|
170
|
+
model,
|
|
171
|
+
setModel,
|
|
172
|
+
streaming,
|
|
173
|
+
streamText,
|
|
174
|
+
pending,
|
|
175
|
+
activity,
|
|
176
|
+
error,
|
|
177
|
+
send: (prompt) => void send(prompt),
|
|
178
|
+
artifacts: artifactsQuery.data ?? [],
|
|
179
|
+
artifactUrl: (relPath) => `/artifact?id=${encodeURIComponent(prId)}&path=${encodeURIComponent(relPath)}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { trpc } from "../trpc.ts";
|
|
3
|
+
import { nextToken, isCurrent } from "@/web/lib/searchToken.ts";
|
|
4
|
+
import { fetchResults, errorMessage, type RunParams } from "@/web/lib/codeSearchFetch.ts";
|
|
5
|
+
import { searchInputsKey } from "@/web/lib/searchInputsKey.ts";
|
|
6
|
+
import type { CodeResult } from "@/services/symbols.ts";
|
|
7
|
+
import type { CodeResultFilters } from "@/web/lib/filterCodeResults.ts";
|
|
8
|
+
|
|
9
|
+
/** Search mode: a free-text search vs a semantic symbol lookup. */
|
|
10
|
+
export type SearchMode = "general" | "symbol";
|
|
11
|
+
|
|
12
|
+
/** Which symbol lookup to run in symbol mode. */
|
|
13
|
+
export type SymbolAction = "definition" | "usages";
|
|
14
|
+
|
|
15
|
+
/** Which checkout to run against: the range's head (end) or base (start). */
|
|
16
|
+
export type SearchSide = "head" | "base";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A snapshot of the inputs a run used. Drives the results header (which stays
|
|
20
|
+
* frozen on the last run rather than tracking the live controls) and the
|
|
21
|
+
* "broaden to the whole repo" action.
|
|
22
|
+
*/
|
|
23
|
+
export interface RunSnapshot {
|
|
24
|
+
/** Mode the run used. */
|
|
25
|
+
mode: SearchMode;
|
|
26
|
+
/** Symbol lookup the run used (relevant in symbol mode). */
|
|
27
|
+
symbolAction: SymbolAction;
|
|
28
|
+
/** The term/query the run used. */
|
|
29
|
+
term: string;
|
|
30
|
+
/** The checkout side the run targeted. */
|
|
31
|
+
side: SearchSide;
|
|
32
|
+
/** The file the run was scoped to ("" = repo-wide). */
|
|
33
|
+
scopeFile: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** SHA getters for the two ends of the current review range. */
|
|
37
|
+
export interface ShaGetters {
|
|
38
|
+
/** Current head (end) SHA of the range, or "" when unset. */
|
|
39
|
+
headSha: () => string;
|
|
40
|
+
/** Current base (start) SHA of the range, or "" when unset. */
|
|
41
|
+
baseSha: () => string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The full code-search rail state plus its actions. */
|
|
45
|
+
export interface CodeSearchState {
|
|
46
|
+
/** General text search vs semantic symbol lookup. */
|
|
47
|
+
mode: SearchMode;
|
|
48
|
+
/** The query / symbol term in the input. */
|
|
49
|
+
query: string;
|
|
50
|
+
/** General mode: case-sensitive matching. */
|
|
51
|
+
caseSensitive: boolean;
|
|
52
|
+
/** General mode: treat the query as a regex. */
|
|
53
|
+
regex: boolean;
|
|
54
|
+
/** Symbol mode: which lookup to run. */
|
|
55
|
+
symbolAction: SymbolAction;
|
|
56
|
+
/** Which checkout the next/last run targets. */
|
|
57
|
+
side: SearchSide;
|
|
58
|
+
/** The SHA the last completed run resolved to (for opening file views). */
|
|
59
|
+
resultSha: string;
|
|
60
|
+
/** The last run's results (deduping is done in the view). */
|
|
61
|
+
results: CodeResult[];
|
|
62
|
+
/** True while a lookup is running. */
|
|
63
|
+
loading: boolean;
|
|
64
|
+
/** A user-facing error from the last run (e.g. bad regex), or null. */
|
|
65
|
+
error: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* Whether the current inputs differ from the last run (so the shown results
|
|
68
|
+
* are stale and "Run" should be pressed). False before the first run when the
|
|
69
|
+
* query is empty.
|
|
70
|
+
*/
|
|
71
|
+
dirty: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* The inputs the last run used, or null before the first run. The results
|
|
74
|
+
* header reads this (not the live controls) so it only changes on Run.
|
|
75
|
+
*/
|
|
76
|
+
lastRun: RunSnapshot | null;
|
|
77
|
+
/** The active client-side result filters. */
|
|
78
|
+
filters: CodeResultFilters;
|
|
79
|
+
/** Update the query text. */
|
|
80
|
+
setQuery: (q: string) => void;
|
|
81
|
+
/** Switch search mode. */
|
|
82
|
+
setMode: (m: SearchMode) => void;
|
|
83
|
+
/** Toggle case-sensitive (general mode). */
|
|
84
|
+
setCaseSensitive: (v: boolean) => void;
|
|
85
|
+
/** Toggle regex (general mode). */
|
|
86
|
+
setRegex: (v: boolean) => void;
|
|
87
|
+
/** Switch the symbol lookup (symbol mode). */
|
|
88
|
+
setSymbolAction: (a: SymbolAction) => void;
|
|
89
|
+
/** Switch the target checkout. */
|
|
90
|
+
setSide: (s: SearchSide) => void;
|
|
91
|
+
/** Replace the active filters. */
|
|
92
|
+
setFilters: (f: CodeResultFilters) => void;
|
|
93
|
+
/** Run the current query as configured by the rail controls. */
|
|
94
|
+
runFromRail: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* Run a lookup initiated from the diff double-click menu: sets the state to
|
|
97
|
+
* match the picked action/term/side (+ file scope hint) and runs it.
|
|
98
|
+
*/
|
|
99
|
+
runFromMenu: (op: MenuOp, term: string, side: SearchSide, file: string) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Re-run the last (file-scoped) lookup across the whole repo, dropping the
|
|
102
|
+
* file scope. A no-op when the last run was already repo-wide.
|
|
103
|
+
*/
|
|
104
|
+
broaden: () => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The operation a double-click menu can pick. */
|
|
108
|
+
export type MenuOp = "definition" | "usages" | "search";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The code-search rail state: holds the query + mode + toggles + filters, runs
|
|
112
|
+
* general (rg) searches and symbol (sem) definition/usages lookups against the
|
|
113
|
+
* chosen checkout, and surfaces loading/error/results. A monotonic request
|
|
114
|
+
* token drops results from superseded runs (last-wins) so overlapping async
|
|
115
|
+
* work — including StrictMode's double-fire — never shows stale results.
|
|
116
|
+
*
|
|
117
|
+
* @param prId - The PR id lookups run against.
|
|
118
|
+
* @param shas - Getters for the current head/base SHAs of the review range.
|
|
119
|
+
*/
|
|
120
|
+
export function useCodeSearch(prId: string, shas: ShaGetters): CodeSearchState {
|
|
121
|
+
const utils = trpc.useUtils();
|
|
122
|
+
const token = useRef(0);
|
|
123
|
+
const [mode, setMode] = useState<SearchMode>("general");
|
|
124
|
+
const [query, setQuery] = useState("");
|
|
125
|
+
const [caseSensitive, setCaseSensitive] = useState(false);
|
|
126
|
+
const [regex, setRegex] = useState(false);
|
|
127
|
+
const [symbolAction, setSymbolAction] = useState<SymbolAction>("definition");
|
|
128
|
+
const [side, setSide] = useState<SearchSide>("head");
|
|
129
|
+
const [resultSha, setResultSha] = useState("");
|
|
130
|
+
const [results, setResults] = useState<CodeResult[]>([]);
|
|
131
|
+
const [loading, setLoading] = useState(false);
|
|
132
|
+
const [error, setError] = useState<string | null>(null);
|
|
133
|
+
const [filters, setFilters] = useState<CodeResultFilters>({});
|
|
134
|
+
// Key of the inputs the last run used, so the view can flag stale results.
|
|
135
|
+
const [lastRunKey, setLastRunKey] = useState<string | null>(null);
|
|
136
|
+
// The inputs the last run used — the header reads this so it only changes on Run.
|
|
137
|
+
const [lastRun, setLastRun] = useState<RunSnapshot | null>(null);
|
|
138
|
+
|
|
139
|
+
const run = async (p: RunParams): Promise<void> => {
|
|
140
|
+
const id = nextToken(token.current);
|
|
141
|
+
token.current = id;
|
|
142
|
+
const sha: string = p.side === "base" ? shas.baseSha() : shas.headSha();
|
|
143
|
+
setLastRunKey(searchInputsKey({
|
|
144
|
+
mode: p.mode, query: p.term, caseSensitive: p.caseSensitive,
|
|
145
|
+
regex: p.regex, symbolAction: p.symbolAction, side: p.side,
|
|
146
|
+
}));
|
|
147
|
+
setLastRun({ mode: p.mode, symbolAction: p.symbolAction, term: p.term, side: p.side, scopeFile: p.file ?? "" });
|
|
148
|
+
setLoading(true);
|
|
149
|
+
setError(null);
|
|
150
|
+
if (sha === "" || p.term === "") {
|
|
151
|
+
if (isCurrent(id, token.current)) { setLoading(false); setResults([]); }
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const out = await fetchResults(utils, prId, sha, p);
|
|
156
|
+
if (isCurrent(id, token.current)) { setResults(out); setResultSha(sha); setLoading(false); }
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (isCurrent(id, token.current)) { setResults([]); setError(errorMessage(err)); setLoading(false); }
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const runFromRail = (): void => {
|
|
163
|
+
void run({ mode, symbolAction, term: query.trim(), side, caseSensitive, regex });
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const currentKey: string = searchInputsKey({ mode, query, caseSensitive, regex, symbolAction, side });
|
|
167
|
+
const dirty: boolean = query.trim() !== "" && currentKey !== lastRunKey;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
mode, query, caseSensitive, regex, symbolAction, side, resultSha, results, loading, error, dirty, lastRun, filters,
|
|
171
|
+
setQuery, setMode, setCaseSensitive, setRegex, setSymbolAction, setSide, setFilters,
|
|
172
|
+
runFromRail,
|
|
173
|
+
broaden: () => {
|
|
174
|
+
if (!lastRun || lastRun.scopeFile === "") return;
|
|
175
|
+
void run({
|
|
176
|
+
mode: lastRun.mode, symbolAction: lastRun.symbolAction, term: lastRun.term,
|
|
177
|
+
side: lastRun.side, caseSensitive, regex, file: undefined,
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
runFromMenu: (op, term, side_, file) => {
|
|
181
|
+
const nextMode: SearchMode = op === "search" ? "general" : "symbol";
|
|
182
|
+
const nextAction: SymbolAction = op === "usages" ? "usages" : "definition";
|
|
183
|
+
setMode(nextMode);
|
|
184
|
+
setSide(side_);
|
|
185
|
+
setQuery(term);
|
|
186
|
+
if (op !== "search") setSymbolAction(nextAction);
|
|
187
|
+
void run({
|
|
188
|
+
mode: nextMode,
|
|
189
|
+
symbolAction: nextAction,
|
|
190
|
+
term,
|
|
191
|
+
side: side_,
|
|
192
|
+
caseSensitive,
|
|
193
|
+
regex,
|
|
194
|
+
file: file === "" ? undefined : file,
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { trpc } from "../trpc.ts";
|
|
3
|
+
import type { CommitInfo } from "@/services/git.ts";
|
|
4
|
+
import type { FileView } from "@/daemon/reviewService.ts";
|
|
5
|
+
import type { ReviewedRangeRow } from "@/db/repositories/reviewedRanges.ts";
|
|
6
|
+
import type { PostPreview, PostTarget } from "@/daemon/registry.ts";
|
|
7
|
+
|
|
8
|
+
/** A selected commit range. */
|
|
9
|
+
export interface RangeSel {
|
|
10
|
+
/** Baseline (excluded) SHA. */
|
|
11
|
+
start: string;
|
|
12
|
+
/** End (inclusive) SHA. */
|
|
13
|
+
end: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Arguments for creating a comment (PR id is injected by the hook). */
|
|
17
|
+
export interface AddCommentArgs {
|
|
18
|
+
/** Line range or whole hunk. */
|
|
19
|
+
kind: "lines" | "hunk";
|
|
20
|
+
/** Diff side. */
|
|
21
|
+
side: "LEFT" | "RIGHT";
|
|
22
|
+
/** File path. */
|
|
23
|
+
path: string;
|
|
24
|
+
/** Markdown body. */
|
|
25
|
+
body: string;
|
|
26
|
+
/** End commit of the current range. */
|
|
27
|
+
madeAtSha: string;
|
|
28
|
+
/** Exact line text (for line comments). */
|
|
29
|
+
lineText?: string;
|
|
30
|
+
/** First side line number (for line comments). */
|
|
31
|
+
lineNo?: number;
|
|
32
|
+
/** Last side line number (for multi-line comments). */
|
|
33
|
+
endLineNo?: number;
|
|
34
|
+
/** Hunk content hash (for hunk comments). */
|
|
35
|
+
hunkHash?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Everything the review UI needs for one PR. */
|
|
39
|
+
export interface ReviewState {
|
|
40
|
+
baselineSha: string | null;
|
|
41
|
+
commits: CommitInfo[];
|
|
42
|
+
range: RangeSel | null;
|
|
43
|
+
setRange: (r: RangeSel) => void;
|
|
44
|
+
files: FileView[];
|
|
45
|
+
loading: boolean;
|
|
46
|
+
reviewedRanges: ReviewedRangeRow[];
|
|
47
|
+
toggleHunkViewed: (hunkHash: string, viewed: boolean) => void;
|
|
48
|
+
markReviewed: () => void;
|
|
49
|
+
/** Un-mark the currently selected range as reviewed. */
|
|
50
|
+
unmarkReviewed: () => void;
|
|
51
|
+
addComment: (args: AddCommentArgs) => void;
|
|
52
|
+
editComment: (commentId: number, body: string) => void;
|
|
53
|
+
deleteComment: (commentId: number) => void;
|
|
54
|
+
/** Preview where a comment would post (line + warning) without posting. */
|
|
55
|
+
previewPost: (commentId: number, target: PostTarget) => Promise<PostPreview>;
|
|
56
|
+
/** Post a comment to GitHub at the chosen target. */
|
|
57
|
+
postComment: (commentId: number, target: PostTarget) => void;
|
|
58
|
+
/** Re-fetch the PR from GitHub (new commits / head movement). */
|
|
59
|
+
refreshPr: () => void;
|
|
60
|
+
/** True while a PR refresh is in flight. */
|
|
61
|
+
refreshing: boolean;
|
|
62
|
+
/** Reply to a synced GitHub thread by its root comment id. */
|
|
63
|
+
replyToThread: (rootGithubId: string, body: string) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load and manage review state for a PR: commit topology, the selected range
|
|
68
|
+
* (defaulting to last-reviewed→head), the range's file/hunk view, and the
|
|
69
|
+
* mutations for viewed state and reviewed ranges.
|
|
70
|
+
*/
|
|
71
|
+
export function useReview(prId: string, initialRange?: RangeSel | null, hideWhitespace = false): ReviewState {
|
|
72
|
+
const utils = trpc.useUtils();
|
|
73
|
+
const topo = trpc.commitsWithBaseline.useQuery({ id: prId });
|
|
74
|
+
const def = trpc.defaultRange.useQuery({ id: prId });
|
|
75
|
+
const [override, setOverride] = useState<RangeSel | null>(initialRange ?? null);
|
|
76
|
+
|
|
77
|
+
const range: RangeSel | null =
|
|
78
|
+
override ?? (def.data ? { start: def.data.startSha, end: def.data.endSha } : null);
|
|
79
|
+
|
|
80
|
+
// `ignoreWhitespace` is part of the query key, so toggling it re-fetches the
|
|
81
|
+
// diff (git re-diffs with `--ignore-all-space`), collapsing whitespace-only
|
|
82
|
+
// hunks. Viewed-progress is keyed to each mode's hunk content, so it does not
|
|
83
|
+
// carry between the two modes — intentional, and lossless on round-trip.
|
|
84
|
+
const view = trpc.rangeView.useQuery(
|
|
85
|
+
{ id: prId, start: range?.start ?? "", end: range?.end ?? "", ignoreWhitespace: hideWhitespace },
|
|
86
|
+
{ enabled: range !== null },
|
|
87
|
+
);
|
|
88
|
+
const reviewed = trpc.listReviewedRanges.useQuery({ id: prId });
|
|
89
|
+
const setViewed = trpc.setHunkViewed.useMutation({ onSuccess: () => utils.rangeView.invalidate() });
|
|
90
|
+
const addReviewed = trpc.addReviewedRange.useMutation({ onSuccess: () => utils.listReviewedRanges.invalidate() });
|
|
91
|
+
const removeReviewed = trpc.removeReviewedRange.useMutation({ onSuccess: () => utils.listReviewedRanges.invalidate() });
|
|
92
|
+
const invalidateView = (): void => {
|
|
93
|
+
void utils.rangeView.invalidate();
|
|
94
|
+
void utils.listComments.invalidate();
|
|
95
|
+
// The All-comments side panel shares the screen now, so keep it in sync
|
|
96
|
+
// when comments change from the diff (add / edit / delete / post / sync).
|
|
97
|
+
void utils.listAllComments.invalidate();
|
|
98
|
+
};
|
|
99
|
+
const addC = trpc.addComment.useMutation({ onSuccess: invalidateView });
|
|
100
|
+
const editC = trpc.editComment.useMutation({ onSuccess: invalidateView });
|
|
101
|
+
const delC = trpc.deleteComment.useMutation({ onSuccess: invalidateView });
|
|
102
|
+
const postC = trpc.postComment.useMutation({ onSuccess: invalidateView });
|
|
103
|
+
const reply = trpc.replyToThread.useMutation({ onSuccess: invalidateView });
|
|
104
|
+
const refresh = trpc.refreshPr.useMutation({
|
|
105
|
+
onSuccess: () => {
|
|
106
|
+
void utils.commitsWithBaseline.invalidate();
|
|
107
|
+
void utils.defaultRange.invalidate();
|
|
108
|
+
void utils.prCommits.invalidate();
|
|
109
|
+
void utils.health.invalidate();
|
|
110
|
+
invalidateView();
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
baselineSha: topo.data?.baselineSha ?? null,
|
|
116
|
+
commits: topo.data?.commits ?? [],
|
|
117
|
+
range,
|
|
118
|
+
setRange: setOverride,
|
|
119
|
+
files: view.data ?? [],
|
|
120
|
+
loading: topo.isLoading || def.isLoading || view.isLoading,
|
|
121
|
+
reviewedRanges: reviewed.data ?? [],
|
|
122
|
+
toggleHunkViewed: (hunkHash, viewed) => setViewed.mutate({ id: prId, hunkHash, viewed }),
|
|
123
|
+
markReviewed: () => {
|
|
124
|
+
if (range) addReviewed.mutate({ id: prId, start: range.start, end: range.end });
|
|
125
|
+
},
|
|
126
|
+
unmarkReviewed: () => {
|
|
127
|
+
if (range) removeReviewed.mutate({ id: prId, start: range.start, end: range.end });
|
|
128
|
+
},
|
|
129
|
+
addComment: (args) => addC.mutate({ id: prId, ...args }),
|
|
130
|
+
editComment: (commentId, body) => editC.mutate({ id: prId, commentId, body }),
|
|
131
|
+
deleteComment: (commentId) => delC.mutate({ id: prId, commentId }),
|
|
132
|
+
previewPost: (commentId, target) => utils.postCommentPreview.fetch({ id: prId, commentId, target }),
|
|
133
|
+
postComment: (commentId, target) => postC.mutate({ id: prId, commentId, target }),
|
|
134
|
+
replyToThread: (rootGithubId, body) => reply.mutate({ id: prId, rootGithubId, body }),
|
|
135
|
+
refreshPr: () => refresh.mutate({ id: prId }),
|
|
136
|
+
refreshing: refresh.isPending,
|
|
137
|
+
};
|
|
138
|
+
}
|