@sealant/mend 0.22.0 → 0.23.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/README.md +133 -6
- package/dist/chunk-YPM6TYQR.js +27888 -0
- package/dist/dashboard-LR5LHV7O.js +2951 -0
- package/dist/main.js +6917 -2580
- package/man/mend-accounts.1 +1 -1
- package/man/mend-adopt.1 +4 -4
- package/man/mend-attach.1 +1 -1
- package/man/mend-codex.1 +1 -1
- package/man/mend-completions.1 +1 -1
- package/man/mend-connect.1 +1 -1
- package/man/mend-continue.1 +1 -1
- package/man/mend-doctor.1 +1 -1
- package/man/mend-dotfiles-sync.1 +1 -1
- package/man/mend-dotfiles.1 +1 -1
- package/man/mend-env-cluster.1 +1 -1
- package/man/mend-env-load.1 +1 -1
- package/man/mend-env-show.1 +1 -1
- package/man/mend-help.1 +1 -1
- package/man/mend-keys-init.1 +1 -1
- package/man/mend-keys-mode.1 +1 -1
- package/man/mend-keys-share.1 +1 -1
- package/man/mend-keys-show.1 +1 -1
- package/man/mend-login.1 +1 -1
- package/man/mend-logout.1 +1 -1
- package/man/mend-man.1 +1 -1
- package/man/mend-pair.1 +1 -1
- package/man/mend-projects.1 +1 -1
- package/man/mend-refresh.1 +1 -1
- package/man/mend-rejoin.1 +1 -1
- package/man/mend-resume.1 +1 -1
- package/man/mend-run.1 +1 -1
- package/man/mend-server-logs.1 +15 -0
- package/man/mend-server-restart.1 +15 -0
- package/man/mend-server-setup.1 +64 -0
- package/man/mend-server-start.1 +15 -0
- package/man/mend-server-status.1 +11 -0
- package/man/mend-server-stop.1 +11 -0
- package/man/mend-server-upgrade.1 +30 -0
- package/man/mend-server.1 +11 -0
- package/man/mend-service-add.1 +1 -1
- package/man/mend-service-connect.1 +1 -1
- package/man/mend-service-init.1 +1 -1
- package/man/mend-service-list.1 +1 -1
- package/man/mend-service-logs.1 +1 -1
- package/man/mend-service-restart.1 +1 -1
- package/man/mend-service-run.1 +1 -1
- package/man/mend-service-stop.1 +1 -1
- package/man/mend-sessions.1 +1 -1
- package/man/mend-shell.1 +1 -1
- package/man/mend-skills-push.1 +1 -1
- package/man/mend-skills.1 +1 -1
- package/man/mend-ssh-setup.1 +6 -3
- package/man/mend-ssh.1 +1 -1
- package/man/mend-stop.1 +1 -1
- package/man/mend-ui.1 +1 -1
- package/man/mend-version.1 +1 -1
- package/man/mend-worktrees.1 +1 -1
- package/man/mend.1 +26 -2
- package/package.json +6 -2
- package/dist/agent-share.js +0 -150
- package/dist/clipboard.js +0 -73
- package/dist/dashboard-model.js +0 -308
- package/dist/dashboard.js +0 -1200
- package/dist/doctor.js +0 -233
- package/dist/dotfiles.js +0 -90
- package/dist/env.js +0 -17
- package/dist/help.js +0 -839
- package/dist/login.js +0 -240
- package/dist/pair.js +0 -83
- package/dist/review-model.js +0 -212
- package/dist/review-workflow.js +0 -8
- package/dist/review.js +0 -690
- package/dist/service-init.js +0 -227
- package/dist/shared.js +0 -338
- package/dist/skills.js +0 -134
- package/dist/ssh-setup.js +0 -181
- package/dist/terminal.js +0 -8
- package/dist/tui-theme.js +0 -41
- package/dist/version.js +0 -48
- package/dist/workbench-events.js +0 -72
package/dist/review.js
DELETED
|
@@ -1,690 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
|
|
2
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { RGBA, SyntaxStyle, } from "@opentui/core";
|
|
4
|
-
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
5
|
-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
-
import { assembleReviewInstruction, buildReviewFiles, visibleWhitespace, } from "./review-model.js";
|
|
8
|
-
import { commentRange, deliverReview } from "./review-workflow.js";
|
|
9
|
-
import { isPendingId, pendingId } from "./shared.js";
|
|
10
|
-
import { openUrl } from "./terminal.js";
|
|
11
|
-
import { ADD_WASH, COBALT, DELETE_WASH, FAINT, GREEN, INK, INK_2, MUTED, RED, RULE, SYNTAX_FUNCTION, SYNTAX_KEYWORD, SYNTAX_NUMBER, SYNTAX_STRING, WASH, } from "./tui-theme.js";
|
|
12
|
-
const REVIEW_KEY = (changeId) => ["review", changeId];
|
|
13
|
-
const EDITOR_KEY_BINDINGS = [{ name: "return", ctrl: true, action: "submit" }];
|
|
14
|
-
const syntaxStyle = SyntaxStyle.fromStyles({
|
|
15
|
-
keyword: { fg: RGBA.fromHex(SYNTAX_KEYWORD) },
|
|
16
|
-
string: { fg: RGBA.fromHex(SYNTAX_STRING) },
|
|
17
|
-
comment: { fg: RGBA.fromHex(FAINT), italic: true },
|
|
18
|
-
number: { fg: RGBA.fromHex(SYNTAX_NUMBER) },
|
|
19
|
-
function: { fg: RGBA.fromHex(SYNTAX_FUNCTION) },
|
|
20
|
-
variable: { fg: RGBA.fromHex(INK) },
|
|
21
|
-
default: { fg: RGBA.fromHex(INK) },
|
|
22
|
-
});
|
|
23
|
-
const truncate = (text, width) => {
|
|
24
|
-
const oneLine = text.replaceAll(/\s+/g, " ").trim();
|
|
25
|
-
if (width <= 1)
|
|
26
|
-
return "";
|
|
27
|
-
return oneLine.length <= width ? oneLine : `${oneLine.slice(0, width - 1)}…`;
|
|
28
|
-
};
|
|
29
|
-
const fetchReview = async (ctx, changeId) => {
|
|
30
|
-
const opened = await ctx.api("POST", `/changes/${changeId}/reviews/open`, {
|
|
31
|
-
idempotencyKey: `cli-review:${changeId}`,
|
|
32
|
-
});
|
|
33
|
-
const pinned = await ctx.api("GET", `/changes/${changeId}/reviews/${opened.slice.id}/diff`);
|
|
34
|
-
const wire = {
|
|
35
|
-
change: pinned.change,
|
|
36
|
-
diff: pinned.patch,
|
|
37
|
-
files: pinned.files.map((file) => ({
|
|
38
|
-
path: file.newPath ?? file.oldPath ?? "unknown path",
|
|
39
|
-
additions: file.additions,
|
|
40
|
-
deletions: file.deletions,
|
|
41
|
-
})),
|
|
42
|
-
};
|
|
43
|
-
const [comments, tour, passes, followUp, sessionDetail] = await Promise.all([
|
|
44
|
-
ctx.api("GET", `/changes/${changeId}/comments`),
|
|
45
|
-
ctx.api("GET", `/changes/${changeId}/tour`),
|
|
46
|
-
ctx.api("GET", `/changes/${changeId}/passes`),
|
|
47
|
-
ctx.api("GET", `/sessions/${wire.change.sessionId}/follow-up`),
|
|
48
|
-
ctx.api("GET", `/sessions/${wire.change.sessionId}`),
|
|
49
|
-
]);
|
|
50
|
-
return {
|
|
51
|
-
wire,
|
|
52
|
-
sliceId: pinned.slice.id,
|
|
53
|
-
checkpointAId: pinned.checkpointA.id,
|
|
54
|
-
checkpointBId: pinned.checkpointB.id,
|
|
55
|
-
diffDigest: pinned.slice.diffDigest,
|
|
56
|
-
anchorFiles: pinned.anchorFiles,
|
|
57
|
-
files: buildReviewFiles(wire.diff, wire.files),
|
|
58
|
-
comments,
|
|
59
|
-
tour,
|
|
60
|
-
passes,
|
|
61
|
-
followUp,
|
|
62
|
-
sessionStatus: sessionDetail.session.status,
|
|
63
|
-
};
|
|
64
|
-
};
|
|
65
|
-
const commentPriority = (comment) => comment.state === "draft" ? 0 : comment.state === "open" ? 1 : 2;
|
|
66
|
-
const orderedComments = (comments) => comments.toSorted((a, b) => {
|
|
67
|
-
const byState = commentPriority(a) - commentPriority(b);
|
|
68
|
-
return byState === 0 ? b.createdAt.localeCompare(a.createdAt) : byState;
|
|
69
|
-
});
|
|
70
|
-
const stateColor = (state) => state === "draft" || state === "open" ? INK : state === "addressed" ? INK_2 : FAINT;
|
|
71
|
-
const anchorOf = (comment) => comment.file === null
|
|
72
|
-
? "change"
|
|
73
|
-
: `${comment.file}${comment.line === null ? "" : `:${comment.line}${comment.endLine === null || comment.endLine === comment.line ? "" : `-${comment.endLine}`}`}`;
|
|
74
|
-
const passFact = (pass) => {
|
|
75
|
-
const label = pass.kind === "suggest" ? "suggestions" : pass.kind;
|
|
76
|
-
if (pass.status === "running")
|
|
77
|
-
return { text: `${label} running`, color: INK_2 };
|
|
78
|
-
if (pass.status === "failed")
|
|
79
|
-
return { text: `${label} failed`, color: RED };
|
|
80
|
-
return {
|
|
81
|
-
text: `${label} observed · ${pass.findings ?? 0} ${pass.kind === "tour" ? "stops" : "drafts"}`,
|
|
82
|
-
color: MUTED,
|
|
83
|
-
};
|
|
84
|
-
};
|
|
85
|
-
const FileRow = ({ file, selected }) => (_jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }), _jsx("span", { fg: INK, children: file.path }), file.binary ? _jsx("span", { fg: MUTED, children: " \u00B7 binary" }) : null, file.likelyGenerated ? _jsx("span", { fg: FAINT, children: " \u00B7 inferred \u00B7 likely generated" }) : null] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsxs("span", { fg: GREEN, children: ["+", file.additions] }), _jsx("span", { fg: FAINT, children: " " }), _jsxs("span", { fg: RED, children: ["\u2212", file.deletions] }), _jsxs("span", { fg: FAINT, children: [" ", "\u00B7 ", file.status, " \u00B7 ", file.hunks.length, " hunks"] })] })] }));
|
|
86
|
-
const CommentRow = ({ comment, selected, }) => (_jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }), _jsx("span", { fg: stateColor(comment.state), children: comment.state }), _jsxs("span", { fg: FAINT, children: [" \u00B7 ", anchorOf(comment)] })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsx("span", { fg: MUTED, children: comment.authorKind === "mend" ? "Mend · " : "You · " }), _jsx("span", { fg: INK, children: truncate(comment.body, 27) })] })] }));
|
|
87
|
-
const Description = ({ data, tourIndex, stale, width, height, }) => {
|
|
88
|
-
const passFacts = data.passes.map(passFact);
|
|
89
|
-
const tourStop = data.tour?.stops[tourIndex] ?? null;
|
|
90
|
-
const stopEvidence = tourStop?.evidence[0] ?? null;
|
|
91
|
-
return (_jsxs("box", { height: height, border: true, borderStyle: "rounded", borderColor: RULE, title: " change description ", titleAlignment: "left", backgroundColor: "transparent", flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, bg: "transparent", children: data.tour === null ? (_jsx("span", { fg: INK, children: "No description yet \u2014 t composes one from the diff and session record." })) : (_jsxs(_Fragment, { children: [_jsx("span", { fg: MUTED, children: "Mend uses inference \u00B7 " }), stale ? _jsx("span", { fg: MUTED, children: "diff changed since \u00B7 " }) : null, _jsx("span", { fg: INK, children: truncate(data.tour.summary, Math.max(20, width - 50)) })] })) }), height >= 5 && data.tour !== null ? (_jsx("text", { height: 1, bg: "transparent", children: tourStop === null ? (_jsxs("span", { fg: FAINT, children: ["inferred approach \u00B7 ", truncate(data.tour.approach ?? "No tour stops yet.", width - 24)] })) : (_jsxs(_Fragment, { children: [_jsxs("span", { fg: INK_2, children: ["stop ", tourIndex + 1, "/", data.tour.stops.length, " \u00B7 ", tourStop.title] }), _jsxs("span", { fg: FAINT, children: [tourStop.file === null
|
|
92
|
-
? " · change"
|
|
93
|
-
: ` · ${tourStop.file}${tourStop.line === null ? "" : `:${tourStop.line}`}`, tourStop.grounded ? " · direct record" : " · inferred reading"] })] })) })) : null, height >= 6 && tourStop !== null ? (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: MUTED, children: tourStop.grounded ? "record-grounded narration · " : "inferred narration · " }), _jsx("span", { fg: MUTED, children: truncate(tourStop.narration, Math.max(20, width - 24)) })] })) : null, height >= 7 && tourStop !== null ? (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: MUTED, children: stopEvidence === null
|
|
94
|
-
? "no direct evidence · "
|
|
95
|
-
: `evidence seq ${stopEvidence.sequence} · ` }), _jsx("span", { fg: MUTED, children: truncate(stopEvidence?.excerpt ?? "this stop is labeled inferred", Math.max(20, width - 26)) })] })) : null, _jsxs("text", { height: 1, bg: "transparent", children: [passFacts.length === 0 ? (_jsx("span", { fg: FAINT, children: "Mend has not read this change." })) : (passFacts.map((fact, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, _jsx("span", { fg: fact.color, children: fact.text })] }, `${fact.text}-${index}`)))), data.followUp?.status === "pending" ? _jsx("span", { fg: INK_2, children: " \u00B7 follow-up pending" }) : null] })] }));
|
|
96
|
-
};
|
|
97
|
-
const CompactTourEvidence = ({ stop, index, total, width, }) => {
|
|
98
|
-
const evidence = stop.evidence[0] ?? null;
|
|
99
|
-
return (_jsxs("box", { height: 5, border: true, borderStyle: "rounded", borderColor: RULE, title: ` tour stop ${index + 1}/${total} · ${stop.grounded ? "direct record" : "inferred reading"} `, titleAlignment: "left", backgroundColor: "transparent", flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(stop.narration, Math.max(12, width - 6)) }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: MUTED, children: evidence === null ? "no direct record link · " : `seq ${evidence.sequence} · ` }), _jsx("span", { fg: MUTED, children: truncate(evidence?.excerpt ?? "the narration is an inferred reading", Math.max(12, width - 26)) })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ", previous stop \u00B7 . next stop" })] }));
|
|
100
|
-
};
|
|
101
|
-
const EvidenceCard = ({ comment, width, }) => (_jsxs("box", { height: Math.min(6, 4 + Math.min(2, comment.evidence.length)), border: true, borderStyle: "rounded", borderColor: RULE, title: ` ${anchorOf(comment)} · ${comment.authorKind === "mend" ? "Mend" : "You"} `, titleAlignment: "left", backgroundColor: "transparent", flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(comment.body, Math.max(12, width - 6)) }), comment.suggestion === null ? null : (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: MUTED, children: "proposed \u00B7 " }), _jsx("span", { fg: MUTED, children: truncate(comment.suggestion, Math.max(12, width - 17)) })] })), comment.evidence.slice(0, 2).map((evidence) => (_jsxs("text", { height: 1, bg: "transparent", children: [_jsxs("span", { fg: FAINT, children: ["seq ", evidence.sequence, " \u00B7 "] }), _jsx("span", { fg: MUTED, children: truncate(evidence.excerpt, Math.max(12, width - 18)) })] }, `${evidence.sequence}-${evidence.excerpt}`)))] }));
|
|
102
|
-
const EditorPanel = ({ editor, refValue, busy, onSubmit, }) => {
|
|
103
|
-
const title = editor.kind === "send"
|
|
104
|
-
? " send review to session · edit before sending "
|
|
105
|
-
: ` comment · ${editor.file === null ? "change" : `${editor.file}:${editor.line ?? ""}`} `;
|
|
106
|
-
return (_jsxs("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: title, titleAlignment: "left", backgroundColor: "transparent", height: editor.kind === "send" ? 16 : 8, flexShrink: 0, flexDirection: "column", children: [_jsx("textarea", { ref: refValue, focused: true, initialValue: editor.kind === "send" ? editor.instruction : (editor.initialBody ?? ""), placeholder: editor.kind === "send" ? "Review instruction…" : "What should change, and why?", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, keyBindings: [...EDITOR_KEY_BINDINGS], flexGrow: 1, onSubmit: onSubmit }), _jsx("text", { height: 1, bg: "transparent", children: _jsxs("span", { fg: FAINT, children: [" ctrl+enter ", busy ? "working…" : "save", " \u00B7 esc cancel"] }) })] }));
|
|
107
|
-
};
|
|
108
|
-
const errorMessage = (error) => error instanceof Error ? error.message : error === null ? "" : String(error);
|
|
109
|
-
export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQuit, }) {
|
|
110
|
-
const queryClient = useQueryClient();
|
|
111
|
-
const { width, height } = useTerminalDimensions();
|
|
112
|
-
const wide = width >= 100;
|
|
113
|
-
const descriptionHeight = height >= 30 ? 7 : 5;
|
|
114
|
-
const { data, failureReason, isFetching } = useQuery({
|
|
115
|
-
queryKey: REVIEW_KEY(changeId),
|
|
116
|
-
queryFn: () => fetchReview(ctx, changeId),
|
|
117
|
-
retry: true,
|
|
118
|
-
retryDelay: 1000,
|
|
119
|
-
});
|
|
120
|
-
const [focus, setFocus] = useState("diff");
|
|
121
|
-
const [fileIndex, setFileIndex] = useState(0);
|
|
122
|
-
const [anchorIndex, setAnchorIndex] = useState(0);
|
|
123
|
-
const [commentIndex, setCommentIndex] = useState(0);
|
|
124
|
-
const [tourIndex, setTourIndex] = useState(0);
|
|
125
|
-
const [view, setView] = useState("unified");
|
|
126
|
-
const [wrap, setWrap] = useState(false);
|
|
127
|
-
const [showWhitespace, setShowWhitespace] = useState(false);
|
|
128
|
-
const [rangeStart, setRangeStart] = useState(null);
|
|
129
|
-
const [editor, setEditor] = useState(null);
|
|
130
|
-
const [busy, setBusy] = useState(false);
|
|
131
|
-
const [status, setStatus] = useState("");
|
|
132
|
-
const checkpointState = data === undefined ? (isFetching ? "recording" : "failed") : "recorded";
|
|
133
|
-
const lockRef = useRef(false);
|
|
134
|
-
const editorRef = useRef(null);
|
|
135
|
-
const diffRef = useRef(null);
|
|
136
|
-
const diffScrollRef = useRef(null);
|
|
137
|
-
const fileScrollRef = useRef(null);
|
|
138
|
-
const commentScrollRef = useRef(null);
|
|
139
|
-
const files = useMemo(() => {
|
|
140
|
-
const order = {
|
|
141
|
-
added: 0,
|
|
142
|
-
modified: 1,
|
|
143
|
-
renamed: 2,
|
|
144
|
-
deleted: 3,
|
|
145
|
-
};
|
|
146
|
-
return (data?.files ?? []).toSorted((left, right) => order[left.status] - order[right.status] || left.path.localeCompare(right.path));
|
|
147
|
-
}, [data?.files]);
|
|
148
|
-
const selectedFile = files[Math.min(fileIndex, Math.max(0, files.length - 1))] ?? null;
|
|
149
|
-
const anchors = useMemo(() => selectedFile?.lines.filter((line) => line.commentable) ?? [], [selectedFile]);
|
|
150
|
-
const selectedAnchor = anchors[Math.min(anchorIndex, Math.max(0, anchors.length - 1))] ?? null;
|
|
151
|
-
const comments = useMemo(() => orderedComments(data?.comments ?? []), [data?.comments]);
|
|
152
|
-
const selectedComment = comments[Math.min(commentIndex, Math.max(0, comments.length - 1))] ?? null;
|
|
153
|
-
const lineComment = selectedFile === null || selectedAnchor === null
|
|
154
|
-
? null
|
|
155
|
-
: (comments.find((comment) => comment.file === selectedFile.path &&
|
|
156
|
-
comment.line !== null &&
|
|
157
|
-
selectedAnchor.newLine !== null &&
|
|
158
|
-
comment.line <= selectedAnchor.newLine &&
|
|
159
|
-
(comment.endLine ?? comment.line) >= selectedAnchor.newLine) ?? null);
|
|
160
|
-
const detailComment = focus === "comments" ? selectedComment : lineComment;
|
|
161
|
-
const tourStops = data?.tour?.stops ?? [];
|
|
162
|
-
const activeTourIndex = Math.min(tourIndex, Math.max(0, tourStops.length - 1));
|
|
163
|
-
const activeTourStop = tourStops[activeTourIndex] ?? null;
|
|
164
|
-
const tourStale = useMemo(() => data?.tour === null || data?.tour === undefined
|
|
165
|
-
? false
|
|
166
|
-
: createHash("sha256").update(data.wire.diff).digest("hex") !== data.tour.diffDigest, [data?.tour, data?.wire.diff]);
|
|
167
|
-
const refresh = () => {
|
|
168
|
-
void queryClient.invalidateQueries({ queryKey: REVIEW_KEY(changeId) });
|
|
169
|
-
};
|
|
170
|
-
/** Converge on the server's truth once, after the LAST in-flight mutation. */
|
|
171
|
-
const settleRefresh = () => {
|
|
172
|
-
if (queryClient.isMutating() === 1)
|
|
173
|
-
refresh();
|
|
174
|
-
};
|
|
175
|
-
const patchReview = (f) => {
|
|
176
|
-
const current = queryClient.getQueryData(REVIEW_KEY(changeId));
|
|
177
|
-
if (current !== undefined)
|
|
178
|
-
queryClient.setQueryData(REVIEW_KEY(changeId), f(current));
|
|
179
|
-
};
|
|
180
|
-
// A state flip (accept, address, dismiss, reopen) lands in the list at the
|
|
181
|
-
// keystroke — triage never waits on a round trip. An error refetches the
|
|
182
|
-
// truth back; the settled refetch reconciles everything else.
|
|
183
|
-
const commentStateMutation = useMutation({
|
|
184
|
-
mutationFn: (vars) => ctx.api("POST", `/changes/${changeId}/comments/${vars.commentId}/state`, {
|
|
185
|
-
state: vars.state,
|
|
186
|
-
}),
|
|
187
|
-
onMutate: async (vars) => {
|
|
188
|
-
await queryClient.cancelQueries({ queryKey: REVIEW_KEY(changeId) });
|
|
189
|
-
patchReview((current) => ({
|
|
190
|
-
...current,
|
|
191
|
-
comments: current.comments.map((comment) => comment.id === vars.commentId ? { ...comment, state: vars.state } : comment),
|
|
192
|
-
}));
|
|
193
|
-
},
|
|
194
|
-
onError: (error) => {
|
|
195
|
-
setStatus(errorMessage(error));
|
|
196
|
-
refresh();
|
|
197
|
-
},
|
|
198
|
-
onSettled: settleRefresh,
|
|
199
|
-
});
|
|
200
|
-
// A new comment appears in the list the moment ctrl+enter lands and the
|
|
201
|
-
// editor closes; a failed save reopens the editor with the text intact.
|
|
202
|
-
const commentCreateMutation = useMutation({
|
|
203
|
-
mutationFn: (vars) => ctx.api("POST", `/changes/${changeId}/reviews/${vars.sliceId}/comments`, {
|
|
204
|
-
target: vars.target,
|
|
205
|
-
body: vars.body,
|
|
206
|
-
}),
|
|
207
|
-
onMutate: async (vars) => {
|
|
208
|
-
await queryClient.cancelQueries({ queryKey: REVIEW_KEY(changeId) });
|
|
209
|
-
patchReview((current) => ({
|
|
210
|
-
...current,
|
|
211
|
-
comments: [vars.optimistic, ...current.comments],
|
|
212
|
-
}));
|
|
213
|
-
setEditor(null);
|
|
214
|
-
setStatus(vars.optimistic.file === null ? "Change comment added" : "Inline comment added");
|
|
215
|
-
},
|
|
216
|
-
onError: (error, vars) => {
|
|
217
|
-
patchReview((current) => ({
|
|
218
|
-
...current,
|
|
219
|
-
comments: current.comments.filter((comment) => comment.id !== vars.optimistic.id),
|
|
220
|
-
}));
|
|
221
|
-
setEditor({
|
|
222
|
-
kind: "comment",
|
|
223
|
-
file: vars.optimistic.file,
|
|
224
|
-
line: vars.optimistic.line,
|
|
225
|
-
endLine: vars.optimistic.endLine,
|
|
226
|
-
initialBody: vars.body,
|
|
227
|
-
});
|
|
228
|
-
setStatus(errorMessage(error));
|
|
229
|
-
},
|
|
230
|
-
onSettled: settleRefresh,
|
|
231
|
-
});
|
|
232
|
-
useEffect(() => {
|
|
233
|
-
fileScrollRef.current?.scrollTo(Math.max(0, fileIndex * 2 - 2));
|
|
234
|
-
}, [fileIndex]);
|
|
235
|
-
useEffect(() => {
|
|
236
|
-
commentScrollRef.current?.scrollTo(Math.max(0, commentIndex * 2 - 2));
|
|
237
|
-
}, [commentIndex]);
|
|
238
|
-
useEffect(() => {
|
|
239
|
-
if (selectedAnchor === null)
|
|
240
|
-
return;
|
|
241
|
-
const row = view === "unified" ? selectedAnchor.unifiedRow : selectedAnchor.splitRow;
|
|
242
|
-
diffScrollRef.current?.scrollTo(Math.max(0, row - 3));
|
|
243
|
-
}, [selectedAnchor, view]);
|
|
244
|
-
useEffect(() => {
|
|
245
|
-
// Split can align a deletion and addition on one row; preserve their
|
|
246
|
-
// semantic colors there. Unified has one fact per row, so cobalt can mark
|
|
247
|
-
// the active comment anchor without lying about either side.
|
|
248
|
-
if (view !== "unified" || selectedAnchor === null || diffRef.current === null)
|
|
249
|
-
return;
|
|
250
|
-
const start = rangeStart === null ? selectedAnchor.newLine : rangeStart;
|
|
251
|
-
const selectedLine = selectedAnchor.newLine;
|
|
252
|
-
const highlighted = start === null || selectedLine === null
|
|
253
|
-
? [selectedAnchor]
|
|
254
|
-
: anchors.filter((anchor) => anchor.newLine !== null &&
|
|
255
|
-
anchor.newLine >= Math.min(start, selectedLine) &&
|
|
256
|
-
anchor.newLine <= Math.max(start, selectedLine));
|
|
257
|
-
for (const anchor of highlighted) {
|
|
258
|
-
diffRef.current.setLineColor(anchor.unifiedRow, { gutter: COBALT, content: WASH });
|
|
259
|
-
}
|
|
260
|
-
return () => {
|
|
261
|
-
if (diffRef.current === null)
|
|
262
|
-
return;
|
|
263
|
-
for (const anchor of highlighted) {
|
|
264
|
-
const content = anchor.kind === "addition"
|
|
265
|
-
? ADD_WASH
|
|
266
|
-
: anchor.kind === "deletion"
|
|
267
|
-
? DELETE_WASH
|
|
268
|
-
: "transparent";
|
|
269
|
-
diffRef.current.setLineColor(anchor.unifiedRow, {
|
|
270
|
-
gutter: "transparent",
|
|
271
|
-
content,
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
};
|
|
275
|
-
}, [anchors, rangeStart, selectedAnchor, view]);
|
|
276
|
-
const moveFile = (delta) => {
|
|
277
|
-
if (files.length === 0)
|
|
278
|
-
return;
|
|
279
|
-
setFileIndex((current) => {
|
|
280
|
-
const next = Math.max(0, Math.min(files.length - 1, current + delta));
|
|
281
|
-
if (next !== current) {
|
|
282
|
-
setAnchorIndex(0);
|
|
283
|
-
setRangeStart(null);
|
|
284
|
-
}
|
|
285
|
-
return next;
|
|
286
|
-
});
|
|
287
|
-
};
|
|
288
|
-
const moveAnchor = (delta) => {
|
|
289
|
-
if (anchors.length === 0)
|
|
290
|
-
return;
|
|
291
|
-
setAnchorIndex((current) => Math.max(0, Math.min(anchors.length - 1, current + delta)));
|
|
292
|
-
};
|
|
293
|
-
const navigateToAnchor = (file, line) => {
|
|
294
|
-
if (file === null)
|
|
295
|
-
return;
|
|
296
|
-
const nextFileIndex = files.findIndex((candidate) => candidate.path === file);
|
|
297
|
-
if (nextFileIndex < 0)
|
|
298
|
-
return;
|
|
299
|
-
const nextFile = files[nextFileIndex];
|
|
300
|
-
const nextAnchorIndex = line === null
|
|
301
|
-
? 0
|
|
302
|
-
: (nextFile?.lines.findIndex((candidate) => candidate.newLine !== null && candidate.newLine >= line) ?? -1);
|
|
303
|
-
setFileIndex(nextFileIndex);
|
|
304
|
-
setAnchorIndex(Math.max(0, nextAnchorIndex));
|
|
305
|
-
setRangeStart(null);
|
|
306
|
-
};
|
|
307
|
-
const selectComment = (index) => {
|
|
308
|
-
const next = comments[index];
|
|
309
|
-
if (next === undefined)
|
|
310
|
-
return;
|
|
311
|
-
setCommentIndex(index);
|
|
312
|
-
navigateToAnchor(next.file, next.line);
|
|
313
|
-
};
|
|
314
|
-
const moveComment = (delta) => {
|
|
315
|
-
if (comments.length === 0)
|
|
316
|
-
return;
|
|
317
|
-
const current = Math.min(commentIndex, comments.length - 1);
|
|
318
|
-
selectComment(Math.max(0, Math.min(comments.length - 1, current + delta)));
|
|
319
|
-
};
|
|
320
|
-
const moveTour = (delta) => {
|
|
321
|
-
if (tourStops.length === 0)
|
|
322
|
-
return;
|
|
323
|
-
const current = Math.min(tourIndex, tourStops.length - 1);
|
|
324
|
-
const nextIndex = Math.max(0, Math.min(tourStops.length - 1, current + delta));
|
|
325
|
-
const stop = tourStops[nextIndex];
|
|
326
|
-
setTourIndex(nextIndex);
|
|
327
|
-
if (stop !== undefined)
|
|
328
|
-
navigateToAnchor(stop.file, stop.line);
|
|
329
|
-
};
|
|
330
|
-
const moveHunk = (delta) => {
|
|
331
|
-
if (selectedFile === null || selectedAnchor === null)
|
|
332
|
-
return;
|
|
333
|
-
const target = Math.max(0, Math.min(selectedFile.hunks.length - 1, selectedAnchor.hunk + delta));
|
|
334
|
-
const next = anchors.findIndex((anchor) => anchor.hunk === target);
|
|
335
|
-
if (next >= 0)
|
|
336
|
-
setAnchorIndex(next);
|
|
337
|
-
};
|
|
338
|
-
const setWorking = (value) => {
|
|
339
|
-
lockRef.current = value;
|
|
340
|
-
setBusy(value);
|
|
341
|
-
};
|
|
342
|
-
const runAction = async (label, action, completed = `${label} · requested`) => {
|
|
343
|
-
setWorking(true);
|
|
344
|
-
setStatus(`${label}…`);
|
|
345
|
-
try {
|
|
346
|
-
await action();
|
|
347
|
-
setStatus(completed);
|
|
348
|
-
refresh();
|
|
349
|
-
}
|
|
350
|
-
catch (error) {
|
|
351
|
-
setStatus(errorMessage(error));
|
|
352
|
-
}
|
|
353
|
-
finally {
|
|
354
|
-
setWorking(false);
|
|
355
|
-
}
|
|
356
|
-
};
|
|
357
|
-
const openCommentEditor = (wholeChange) => {
|
|
358
|
-
if (wholeChange) {
|
|
359
|
-
setEditor({ kind: "comment", file: null, line: null, endLine: null });
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
if (selectedFile?.status === "deleted" && selectedAnchor === null) {
|
|
363
|
-
setStatus("Deleted-side anchors are not persisted · use C for a change comment");
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
if (selectedFile === null || selectedAnchor?.newLine === null || selectedAnchor === null) {
|
|
367
|
-
setStatus("No new-side line selected");
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
const range = commentRange(rangeStart, selectedAnchor.newLine);
|
|
371
|
-
setEditor({
|
|
372
|
-
kind: "comment",
|
|
373
|
-
file: selectedFile.path,
|
|
374
|
-
line: range.line,
|
|
375
|
-
endLine: range.endLine === range.line ? null : range.endLine,
|
|
376
|
-
});
|
|
377
|
-
setRangeStart(null);
|
|
378
|
-
};
|
|
379
|
-
const openSendEditor = () => {
|
|
380
|
-
if (data === undefined)
|
|
381
|
-
return;
|
|
382
|
-
// A pending id is an optimistic row the server has not named yet — it
|
|
383
|
-
// cannot ride a follow-up until the settle refetch replaces it.
|
|
384
|
-
const sendable = data.comments.filter((comment) => comment.state === "open" && comment.sentToSessionId === null && !isPendingId(comment.id));
|
|
385
|
-
if (sendable.length === 0) {
|
|
386
|
-
setStatus("No unsent open comments — accept a draft or write a comment first");
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
setEditor({
|
|
390
|
-
kind: "send",
|
|
391
|
-
instruction: assembleReviewInstruction(data.wire.change, sendable),
|
|
392
|
-
commentIds: sendable.map((comment) => comment.id),
|
|
393
|
-
idempotencyKey: `cli-follow-up:${data.sliceId}:${randomUUID()}`,
|
|
394
|
-
});
|
|
395
|
-
};
|
|
396
|
-
const submitEditor = async () => {
|
|
397
|
-
if (editor === null || lockRef.current)
|
|
398
|
-
return;
|
|
399
|
-
const value = editorRef.current?.plainText.trim() ?? "";
|
|
400
|
-
if (value === "") {
|
|
401
|
-
setStatus("Write something before saving");
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
if (data === undefined) {
|
|
405
|
-
setStatus("The pinned Review is not available.");
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
if (editor.kind === "comment") {
|
|
409
|
-
const anchorFile = data.anchorFiles.find((file) => (file.newPath ?? file.oldPath) === editor.file);
|
|
410
|
-
const endLine = editor.endLine ?? editor.line;
|
|
411
|
-
const hunk = editor.line === null || endLine === null
|
|
412
|
-
? null
|
|
413
|
-
: (anchorFile?.hunks.find((candidate) => candidate.newLines > 0 &&
|
|
414
|
-
editor.line !== null &&
|
|
415
|
-
editor.line >= candidate.newStart &&
|
|
416
|
-
endLine <= candidate.newStart + candidate.newLines - 1) ?? null);
|
|
417
|
-
if (editor.file !== null && (anchorFile === undefined || hunk === null)) {
|
|
418
|
-
setStatus("The selected line is outside the pinned Review anchor.");
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
commentCreateMutation.mutate({
|
|
422
|
-
sliceId: data.sliceId,
|
|
423
|
-
body: value,
|
|
424
|
-
target: editor.file === null
|
|
425
|
-
? {
|
|
426
|
-
oldPath: null,
|
|
427
|
-
newPath: null,
|
|
428
|
-
side: null,
|
|
429
|
-
startLine: null,
|
|
430
|
-
endLine: null,
|
|
431
|
-
hunkContextHash: null,
|
|
432
|
-
}
|
|
433
|
-
: {
|
|
434
|
-
oldPath: anchorFile?.oldPath ?? null,
|
|
435
|
-
newPath: anchorFile?.newPath ?? null,
|
|
436
|
-
side: "new",
|
|
437
|
-
startLine: editor.line,
|
|
438
|
-
endLine,
|
|
439
|
-
hunkContextHash: hunk?.contextHash ?? null,
|
|
440
|
-
},
|
|
441
|
-
optimistic: {
|
|
442
|
-
id: pendingId(),
|
|
443
|
-
file: editor.file,
|
|
444
|
-
line: editor.line,
|
|
445
|
-
endLine: editor.endLine,
|
|
446
|
-
authorKind: "reviewer",
|
|
447
|
-
authorName: "You",
|
|
448
|
-
body: value,
|
|
449
|
-
suggestion: null,
|
|
450
|
-
state: "open",
|
|
451
|
-
evidence: [],
|
|
452
|
-
sentToSessionId: null,
|
|
453
|
-
createdAt: new Date().toISOString(),
|
|
454
|
-
},
|
|
455
|
-
});
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
// Delivery stays pessimistic: it starts a run against the session, and
|
|
459
|
-
// pretending it happened before the server agreed would fake a fact.
|
|
460
|
-
setWorking(true);
|
|
461
|
-
try {
|
|
462
|
-
await deliverReview(ctx.api, session.id, {
|
|
463
|
-
reviewSliceId: data.sliceId,
|
|
464
|
-
checkpointAId: data.checkpointAId,
|
|
465
|
-
checkpointBId: data.checkpointBId,
|
|
466
|
-
diffDigest: data.diffDigest,
|
|
467
|
-
commentIds: editor.commentIds,
|
|
468
|
-
instruction: value,
|
|
469
|
-
idempotencyKey: editor.idempotencyKey,
|
|
470
|
-
});
|
|
471
|
-
setStatus("Follow-up delivery requested · retry keeps the same run");
|
|
472
|
-
setEditor(null);
|
|
473
|
-
refresh();
|
|
474
|
-
}
|
|
475
|
-
catch (error) {
|
|
476
|
-
setStatus(errorMessage(error));
|
|
477
|
-
}
|
|
478
|
-
finally {
|
|
479
|
-
setWorking(false);
|
|
480
|
-
}
|
|
481
|
-
};
|
|
482
|
-
const updateComment = (state) => {
|
|
483
|
-
if (selectedComment === null)
|
|
484
|
-
return;
|
|
485
|
-
if (isPendingId(selectedComment.id)) {
|
|
486
|
-
setStatus("That comment is still saving — one moment");
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
setStatus(`${state} · ${anchorOf(selectedComment)}`);
|
|
490
|
-
commentStateMutation.mutate({ commentId: selectedComment.id, state });
|
|
491
|
-
};
|
|
492
|
-
const deliverAndRelaunch = () => {
|
|
493
|
-
const followUp = data?.followUp ?? null;
|
|
494
|
-
if (followUp === null || followUp.status === "delivered" || followUp.status === "superseded") {
|
|
495
|
-
setStatus("No retryable follow-up — s assembles the open Review comments first");
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
if (followUp.reviewSliceId === null ||
|
|
499
|
-
followUp.checkpointAId === null ||
|
|
500
|
-
followUp.checkpointBId === null ||
|
|
501
|
-
followUp.diffDigest === null ||
|
|
502
|
-
followUp.idempotencyKey === null) {
|
|
503
|
-
setStatus("Legacy follow-up · recreate it from a pinned Review before delivery");
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
const input = {
|
|
507
|
-
reviewSliceId: followUp.reviewSliceId,
|
|
508
|
-
checkpointAId: followUp.checkpointAId,
|
|
509
|
-
checkpointBId: followUp.checkpointBId,
|
|
510
|
-
diffDigest: followUp.diffDigest,
|
|
511
|
-
commentIds: followUp.commentIds,
|
|
512
|
-
instruction: followUp.instruction,
|
|
513
|
-
idempotencyKey: followUp.idempotencyKey,
|
|
514
|
-
};
|
|
515
|
-
void runAction("Delivering the persisted Review bundle", () => deliverReview(ctx.api, session.id, input), "Delivery reconciled · the same idempotency key names one run");
|
|
516
|
-
};
|
|
517
|
-
const focusOrder = wide
|
|
518
|
-
? ["files", "diff", "comments"]
|
|
519
|
-
: ["diff", "comments"];
|
|
520
|
-
const cycleFocus = (backwards) => {
|
|
521
|
-
const index = focusOrder.indexOf(focus);
|
|
522
|
-
const direction = backwards ? -1 : 1;
|
|
523
|
-
const next = (index + direction + focusOrder.length) % focusOrder.length;
|
|
524
|
-
const nextFocus = focusOrder[next] ?? "diff";
|
|
525
|
-
if (nextFocus === "comments" && selectedComment !== null) {
|
|
526
|
-
navigateToAnchor(selectedComment.file, selectedComment.line);
|
|
527
|
-
}
|
|
528
|
-
setFocus(nextFocus);
|
|
529
|
-
};
|
|
530
|
-
useKeyboard((key) => {
|
|
531
|
-
if (key.ctrl && key.name === "c")
|
|
532
|
-
return onQuit();
|
|
533
|
-
if (editor !== null) {
|
|
534
|
-
if (key.name === "escape" && !lockRef.current)
|
|
535
|
-
setEditor(null);
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
538
|
-
if (lockRef.current)
|
|
539
|
-
return;
|
|
540
|
-
if (key.name === "q")
|
|
541
|
-
return onQuit();
|
|
542
|
-
if (key.name === "escape" || key.name === "backspace" || key.name === "h")
|
|
543
|
-
return onBack();
|
|
544
|
-
if (key.name === "tab")
|
|
545
|
-
return cycleFocus(key.shift);
|
|
546
|
-
if (key.name === "r") {
|
|
547
|
-
setStatus("Refreshing live change…");
|
|
548
|
-
return refresh();
|
|
549
|
-
}
|
|
550
|
-
if (key.name === "o") {
|
|
551
|
-
openUrl(`${ctx.config.url}/changes/${changeId}`);
|
|
552
|
-
setStatus("Opened web review");
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
if (key.name === "c" && key.shift)
|
|
556
|
-
return openCommentEditor(true);
|
|
557
|
-
if (key.name === "s")
|
|
558
|
-
return openSendEditor();
|
|
559
|
-
if (key.name === "y")
|
|
560
|
-
return deliverAndRelaunch();
|
|
561
|
-
if (key.name === ",")
|
|
562
|
-
return moveTour(-1);
|
|
563
|
-
if (key.name === ".")
|
|
564
|
-
return moveTour(1);
|
|
565
|
-
if (key.name === "t") {
|
|
566
|
-
return void runAction(data?.tour === null ? "Compose description and tour" : "Recompose tour", () => ctx.api("POST", `/changes/${changeId}/tour`, {}));
|
|
567
|
-
}
|
|
568
|
-
if (key.name === "m") {
|
|
569
|
-
return void runAction("Mend reads the change", () => ctx.api("POST", `/changes/${changeId}/read`, {}));
|
|
570
|
-
}
|
|
571
|
-
if (key.name === "g") {
|
|
572
|
-
return void runAction("Draft concrete suggestions", () => ctx.api("POST", `/changes/${changeId}/suggest`, {}));
|
|
573
|
-
}
|
|
574
|
-
if (focus === "files") {
|
|
575
|
-
if (key.name === "j" || key.name === "down")
|
|
576
|
-
return moveFile(1);
|
|
577
|
-
if (key.name === "k" || key.name === "up")
|
|
578
|
-
return moveFile(-1);
|
|
579
|
-
if (key.name === "return" || key.name === "linefeed" || key.name === "l") {
|
|
580
|
-
return setFocus("diff");
|
|
581
|
-
}
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
if (focus === "comments") {
|
|
585
|
-
if (key.name === "j" || key.name === "down")
|
|
586
|
-
return moveComment(1);
|
|
587
|
-
if (key.name === "k" || key.name === "up")
|
|
588
|
-
return moveComment(-1);
|
|
589
|
-
if (key.name === "a" && selectedComment?.state === "draft")
|
|
590
|
-
return updateComment("open");
|
|
591
|
-
if (key.name === "a" && selectedComment?.state === "open") {
|
|
592
|
-
return updateComment("addressed");
|
|
593
|
-
}
|
|
594
|
-
if (key.name === "x")
|
|
595
|
-
return updateComment("dismissed");
|
|
596
|
-
if (key.name === "u" && selectedComment !== null)
|
|
597
|
-
return updateComment("open");
|
|
598
|
-
if (key.name === "return" || key.name === "linefeed" || key.name === "l") {
|
|
599
|
-
if (selectedComment !== null)
|
|
600
|
-
navigateToAnchor(selectedComment.file, selectedComment.line);
|
|
601
|
-
return setFocus("diff");
|
|
602
|
-
}
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
if (key.name === "j" || key.name === "down")
|
|
606
|
-
return moveAnchor(1);
|
|
607
|
-
if (key.name === "k" || key.name === "up")
|
|
608
|
-
return moveAnchor(-1);
|
|
609
|
-
if (key.name === "n")
|
|
610
|
-
return moveFile(1);
|
|
611
|
-
if (key.name === "p")
|
|
612
|
-
return moveFile(-1);
|
|
613
|
-
if (key.name === "]")
|
|
614
|
-
return moveHunk(1);
|
|
615
|
-
if (key.name === "[")
|
|
616
|
-
return moveHunk(-1);
|
|
617
|
-
if (key.name === "c")
|
|
618
|
-
return openCommentEditor(false);
|
|
619
|
-
if (key.name === "v") {
|
|
620
|
-
if (selectedAnchor?.newLine === null || selectedAnchor === null) {
|
|
621
|
-
setStatus("Select a new-side line before starting a range");
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
setRangeStart((current) => (current === null ? selectedAnchor.newLine : null));
|
|
625
|
-
setStatus(rangeStart === null ? `Range starts at line ${selectedAnchor.newLine}` : "Range cleared");
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
if (key.name === "d" && wide)
|
|
629
|
-
return setView((current) => (current === "unified" ? "split" : "unified"));
|
|
630
|
-
if (key.name === "w")
|
|
631
|
-
return setWrap((current) => !current);
|
|
632
|
-
if (key.name === "z")
|
|
633
|
-
return setShowWhitespace((current) => !current);
|
|
634
|
-
});
|
|
635
|
-
if (data === undefined) {
|
|
636
|
-
return (_jsx("box", { flexGrow: 1, backgroundColor: "transparent", alignItems: "center", justifyContent: "center", children: _jsx("text", { bg: "transparent", children: _jsx("span", { fg: failureReason === null ? INK_2 : MUTED, children: failureReason === null
|
|
637
|
-
? "Loading the live change…"
|
|
638
|
-
: `${errorMessage(failureReason)} · retrying` }) }) }));
|
|
639
|
-
}
|
|
640
|
-
const additions = data.wire.files.reduce((sum, file) => sum + file.additions, 0);
|
|
641
|
-
const deletions = data.wire.files.reduce((sum, file) => sum + file.deletions, 0);
|
|
642
|
-
const openCount = data.comments.filter((comment) => comment.state === "open").length;
|
|
643
|
-
const draftCount = data.comments.filter((comment) => comment.state === "draft").length;
|
|
644
|
-
const displayRows = selectedFile === null
|
|
645
|
-
? 1
|
|
646
|
-
: view === "unified"
|
|
647
|
-
? Math.max(1, selectedFile.unifiedRows)
|
|
648
|
-
: Math.max(1, selectedFile.splitRows);
|
|
649
|
-
const displayPatch = selectedFile === null || !showWhitespace
|
|
650
|
-
? (selectedFile?.patch ?? "")
|
|
651
|
-
: visibleWhitespace(selectedFile.patch);
|
|
652
|
-
const footer = editor === null
|
|
653
|
-
? focus === "files"
|
|
654
|
-
? " files · ↑↓/jk move · enter review · tab pane · esc back · q quit"
|
|
655
|
-
: focus === "comments"
|
|
656
|
-
? " comments · ↑↓/jk move · enter anchor · a accept/address · x dismiss · u reopen · tab pane"
|
|
657
|
-
: ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`
|
|
658
|
-
: " ctrl+enter save · esc cancel";
|
|
659
|
-
const sidebar = wide ? (_jsxs("box", { width: 36, flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "files" ? COBALT : RULE, title: ` files · ${files.length} `, titleAlignment: "left", backgroundColor: "transparent", flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: fileScrollRef, focused: focus === "files", flexGrow: 1, minHeight: 0, style: {
|
|
660
|
-
rootOptions: { backgroundColor: "transparent", border: false },
|
|
661
|
-
wrapperOptions: { backgroundColor: "transparent" },
|
|
662
|
-
viewportOptions: { backgroundColor: "transparent" },
|
|
663
|
-
contentOptions: { backgroundColor: "transparent" },
|
|
664
|
-
}, children: files.map((file, index) => (_jsxs("box", { flexShrink: 0, flexDirection: "column", children: [index === 0 || files[index - 1]?.status !== file.status ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ` ${file.status}` })) : null, _jsx(FileRow, { file: file, selected: index === fileIndex })] }, file.path))) }) }), _jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "comments" ? COBALT : RULE, title: ` comments · ${openCount} open${draftCount > 0 ? ` · ${draftCount} draft` : ""} `, titleAlignment: "left", backgroundColor: "transparent", flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: commentScrollRef, focused: focus === "comments", flexGrow: 1, minHeight: 0, style: {
|
|
665
|
-
rootOptions: { backgroundColor: "transparent", border: false },
|
|
666
|
-
wrapperOptions: { backgroundColor: "transparent" },
|
|
667
|
-
viewportOptions: { backgroundColor: "transparent" },
|
|
668
|
-
contentOptions: { backgroundColor: "transparent" },
|
|
669
|
-
}, children: comments.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no review comments yet" })) : (comments.map((comment, index) => (_jsx(CommentRow, { comment: comment, selected: index === commentIndex }, comment.id)))) }) })] })) : null;
|
|
670
|
-
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 2, flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsx("span", { fg: FAINT, children: " / " }), _jsx("span", { fg: MUTED, children: projectName }), _jsx("span", { fg: FAINT, children: " / " }), _jsx("span", { fg: INK, children: "review" }), _jsxs("span", { fg: FAINT, children: [" ", "\u00B7 ", session.harness, " ", session.id.slice(0, 8)] }), isFetching ? _jsx("span", { fg: FAINT, children: " \u00B7 syncing" }) : null, _jsx("span", { fg: checkpointState === "failed" ? MUTED : FAINT, children: checkpointState === "recording"
|
|
671
|
-
? " · checkpointing"
|
|
672
|
-
: checkpointState === "failed"
|
|
673
|
-
? " · checkpoint incomplete"
|
|
674
|
-
: " · checkpoint recorded" })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsxs("span", { fg: FAINT, children: [" ", data.wire.change.branch.replace(/^mend\/session\//, "session ")] }), _jsxs("span", { fg: FAINT, children: [" \u00B7 worktree vs ", data.wire.change.baseSha.slice(0, 12), " \u00B7 "] }), _jsxs("span", { fg: MUTED, children: [files.length, " files"] }), _jsxs("span", { fg: GREEN, children: [" +", additions] }), _jsxs("span", { fg: RED, children: [" \u2212", deletions] }), _jsxs("span", { fg: MUTED, children: [" \u00B7 ", openCount, " open"] })] })] }), _jsx(Description, { data: data, tourIndex: activeTourIndex, stale: tourStale, width: width, height: descriptionHeight }), _jsxs("box", { flexGrow: 1, minHeight: 0, flexDirection: "row", backgroundColor: "transparent", children: [sidebar, _jsxs("box", { flexGrow: 1, minWidth: 0, minHeight: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "diff" ? COBALT : RULE, title: selectedFile === null
|
|
675
|
-
? " diff "
|
|
676
|
-
: ` ${selectedFile.path} · ${view}${selectedFile.likelyGenerated ? " · inferred likely generated" : ""}${showWhitespace ? " · whitespace" : ""}${wrap ? " · wrapped" : ""} `, titleAlignment: "left", backgroundColor: "transparent", flexGrow: 1, minHeight: 0, children: selectedFile === null || selectedFile.patch === "" || selectedFile.binary ? (_jsx("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: _jsx("text", { fg: FAINT, bg: "transparent", children: files.length === 0
|
|
677
|
-
? "The worktree matches its base — nothing to review yet."
|
|
678
|
-
: selectedFile?.binary === true
|
|
679
|
-
? "Binary change — text diff unavailable; statistics remain reviewable."
|
|
680
|
-
: "This file has no renderable text patch." }) })) : (_jsx("scrollbox", { ref: diffScrollRef, focused: focus === "diff", flexGrow: 1, minHeight: 0, scrollX: true, style: {
|
|
681
|
-
rootOptions: { backgroundColor: "transparent", border: false },
|
|
682
|
-
wrapperOptions: { backgroundColor: "transparent" },
|
|
683
|
-
viewportOptions: { backgroundColor: "transparent" },
|
|
684
|
-
contentOptions: { backgroundColor: "transparent" },
|
|
685
|
-
}, children: _jsx("diff", { ref: diffRef, diff: displayPatch, view: view, syncScroll: true, ...(selectedFile.filetype === undefined
|
|
686
|
-
? {}
|
|
687
|
-
: { filetype: selectedFile.filetype }), syntaxStyle: syntaxStyle, wrapMode: wrap ? "word" : "none", showLineNumbers: true, fg: INK, lineNumberFg: FAINT, lineNumberBg: "transparent", contextBg: "transparent", contextContentBg: "transparent", addedBg: ADD_WASH, addedContentBg: ADD_WASH, removedBg: DELETE_WASH, removedContentBg: DELETE_WASH, addedSignColor: GREEN, removedSignColor: RED, addedLineNumberBg: "transparent", removedLineNumberBg: "transparent", selectionBg: WASH, selectionFg: INK, width: "100%", height: displayRows, flexShrink: 0 }, `${selectedFile.path}-${view}-${wrap}-${showWhitespace}`) })) }), focus === "comments" && detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : height < 30 && activeTourStop !== null ? (_jsx(CompactTourEvidence, { stop: activeTourStop, index: activeTourIndex, total: tourStops.length, width: width })) : detailComment === null ? null : (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width }))] })] }), editor === null ? null : (_jsx(EditorPanel, { editor: editor, refValue: editorRef, busy: busy, onSubmit: () => void submitEditor() })), _jsx("text", { height: 1, fg: status === "" ? FAINT : INK_2, bg: "transparent", children: status === ""
|
|
688
|
-
? ` m read · g suggest · t tour · ,/. tour stops · s draft review${data.followUp?.status === "pending" ? " · y deliver & relaunch" : ""} · o web · r refresh`
|
|
689
|
-
: ` ${status}` }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footer })] }));
|
|
690
|
-
}
|