@agimon-ai/doompi-git 0.0.1-alpha.36 → 0.0.1-alpha.38
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 +21 -1
- package/dist/apiContracts.cjs +249 -60
- package/dist/apiContracts.cjs.map +1 -1
- package/dist/apiContracts.d.mts.map +1 -1
- package/dist/apiContracts.mjs +249 -60
- package/dist/apiContracts.mjs.map +1 -1
- package/dist/extensions/pi.cjs +2 -2
- package/dist/extensions/pi.mjs +2 -2
- package/dist/extensions/server.cjs +1279 -39
- package/dist/extensions/server.cjs.map +1 -1
- package/dist/extensions/server.d.mts.map +1 -1
- package/dist/extensions/server.mjs +1277 -40
- package/dist/extensions/server.mjs.map +1 -1
- package/dist/extensions/web.mjs +1625 -23
- package/dist/extensions/web.mjs.map +1 -1
- package/dist/{gitDependencies-CZLZ_YwU.mjs → gitDependencies-CtVubYvU.mjs} +2 -2
- package/dist/{gitDependencies-CZLZ_YwU.mjs.map → gitDependencies-CtVubYvU.mjs.map} +1 -1
- package/dist/{gitDependencies-Bv_1StiU.cjs → gitDependencies-Dqsz2HjK.cjs} +2 -2
- package/dist/{gitDependencies-Bv_1StiU.cjs.map → gitDependencies-Dqsz2HjK.cjs.map} +1 -1
- package/dist/index-D79MSCee.d.mts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{runWorktree-CtdyNIpZ.cjs → runWorktree-BE795LxL.cjs} +25 -2
- package/dist/runWorktree-BE795LxL.cjs.map +1 -0
- package/dist/{runWorktree-x0pLorlB.mjs → runWorktree-SPzJvIBt.mjs} +8 -3
- package/dist/runWorktree-SPzJvIBt.mjs.map +1 -0
- package/dist/{worktreeOperations-CEbq7iUE.cjs → worktreeOperations-BYmVU0UV.cjs} +106 -9
- package/dist/worktreeOperations-BYmVU0UV.cjs.map +1 -0
- package/dist/{worktreeOperations-BgE26zO3.mjs → worktreeOperations-BrWG2muV.mjs} +41 -10
- package/dist/worktreeOperations-BrWG2muV.mjs.map +1 -0
- package/package.json +8 -6
- package/dist/runWorktree-CtdyNIpZ.cjs.map +0 -1
- package/dist/runWorktree-x0pLorlB.mjs.map +0 -1
- package/dist/worktreeOperations-BgE26zO3.mjs.map +0 -1
- package/dist/worktreeOperations-CEbq7iUE.cjs.map +0 -1
package/dist/extensions/web.mjs
CHANGED
|
@@ -1,12 +1,73 @@
|
|
|
1
|
-
import { defineActivityGroup, defineChannelFile, defineFill, defineGlobalStore, defineSessionStore, defineToolRenderer, defineWebLifecycle, defineWebPlugin,
|
|
2
|
-
import { Button, Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Dot, DropdownMenuItem, EmptyState, Input, MessageItem, MessageItemBody, MessageItemHeader, OptionRow, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Spinner, StatusBadge, toolTone } from "@agimon-ai/doompi-web-components";
|
|
3
|
-
import { createElement, useEffect, useState } from "react";
|
|
1
|
+
import { apiResponse, createApiClient, defineActivityGroup, defineApiRoutes, defineChannelFile, defineFill, defineGlobalStore, defineRepositorySettingsPanel, defineSessionStore, defineToolRenderer, defineWebLifecycle, defineWebPlugin, sessionApiAddress } from "@agimon-ai/doompi-core/web";
|
|
2
|
+
import { AlertIcon, BranchIcon, Button, ChevronDownIcon, ChevronRightIcon, CloseIcon, Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DiffView, Dot, DropdownMenuItem, EmptyState, Input, Label, MessageIcon, MessageItem, MessageItemBody, MessageItemHeader, OptionRow, RadioGroup, RadioGroupCard, ReviewCommentDraft, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, ShieldIcon, Spinner, StatusBadge, buildReviewPrompt, groupByDirectory, groupRowLabel, reviewCommentAnchor, toolTone } from "@agimon-ai/doompi-web-components";
|
|
3
|
+
import { createElement, useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { sealedTransport } from "@agimon-ai/doompi-web-security/browser";
|
|
4
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
6
|
import { useStore } from "@tanstack/react-store";
|
|
6
|
-
//#region src/
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
//#region src/types/apiRoutes.ts
|
|
8
|
+
/**
|
|
9
|
+
* The routes the page calls, as data.
|
|
10
|
+
*
|
|
11
|
+
* No scope and no base path: the build reads both off
|
|
12
|
+
* `src/extensions/(backend)/api/git/`, which mounts at global scope and so
|
|
13
|
+
* answers workspace and session requests too. Each route answers only where it
|
|
14
|
+
* belongs (auth on a workspace mount, the review on a session mount), so a
|
|
15
|
+
* client accessor on the wrong scope gets a 404 rather than a second way in.
|
|
16
|
+
*
|
|
17
|
+
* Only what the browser calls is listed. The worktree routes stay HTTP for
|
|
18
|
+
* other callers, but the dock drives worktrees over its channel.
|
|
19
|
+
*/
|
|
20
|
+
var apiRoutes_default = defineApiRoutes({
|
|
21
|
+
/** Workspace: the new-session dialog's branch list. */
|
|
22
|
+
branches: {
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: "/branches",
|
|
25
|
+
response: apiResponse()
|
|
26
|
+
},
|
|
27
|
+
/** Workspace: a top-level worktree session on an existing or new branch. */
|
|
28
|
+
createSession: {
|
|
29
|
+
method: "POST",
|
|
30
|
+
path: "/sessions",
|
|
31
|
+
response: apiResponse()
|
|
32
|
+
},
|
|
33
|
+
/** Workspace: that workspace's saved remote auth, never the token itself. */
|
|
34
|
+
auth: {
|
|
35
|
+
method: "GET",
|
|
36
|
+
path: "/auth",
|
|
37
|
+
query: ["repositoryId"],
|
|
38
|
+
response: apiResponse()
|
|
39
|
+
},
|
|
40
|
+
/** Workspace: replaces that workspace's remote auth. Passkey step-up gated for remote devices. */
|
|
41
|
+
saveAuth: {
|
|
42
|
+
method: "PUT",
|
|
43
|
+
path: "/auth",
|
|
44
|
+
query: ["repositoryId"],
|
|
45
|
+
response: apiResponse()
|
|
46
|
+
},
|
|
47
|
+
/** Session: the files this session's checkout changed against its base. */
|
|
48
|
+
review: {
|
|
49
|
+
method: "GET",
|
|
50
|
+
path: "/review",
|
|
51
|
+
response: apiResponse()
|
|
52
|
+
},
|
|
53
|
+
/** Session: one changed file's hunks. */
|
|
54
|
+
reviewFile: {
|
|
55
|
+
method: "GET",
|
|
56
|
+
path: "/review/file",
|
|
57
|
+
query: ["path"],
|
|
58
|
+
response: apiResponse()
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
const api = createApiClient(apiRoutes_default, {
|
|
62
|
+
scopes: [
|
|
63
|
+
"global",
|
|
64
|
+
"workspace",
|
|
65
|
+
"session"
|
|
66
|
+
],
|
|
67
|
+
basePath: "git",
|
|
68
|
+
transport: (input, init) => sealedTransport.fetch(input, init),
|
|
69
|
+
sessionAddress: sessionApiAddress
|
|
70
|
+
});
|
|
10
71
|
//#endregion
|
|
11
72
|
//#region src/extensions/workspaces/(frontend)/_lib/newSessionForm.ts
|
|
12
73
|
const MAX_LISTED = 200;
|
|
@@ -37,7 +98,7 @@ function baseOptions(branches) {
|
|
|
37
98
|
])];
|
|
38
99
|
}
|
|
39
100
|
/** Why the form cannot be submitted yet, or undefined when it can. */
|
|
40
|
-
function formProblem(state) {
|
|
101
|
+
function formProblem$1(state) {
|
|
41
102
|
if (state.mode === "existing") {
|
|
42
103
|
if (state.selected === void 0) return "Pick a branch.";
|
|
43
104
|
if (state.selected.busyAt !== void 0) return `That branch is checked out at ${state.selected.busyAt}.`;
|
|
@@ -80,7 +141,7 @@ const MODES = [
|
|
|
80
141
|
label: "no branch"
|
|
81
142
|
}
|
|
82
143
|
];
|
|
83
|
-
function errorOf(body, status) {
|
|
144
|
+
function errorOf$1(body, status) {
|
|
84
145
|
return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : `The cockpit answered ${String(status)}.`;
|
|
85
146
|
}
|
|
86
147
|
/**
|
|
@@ -105,12 +166,12 @@ function GitNewSessionDialog({ newSession }) {
|
|
|
105
166
|
useEffect(() => {
|
|
106
167
|
if (workspaceId === void 0 || request === void 0) return;
|
|
107
168
|
let stale = false;
|
|
108
|
-
const url =
|
|
169
|
+
const url = api.workspace(workspaceId).branches.url();
|
|
109
170
|
request(url).then(async (response) => {
|
|
110
171
|
const body = await response.json().catch(() => void 0);
|
|
111
172
|
if (stale) return;
|
|
112
173
|
if (!response.ok) {
|
|
113
|
-
setLoadError(errorOf(body, response.status));
|
|
174
|
+
setLoadError(errorOf$1(body, response.status));
|
|
114
175
|
return;
|
|
115
176
|
}
|
|
116
177
|
const loaded = body;
|
|
@@ -133,7 +194,7 @@ function GitNewSessionDialog({ newSession }) {
|
|
|
133
194
|
...form,
|
|
134
195
|
mode
|
|
135
196
|
};
|
|
136
|
-
const problem = formProblem(state);
|
|
197
|
+
const problem = formProblem$1(state);
|
|
137
198
|
const options = branches === null ? [] : branchOptions(branches, query);
|
|
138
199
|
const bases = branches === null ? [] : baseOptions(branches);
|
|
139
200
|
const update = (patch) => setForm((current) => ({
|
|
@@ -148,13 +209,13 @@ function GitNewSessionDialog({ newSession }) {
|
|
|
148
209
|
let failure;
|
|
149
210
|
if (body === void 0) failure = await newSession.createPlain(state.name);
|
|
150
211
|
else try {
|
|
151
|
-
const response = await newSession.requestWithStepUp(
|
|
212
|
+
const response = await newSession.requestWithStepUp(api.workspace(newSession.workspaceId).createSession.url(), {
|
|
152
213
|
method: "POST",
|
|
153
214
|
headers: { "Content-Type": "application/json" },
|
|
154
215
|
body: JSON.stringify(body)
|
|
155
216
|
});
|
|
156
217
|
const answer = await response.json().catch(() => void 0);
|
|
157
|
-
failure = response.ok ? await newSession.openCreated(answer.sessionId) : errorOf(answer, response.status);
|
|
218
|
+
failure = response.ok ? await newSession.openCreated(answer.sessionId) : errorOf$1(answer, response.status);
|
|
158
219
|
} catch {
|
|
159
220
|
failure = "The cockpit is unreachable.";
|
|
160
221
|
}
|
|
@@ -327,6 +388,1466 @@ function GitNewSessionDialog({ newSession }) {
|
|
|
327
388
|
//#endregion
|
|
328
389
|
//#region src/extensions/workspaces/(frontend)/fill/GitNewSession.new-session.web.tsx
|
|
329
390
|
var GitNewSession_new_session_web_default = defineFill({ component: GitNewSessionDialog });
|
|
391
|
+
function formFromView(view) {
|
|
392
|
+
return {
|
|
393
|
+
method: view.method,
|
|
394
|
+
keyPath: view.ssh?.keyPath ?? "",
|
|
395
|
+
host: view.https?.host ?? "github.com",
|
|
396
|
+
username: view.https?.username ?? "",
|
|
397
|
+
token: ""
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const HOST_PATTERN = /^[a-z0-9.-]{1,253}(:\d{1,5})?$/u;
|
|
401
|
+
/** Whether a value holds an ASCII control character; a newline in a credential field could add a line to git's credential protocol. */
|
|
402
|
+
function hasControl(value) {
|
|
403
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
404
|
+
const code = value.charCodeAt(index);
|
|
405
|
+
if (code < 32 || code === 127) return true;
|
|
406
|
+
}
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
/** Whether the saved token can stay: only when one exists and the host has not moved. */
|
|
410
|
+
function keepsToken(form, saved) {
|
|
411
|
+
return saved.https?.hasToken === true && saved.https.host === form.host.trim().toLowerCase();
|
|
412
|
+
}
|
|
413
|
+
/** The first thing wrong with the form, in words, or undefined when it can be saved. */
|
|
414
|
+
function formProblem(form, saved) {
|
|
415
|
+
if (form.method === "ssh") {
|
|
416
|
+
const keyPath = form.keyPath.trim();
|
|
417
|
+
if (keyPath === "") return void 0;
|
|
418
|
+
if (hasControl(keyPath)) return "The key path has a control character in it.";
|
|
419
|
+
if (!keyPath.startsWith("/") && !keyPath.startsWith("~/")) return "Give the key as an absolute path or one under ~/.";
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (form.method === "https") {
|
|
423
|
+
const host = form.host.trim().toLowerCase();
|
|
424
|
+
if (!HOST_PATTERN.test(host)) return "The host should look like github.com, with an optional :port.";
|
|
425
|
+
if (form.username.trim() === "") return "A username is required.";
|
|
426
|
+
if (hasControl(form.username) || hasControl(form.token)) return "Remove the control character from the username or token.";
|
|
427
|
+
if (form.token === "" && !keepsToken(form, saved)) return saved.https?.hasToken === true ? "The host changed, so enter the token for the new host." : "A token is required.";
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** What a save sends. The token goes only when the reader typed one. */
|
|
431
|
+
function saveRequestOf(form) {
|
|
432
|
+
if (form.method === "ssh") {
|
|
433
|
+
const keyPath = form.keyPath.trim();
|
|
434
|
+
return keyPath === "" ? { method: "ssh" } : {
|
|
435
|
+
method: "ssh",
|
|
436
|
+
keyPath
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (form.method === "https") return {
|
|
440
|
+
method: "https",
|
|
441
|
+
host: form.host.trim().toLowerCase(),
|
|
442
|
+
username: form.username.trim(),
|
|
443
|
+
...form.token === "" ? {} : { token: form.token }
|
|
444
|
+
};
|
|
445
|
+
return { method: "none" };
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/extensions/workspaces/(frontend)/repository-settings-panel/_components/GitAuthForm.tsx
|
|
449
|
+
/**
|
|
450
|
+
* A workspace's git remote panel body: how doompi-git reaches the remote when
|
|
451
|
+
* you pull, push or rebase from this workspace's sessions in the cockpit.
|
|
452
|
+
*
|
|
453
|
+
* DESIGN PATTERNS:
|
|
454
|
+
* - Per workspace. Worktree sessions use the setup of the workspace they
|
|
455
|
+
* belong to. A workspace with nothing set uses this machine's own git.
|
|
456
|
+
* - Three choices as cards, the way the theme picker offers them: this
|
|
457
|
+
* machine's own git, an SSH key, or an HTTPS token.
|
|
458
|
+
* - The token is write-only. The field is never prefilled; a saved token shows
|
|
459
|
+
* as a badge, and leaving the field empty keeps it.
|
|
460
|
+
* - Form state is the reader's until they save. The owner remounts the form
|
|
461
|
+
* after a save so it starts again from what the server now holds.
|
|
462
|
+
*
|
|
463
|
+
* AVOID:
|
|
464
|
+
* - Echoing a token back. The view the server sends has none to echo.
|
|
465
|
+
*/
|
|
466
|
+
const METHODS = [
|
|
467
|
+
{
|
|
468
|
+
method: "none",
|
|
469
|
+
title: "this machine's git",
|
|
470
|
+
detail: "use whatever git on this machine already does: your credential helper, ssh config and agent"
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
method: "ssh",
|
|
474
|
+
title: "SSH key",
|
|
475
|
+
detail: "a key file on this machine, or your ssh-agent"
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
method: "https",
|
|
479
|
+
title: "HTTPS token",
|
|
480
|
+
detail: "a username and personal access token for one host"
|
|
481
|
+
}
|
|
482
|
+
];
|
|
483
|
+
function GitAuthForm({ saved, saving, saveError, savedNotice = false, onSave }) {
|
|
484
|
+
const [form, setForm] = useState(() => formFromView(saved));
|
|
485
|
+
const update = (patch) => setForm((current) => ({
|
|
486
|
+
...current,
|
|
487
|
+
...patch
|
|
488
|
+
}));
|
|
489
|
+
const problem = formProblem(form, saved);
|
|
490
|
+
const tokenSaved = saved.https?.hasToken === true && saved.https.host === form.host.trim().toLowerCase();
|
|
491
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
492
|
+
"data-testid": "git-auth-form",
|
|
493
|
+
className: "flex w-full min-w-0 flex-col gap-4",
|
|
494
|
+
children: [
|
|
495
|
+
/* @__PURE__ */ jsx(RadioGroup, {
|
|
496
|
+
"aria-label": "how git reaches your remote",
|
|
497
|
+
value: form.method,
|
|
498
|
+
onValueChange: (value) => update({ method: value }),
|
|
499
|
+
className: "grid grid-cols-1 gap-2 sm:grid-cols-3",
|
|
500
|
+
children: METHODS.map((choice) => /* @__PURE__ */ jsx(RadioGroupCard, {
|
|
501
|
+
value: choice.method,
|
|
502
|
+
"data-testid": `git-auth-method-${choice.method}`,
|
|
503
|
+
className: "p-3",
|
|
504
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
505
|
+
className: "flex flex-col gap-1",
|
|
506
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
507
|
+
className: "text-sm font-bold text-doom-hi",
|
|
508
|
+
children: choice.title
|
|
509
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
510
|
+
className: "text-2xs text-doom-dim",
|
|
511
|
+
children: choice.detail
|
|
512
|
+
})]
|
|
513
|
+
})
|
|
514
|
+
}, choice.method))
|
|
515
|
+
}),
|
|
516
|
+
form.method === "ssh" ? /* @__PURE__ */ jsxs("div", {
|
|
517
|
+
className: "flex flex-col gap-1.5 rounded-lg border border-doom-border bg-doom-panel p-3",
|
|
518
|
+
children: [
|
|
519
|
+
/* @__PURE__ */ jsx(Label, {
|
|
520
|
+
htmlFor: "git-auth-key-path",
|
|
521
|
+
className: "text-xs text-doom-hi",
|
|
522
|
+
children: "key path"
|
|
523
|
+
}),
|
|
524
|
+
/* @__PURE__ */ jsx(Input, {
|
|
525
|
+
id: "git-auth-key-path",
|
|
526
|
+
"data-testid": "git-auth-key-path",
|
|
527
|
+
size: "sm",
|
|
528
|
+
value: form.keyPath,
|
|
529
|
+
placeholder: "~/.ssh/id_ed25519",
|
|
530
|
+
spellCheck: false,
|
|
531
|
+
onChange: (event) => update({ keyPath: event.target.value })
|
|
532
|
+
}),
|
|
533
|
+
/* @__PURE__ */ jsxs("p", {
|
|
534
|
+
className: "text-2xs text-doom-faint",
|
|
535
|
+
children: [
|
|
536
|
+
"Leave it blank to use your ssh config and ssh-agent. A key with a passphrase works only through ssh-agent. An unknown host is refused: run ",
|
|
537
|
+
/* @__PURE__ */ jsx("code", {
|
|
538
|
+
className: "text-doom-dim",
|
|
539
|
+
children: "ssh -T git@github.com"
|
|
540
|
+
}),
|
|
541
|
+
" once in a terminal to trust it."
|
|
542
|
+
]
|
|
543
|
+
})
|
|
544
|
+
]
|
|
545
|
+
}) : null,
|
|
546
|
+
form.method === "https" ? /* @__PURE__ */ jsxs("div", {
|
|
547
|
+
className: "grid grid-cols-1 gap-3 rounded-lg border border-doom-border bg-doom-panel p-3 sm:grid-cols-2",
|
|
548
|
+
children: [
|
|
549
|
+
/* @__PURE__ */ jsxs("div", {
|
|
550
|
+
className: "flex flex-col gap-1.5",
|
|
551
|
+
children: [/* @__PURE__ */ jsx(Label, {
|
|
552
|
+
htmlFor: "git-auth-host",
|
|
553
|
+
className: "text-xs text-doom-hi",
|
|
554
|
+
children: "host"
|
|
555
|
+
}), /* @__PURE__ */ jsx(Input, {
|
|
556
|
+
id: "git-auth-host",
|
|
557
|
+
"data-testid": "git-auth-host",
|
|
558
|
+
size: "sm",
|
|
559
|
+
value: form.host,
|
|
560
|
+
spellCheck: false,
|
|
561
|
+
onChange: (event) => update({ host: event.target.value })
|
|
562
|
+
})]
|
|
563
|
+
}),
|
|
564
|
+
/* @__PURE__ */ jsxs("div", {
|
|
565
|
+
className: "flex flex-col gap-1.5",
|
|
566
|
+
children: [/* @__PURE__ */ jsx(Label, {
|
|
567
|
+
htmlFor: "git-auth-username",
|
|
568
|
+
className: "text-xs text-doom-hi",
|
|
569
|
+
children: "username"
|
|
570
|
+
}), /* @__PURE__ */ jsx(Input, {
|
|
571
|
+
id: "git-auth-username",
|
|
572
|
+
"data-testid": "git-auth-username",
|
|
573
|
+
size: "sm",
|
|
574
|
+
value: form.username,
|
|
575
|
+
autoComplete: "username",
|
|
576
|
+
spellCheck: false,
|
|
577
|
+
onChange: (event) => update({ username: event.target.value })
|
|
578
|
+
})]
|
|
579
|
+
}),
|
|
580
|
+
/* @__PURE__ */ jsxs("div", {
|
|
581
|
+
className: "flex flex-col gap-1.5 sm:col-span-2",
|
|
582
|
+
children: [
|
|
583
|
+
/* @__PURE__ */ jsxs("span", {
|
|
584
|
+
className: "flex items-center gap-2",
|
|
585
|
+
children: [/* @__PURE__ */ jsx(Label, {
|
|
586
|
+
htmlFor: "git-auth-token",
|
|
587
|
+
className: "text-xs text-doom-hi",
|
|
588
|
+
children: "token"
|
|
589
|
+
}), tokenSaved ? /* @__PURE__ */ jsx(StatusBadge, {
|
|
590
|
+
tone: "ok",
|
|
591
|
+
size: "xs",
|
|
592
|
+
"data-testid": "git-auth-token-saved",
|
|
593
|
+
children: "token saved"
|
|
594
|
+
}) : null]
|
|
595
|
+
}),
|
|
596
|
+
/* @__PURE__ */ jsx(Input, {
|
|
597
|
+
id: "git-auth-token",
|
|
598
|
+
"data-testid": "git-auth-token",
|
|
599
|
+
size: "sm",
|
|
600
|
+
type: "password",
|
|
601
|
+
value: form.token,
|
|
602
|
+
autoComplete: "new-password",
|
|
603
|
+
placeholder: tokenSaved ? "leave blank to keep the saved token" : "personal access token",
|
|
604
|
+
onChange: (event) => update({ token: event.target.value })
|
|
605
|
+
}),
|
|
606
|
+
/* @__PURE__ */ jsxs("p", {
|
|
607
|
+
className: "text-2xs text-doom-faint",
|
|
608
|
+
children: [
|
|
609
|
+
"Sent only to ",
|
|
610
|
+
form.host.trim() === "" ? "this host" : form.host.trim(),
|
|
611
|
+
". A remote on any other host keeps using this machine's own git credentials."
|
|
612
|
+
]
|
|
613
|
+
})
|
|
614
|
+
]
|
|
615
|
+
})
|
|
616
|
+
]
|
|
617
|
+
}) : null,
|
|
618
|
+
/* @__PURE__ */ jsxs("div", {
|
|
619
|
+
className: "flex flex-wrap items-center gap-2",
|
|
620
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
621
|
+
variant: "primary",
|
|
622
|
+
size: "sm",
|
|
623
|
+
"data-testid": "git-auth-save",
|
|
624
|
+
disabled: problem !== void 0 || saving,
|
|
625
|
+
loading: saving,
|
|
626
|
+
loadingLabel: "saving",
|
|
627
|
+
onClick: () => onSave(saveRequestOf(form)),
|
|
628
|
+
children: "save"
|
|
629
|
+
}), problem !== void 0 ? /* @__PURE__ */ jsx("span", {
|
|
630
|
+
"data-testid": "git-auth-problem",
|
|
631
|
+
className: "text-2xs text-doom-yellow",
|
|
632
|
+
children: problem
|
|
633
|
+
}) : saveError !== void 0 ? /* @__PURE__ */ jsxs("span", {
|
|
634
|
+
role: "alert",
|
|
635
|
+
"data-testid": "git-auth-error",
|
|
636
|
+
className: "flex items-center gap-1 text-2xs text-doom-red",
|
|
637
|
+
children: [/* @__PURE__ */ jsx(AlertIcon, {
|
|
638
|
+
"aria-hidden": true,
|
|
639
|
+
className: "h-3 w-3"
|
|
640
|
+
}), saveError]
|
|
641
|
+
}) : savedNotice ? /* @__PURE__ */ jsx("span", {
|
|
642
|
+
"data-testid": "git-auth-saved",
|
|
643
|
+
className: "text-2xs text-doom-green",
|
|
644
|
+
children: "saved"
|
|
645
|
+
}) : null]
|
|
646
|
+
}),
|
|
647
|
+
/* @__PURE__ */ jsxs("p", {
|
|
648
|
+
className: "flex items-start gap-1.5 text-2xs text-doom-faint",
|
|
649
|
+
children: [/* @__PURE__ */ jsx(ShieldIcon, {
|
|
650
|
+
"aria-hidden": true,
|
|
651
|
+
className: "mt-px h-3 w-3 shrink-0"
|
|
652
|
+
}), /* @__PURE__ */ jsx("span", { children: "Used only when you pull, push or rebase from this workspace's sessions in the cockpit, worktrees included. The agent's own git commands never see these credentials. Stored outside the repository in ~/.pi/.doom/git/credentials.json, readable only by you, and never in the system keychain." })]
|
|
653
|
+
})
|
|
654
|
+
]
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/extensions/workspaces/(frontend)/repository-settings-panel/_components/GitAuthPanel.tsx
|
|
659
|
+
/**
|
|
660
|
+
* A workspace's git remote panel: loads that workspace's saved auth and saves a
|
|
661
|
+
* new one. Lives under settings, in the repository section the host scopes.
|
|
662
|
+
*
|
|
663
|
+
* DESIGN PATTERNS:
|
|
664
|
+
* - Per workspace. The host hands the selected repository; its opaque id is
|
|
665
|
+
* the only thing sent, and the hub resolves it to an admitted root. A
|
|
666
|
+
* browser never names a path.
|
|
667
|
+
* - Reads through the host's sealed request and writes through its step-up
|
|
668
|
+
* request, so a remote device proves itself with a passkey before it can
|
|
669
|
+
* change credentials. The URLs come from the generated client.
|
|
670
|
+
* - After a save the form remounts from what the server returned, which is how
|
|
671
|
+
* the token field empties and the "token saved" badge appears.
|
|
672
|
+
*/
|
|
673
|
+
function isAuthView(value) {
|
|
674
|
+
return typeof value === "object" && value !== null && [
|
|
675
|
+
"none",
|
|
676
|
+
"ssh",
|
|
677
|
+
"https"
|
|
678
|
+
].includes(String(value.method));
|
|
679
|
+
}
|
|
680
|
+
function errorOf(body, status) {
|
|
681
|
+
return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : `The cockpit answered ${String(status)}.`;
|
|
682
|
+
}
|
|
683
|
+
function GitAuthPanel({ repository, request, requestWithStepUp }) {
|
|
684
|
+
const repositoryId = repository?.id;
|
|
685
|
+
const [loaded, setLoaded] = useState();
|
|
686
|
+
const [loadError, setLoadError] = useState();
|
|
687
|
+
const [saving, setSaving] = useState(false);
|
|
688
|
+
const [saveError, setSaveError] = useState();
|
|
689
|
+
const [savedNotice, setSavedNotice] = useState(false);
|
|
690
|
+
const [generation, setGeneration] = useState(0);
|
|
691
|
+
useEffect(() => {
|
|
692
|
+
if (repositoryId === void 0) return;
|
|
693
|
+
let stale = false;
|
|
694
|
+
request(api.workspace(repositoryId).auth.url({ query: { repositoryId } })).then(async (response) => {
|
|
695
|
+
const body = await response.json().catch(() => void 0);
|
|
696
|
+
if (stale) return;
|
|
697
|
+
if (response.ok && isAuthView(body)) setLoaded({
|
|
698
|
+
repositoryId,
|
|
699
|
+
saved: body
|
|
700
|
+
});
|
|
701
|
+
else setLoadError({
|
|
702
|
+
repositoryId,
|
|
703
|
+
error: errorOf(body, response.status)
|
|
704
|
+
});
|
|
705
|
+
}).catch(() => {
|
|
706
|
+
if (!stale) setLoadError({
|
|
707
|
+
repositoryId,
|
|
708
|
+
error: "The cockpit is unreachable."
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
return () => {
|
|
712
|
+
stale = true;
|
|
713
|
+
};
|
|
714
|
+
}, [repositoryId, request]);
|
|
715
|
+
if (repository === null || repositoryId === void 0) return /* @__PURE__ */ jsx(EmptyState, {
|
|
716
|
+
"data-testid": "git-auth-no-repository",
|
|
717
|
+
className: "py-6",
|
|
718
|
+
title: "pick a repository",
|
|
719
|
+
description: "git remote auth is set per workspace; choose one above."
|
|
720
|
+
});
|
|
721
|
+
const save = async (next) => {
|
|
722
|
+
setSaving(true);
|
|
723
|
+
setSaveError(void 0);
|
|
724
|
+
setSavedNotice(false);
|
|
725
|
+
try {
|
|
726
|
+
const response = await requestWithStepUp(api.workspace(repositoryId).saveAuth.url({ query: { repositoryId } }), {
|
|
727
|
+
method: "PUT",
|
|
728
|
+
headers: { "Content-Type": "application/json" },
|
|
729
|
+
body: JSON.stringify(next)
|
|
730
|
+
});
|
|
731
|
+
const body = await response.json().catch(() => void 0);
|
|
732
|
+
if (response.ok && isAuthView(body)) {
|
|
733
|
+
setLoaded({
|
|
734
|
+
repositoryId,
|
|
735
|
+
saved: body
|
|
736
|
+
});
|
|
737
|
+
setGeneration((current) => current + 1);
|
|
738
|
+
setSavedNotice(true);
|
|
739
|
+
} else setSaveError(errorOf(body, response.status));
|
|
740
|
+
} catch {
|
|
741
|
+
setSaveError("The cockpit is unreachable.");
|
|
742
|
+
} finally {
|
|
743
|
+
setSaving(false);
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
if (loadError?.repositoryId === repositoryId) return /* @__PURE__ */ jsx("p", {
|
|
747
|
+
role: "alert",
|
|
748
|
+
"data-testid": "git-auth-load-error",
|
|
749
|
+
className: "text-xs text-doom-red",
|
|
750
|
+
children: loadError.error
|
|
751
|
+
});
|
|
752
|
+
if (loaded?.repositoryId !== repositoryId) return /* @__PURE__ */ jsxs("span", {
|
|
753
|
+
"data-testid": "git-auth-loading",
|
|
754
|
+
className: "flex items-center gap-2",
|
|
755
|
+
children: [/* @__PURE__ */ jsx(Spinner, {
|
|
756
|
+
className: "h-3 w-3 text-doom-faint",
|
|
757
|
+
label: "loading git settings"
|
|
758
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
759
|
+
className: "text-xs text-doom-dim",
|
|
760
|
+
children: "loading…"
|
|
761
|
+
})]
|
|
762
|
+
});
|
|
763
|
+
return /* @__PURE__ */ jsx(GitAuthForm, {
|
|
764
|
+
saved: loaded.saved,
|
|
765
|
+
saving,
|
|
766
|
+
savedNotice,
|
|
767
|
+
onSave: (next) => void save(next),
|
|
768
|
+
...saveError === void 0 ? {} : { saveError }
|
|
769
|
+
}, `${repositoryId}-${String(generation)}`);
|
|
770
|
+
}
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/extensions/workspaces/(frontend)/repository-settings-panel/git.web.tsx
|
|
773
|
+
var git_web_default$1 = defineRepositorySettingsPanel({
|
|
774
|
+
label: "git remote",
|
|
775
|
+
detail: "how this workspace's sessions reach the remote when you pull, push or rebase from the cockpit",
|
|
776
|
+
order: 50,
|
|
777
|
+
component: GitAuthPanel
|
|
778
|
+
});
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region src/types/gitReview.ts
|
|
781
|
+
/**
|
|
782
|
+
* The cockpit's view of what this session changed against its base: the counts
|
|
783
|
+
* the '# diff' group shows, what the review tab lists and draws, and the manual
|
|
784
|
+
* sync that sits beside them.
|
|
785
|
+
*
|
|
786
|
+
* DESIGN PATTERNS:
|
|
787
|
+
* - Declarations only, shared by the hub and the page.
|
|
788
|
+
* - Its own channel, apart from worktrees. Worktrees are repository
|
|
789
|
+
* management; the diff is this session's code change.
|
|
790
|
+
* - A diff row carries one line number whose file follows from its marker: the
|
|
791
|
+
* new file's for context and additions, the old file's for removals. It is
|
|
792
|
+
* the same shape the shared DiffView draws.
|
|
793
|
+
*
|
|
794
|
+
* AVOID:
|
|
795
|
+
* - Behaviour or node builtins. Browser code imports this file.
|
|
796
|
+
*/
|
|
797
|
+
/** The session channel carrying this checkout's changes and the sync around them. */
|
|
798
|
+
const GIT_CHANGES_TYPE = "git_changes";
|
|
799
|
+
//#endregion
|
|
800
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/gitChangesStore.ts
|
|
801
|
+
/**
|
|
802
|
+
* This session's code change against its base, and the manual sync beside it.
|
|
803
|
+
*
|
|
804
|
+
* DESIGN PATTERNS:
|
|
805
|
+
* - Its own session store and hub channel, apart from worktrees. Worktrees are
|
|
806
|
+
* repository management; this is the session's diff.
|
|
807
|
+
* - The hub is authoritative: it computes the counts, runs pull, push and
|
|
808
|
+
* rebase one at a time, and reports pending, errors and a paused rebase here.
|
|
809
|
+
* - The sender lives in a store rather than a module variable, because a web
|
|
810
|
+
* plugin keeps no mutable module state and the page may reconnect.
|
|
811
|
+
*
|
|
812
|
+
* AVOID:
|
|
813
|
+
* - `sendSessionFrame` for a sync command. That frame is bound for the agent
|
|
814
|
+
* and never reaches a channel's `receive`.
|
|
815
|
+
*/
|
|
816
|
+
const gitChanges = defineSessionStore({
|
|
817
|
+
changes: void 0,
|
|
818
|
+
pending: void 0,
|
|
819
|
+
error: void 0,
|
|
820
|
+
errorTarget: void 0
|
|
821
|
+
});
|
|
822
|
+
/** The page's hub socket sender; undefined before start and after dispose, when a command is dropped. */
|
|
823
|
+
const changesSender = defineGlobalStore(void 0);
|
|
824
|
+
/** Binds the page's hub socket for sync commands. The plugin host calls this once per page. */
|
|
825
|
+
function startGitChangesRuntime(runtime) {
|
|
826
|
+
const send = (frame) => {
|
|
827
|
+
runtime.sendHubFrame(frame);
|
|
828
|
+
};
|
|
829
|
+
changesSender.update(() => send);
|
|
830
|
+
return () => {
|
|
831
|
+
changesSender.update((current) => current === send ? void 0 : current);
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
/** Whether a sync command is running for this session. */
|
|
835
|
+
const gitChangesActivitySource = {
|
|
836
|
+
subscribe(listener) {
|
|
837
|
+
const subscription = gitChanges.store.subscribe(listener);
|
|
838
|
+
return () => subscription.unsubscribe();
|
|
839
|
+
},
|
|
840
|
+
isActive(sessionId) {
|
|
841
|
+
return gitChanges.select(gitChanges.store.state, sessionId).pending !== void 0;
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
function isRecord$2(value) {
|
|
845
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
846
|
+
}
|
|
847
|
+
function optionalText$1(value) {
|
|
848
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
849
|
+
}
|
|
850
|
+
function count(value) {
|
|
851
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
|
|
852
|
+
}
|
|
853
|
+
/** The page never trusts a frame's shape: a malformed field drops, the rest still renders. */
|
|
854
|
+
const MAX_CONFLICTS = 100;
|
|
855
|
+
function optionalChanges(value) {
|
|
856
|
+
if (!isRecord$2(value)) return void 0;
|
|
857
|
+
const added = count(value.added);
|
|
858
|
+
const removed = count(value.removed);
|
|
859
|
+
const files = count(value.files);
|
|
860
|
+
if (added === void 0 || removed === void 0 || files === void 0) return void 0;
|
|
861
|
+
const branch = optionalText$1(value.branch);
|
|
862
|
+
const base = optionalText$1(value.base);
|
|
863
|
+
const upstream = isRecord$2(value.upstream) ? value.upstream : void 0;
|
|
864
|
+
const ahead = count(upstream?.ahead);
|
|
865
|
+
const behind = count(upstream?.behind);
|
|
866
|
+
const upstreamRef = optionalText$1(upstream?.ref);
|
|
867
|
+
const conflicts = isRecord$2(value.rebase) && Array.isArray(value.rebase.conflicts) ? value.rebase.conflicts.filter((file) => typeof file === "string").slice(0, MAX_CONFLICTS) : void 0;
|
|
868
|
+
return {
|
|
869
|
+
added,
|
|
870
|
+
removed,
|
|
871
|
+
files,
|
|
872
|
+
...branch === void 0 ? {} : { branch },
|
|
873
|
+
...base === void 0 ? {} : { base },
|
|
874
|
+
...value.truncated === true ? { truncated: true } : {},
|
|
875
|
+
...upstreamRef === void 0 || ahead === void 0 || behind === void 0 ? {} : { upstream: {
|
|
876
|
+
ref: upstreamRef,
|
|
877
|
+
ahead,
|
|
878
|
+
behind
|
|
879
|
+
} },
|
|
880
|
+
...conflicts === void 0 ? {} : { rebase: { conflicts } }
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
function optionalErrorTarget$1(value) {
|
|
884
|
+
if (!isRecord$2(value)) return void 0;
|
|
885
|
+
if (value.action === "pull" || value.action === "rebase" || value.action === "abort-rebase") return { action: value.action };
|
|
886
|
+
if (value.action === "push") return value.forceRequired === true ? {
|
|
887
|
+
action: "push",
|
|
888
|
+
forceRequired: true
|
|
889
|
+
} : { action: "push" };
|
|
890
|
+
}
|
|
891
|
+
/** The session data channel: 'git_changes' payloads into the store. */
|
|
892
|
+
const gitChangesChannel = gitChanges.channel({
|
|
893
|
+
channel: GIT_CHANGES_TYPE,
|
|
894
|
+
parse(input) {
|
|
895
|
+
if (!isRecord$2(input)) return null;
|
|
896
|
+
return {
|
|
897
|
+
changes: optionalChanges(input.changes),
|
|
898
|
+
pending: optionalText$1(input.pending),
|
|
899
|
+
error: optionalText$1(input.error),
|
|
900
|
+
errorTarget: optionalErrorTarget$1(input.errorTarget)
|
|
901
|
+
};
|
|
902
|
+
},
|
|
903
|
+
reduce(_current, next) {
|
|
904
|
+
return next;
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
/**
|
|
908
|
+
* Asks the hub to pull, push, rebase or abort a paused rebase. `force` is only
|
|
909
|
+
* ever sent after the reader confirmed a lease force for a rejected push.
|
|
910
|
+
*/
|
|
911
|
+
function requestGitSync(sessionId, action, force = false) {
|
|
912
|
+
const payload = action === "push" && force ? {
|
|
913
|
+
action: "push",
|
|
914
|
+
force: true
|
|
915
|
+
} : { action };
|
|
916
|
+
changesSender.store.state?.({
|
|
917
|
+
type: GIT_CHANGES_TYPE,
|
|
918
|
+
sessionId,
|
|
919
|
+
payload
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
//#endregion
|
|
923
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewApi.ts
|
|
924
|
+
/**
|
|
925
|
+
* The review tab's half of the session API: the changed-file list and one
|
|
926
|
+
* file's hunks.
|
|
927
|
+
*
|
|
928
|
+
* The generated client owns the URL and the sealed transport, so nothing here
|
|
929
|
+
* spells a route or reaches for `fetch`. What travels is file contents, which a
|
|
930
|
+
* tunnel's relay must never see in the clear.
|
|
931
|
+
*/
|
|
932
|
+
const UNREACHABLE = "The session is unreachable.";
|
|
933
|
+
function isRecord$1(value) {
|
|
934
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
935
|
+
}
|
|
936
|
+
/** The message a reader is shown: the route's own words, or why there were none. */
|
|
937
|
+
function messageOf(result) {
|
|
938
|
+
if (result.status === 0) return UNREACHABLE;
|
|
939
|
+
return result.error === "" ? `The session answered ${String(result.status)}.` : result.error;
|
|
940
|
+
}
|
|
941
|
+
async function fetchReviewSummary(sessionId, signal) {
|
|
942
|
+
const result = await api.session(sessionId).review(signal === void 0 ? {} : { signal });
|
|
943
|
+
if (!result.ok) return {
|
|
944
|
+
ok: false,
|
|
945
|
+
error: messageOf(result)
|
|
946
|
+
};
|
|
947
|
+
if (!isRecord$1(result.data) || !Array.isArray(result.data.files)) return {
|
|
948
|
+
ok: false,
|
|
949
|
+
error: "The session answered with no file list."
|
|
950
|
+
};
|
|
951
|
+
return {
|
|
952
|
+
ok: true,
|
|
953
|
+
summary: result.data
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
async function fetchReviewFile(sessionId, path, signal) {
|
|
957
|
+
const result = await api.session(sessionId).reviewFile({
|
|
958
|
+
query: { path },
|
|
959
|
+
...signal === void 0 ? {} : { signal }
|
|
960
|
+
});
|
|
961
|
+
if (!result.ok) return {
|
|
962
|
+
ok: false,
|
|
963
|
+
error: messageOf(result)
|
|
964
|
+
};
|
|
965
|
+
if (!isRecord$1(result.data) || !Array.isArray(result.data.hunks)) return {
|
|
966
|
+
ok: false,
|
|
967
|
+
error: "The session answered with no diff."
|
|
968
|
+
};
|
|
969
|
+
return {
|
|
970
|
+
ok: true,
|
|
971
|
+
diff: result.data
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewStore.ts
|
|
976
|
+
const gitReview = defineSessionStore({ comments: [] });
|
|
977
|
+
function addReviewComment(sessionId, comment) {
|
|
978
|
+
const id = globalThis.crypto.randomUUID();
|
|
979
|
+
gitReview.update(sessionId, (current) => ({ comments: [...current.comments, {
|
|
980
|
+
...comment,
|
|
981
|
+
id
|
|
982
|
+
}] }));
|
|
983
|
+
}
|
|
984
|
+
function removeReviewComment(sessionId, id) {
|
|
985
|
+
gitReview.update(sessionId, (current) => ({ comments: current.comments.filter((comment) => comment.id !== id) }));
|
|
986
|
+
}
|
|
987
|
+
function clearReviewComments(sessionId) {
|
|
988
|
+
gitReview.update(sessionId, () => ({ comments: [] }));
|
|
989
|
+
}
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewView.ts
|
|
992
|
+
/**
|
|
993
|
+
* Pure wording for what the review tab sends the agent, and the one send.
|
|
994
|
+
*
|
|
995
|
+
* Both messages go as `enqueue_automatic`: the host stores it and starts a turn
|
|
996
|
+
* at once when the agent is idle, or after the current one. A `prompt` frame
|
|
997
|
+
* instead fails while a turn runs, and a review lost to that is the worst kind
|
|
998
|
+
* of lost work.
|
|
999
|
+
*/
|
|
1000
|
+
/** The opening line of a review, naming what was reviewed. */
|
|
1001
|
+
function reviewHeading(count, branch, base) {
|
|
1002
|
+
return `I reviewed ${branch === void 0 ? "this checkout" : `branch ${branch}`} (${base === void 0 ? "its uncommitted changes" : `its changes against ${base}`}) and left ${count === 1 ? "one review comment" : `${String(count)} review comments`}. ${count === 1 ? "Please address it." : "Please address them all."}`;
|
|
1003
|
+
}
|
|
1004
|
+
/** What "ask agent to resolve" sends: the files, and the steps that finish a paused rebase. */
|
|
1005
|
+
function conflictPrompt(branch, conflicts) {
|
|
1006
|
+
const files = conflicts.length === 0 ? "- (git reports no unmerged paths yet)" : conflicts.map((file) => `- ${file}`).join("\n");
|
|
1007
|
+
return [
|
|
1008
|
+
`A rebase of ${branch ?? "this checkout"} stopped on conflicts in:`,
|
|
1009
|
+
"",
|
|
1010
|
+
files,
|
|
1011
|
+
"",
|
|
1012
|
+
"Please resolve them:",
|
|
1013
|
+
"1. Fix the conflict markers in each file, keeping the intent of both sides.",
|
|
1014
|
+
"2. `git add` each resolved file.",
|
|
1015
|
+
"3. Run `GIT_EDITOR=true git rebase --continue`, and repeat if it stops again.",
|
|
1016
|
+
"",
|
|
1017
|
+
"Do not push. I will push from the cockpit once the rebase is done."
|
|
1018
|
+
].join("\n");
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Hands one message to the session's queue. Returns the reason it failed, or
|
|
1022
|
+
* undefined once the host took it. The sender throws when the session is not
|
|
1023
|
+
* connected, which is the only failure a caller can still act on.
|
|
1024
|
+
*/
|
|
1025
|
+
function deliverToAgent(send, sessionId, message) {
|
|
1026
|
+
try {
|
|
1027
|
+
send(sessionId, {
|
|
1028
|
+
type: "enqueue_automatic",
|
|
1029
|
+
message
|
|
1030
|
+
});
|
|
1031
|
+
return;
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
return error instanceof Error ? error.message : "The session did not take the message.";
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
//#endregion
|
|
1037
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewBrowser.tsx
|
|
1038
|
+
/**
|
|
1039
|
+
* The review tab's file list: every changed file, grouped the way the files
|
|
1040
|
+
* drawer groups them, with its status, its line counts and its comments.
|
|
1041
|
+
*
|
|
1042
|
+
* DESIGN PATTERNS:
|
|
1043
|
+
* - Picking a row scrolls the diff pane to that file; the list never opens a
|
|
1044
|
+
* tab of its own. One tab holds the whole review.
|
|
1045
|
+
* - Grouped while the whole list is in view, flat while a search is active:
|
|
1046
|
+
* a reader searching is pinpointing one path, and headers around two matches
|
|
1047
|
+
* only get in the way.
|
|
1048
|
+
* - Each row is a button, reachable with Tab; arrow keys move and pick in one
|
|
1049
|
+
* step, so the diff follows the cursor.
|
|
1050
|
+
*
|
|
1051
|
+
* AVOID:
|
|
1052
|
+
* - Colour classes outside the token set.
|
|
1053
|
+
*/
|
|
1054
|
+
const STATUS_MARK = {
|
|
1055
|
+
added: {
|
|
1056
|
+
letter: "A",
|
|
1057
|
+
tone: "text-doom-green",
|
|
1058
|
+
label: "added"
|
|
1059
|
+
},
|
|
1060
|
+
modified: {
|
|
1061
|
+
letter: "M",
|
|
1062
|
+
tone: "text-doom-yellow",
|
|
1063
|
+
label: "modified"
|
|
1064
|
+
},
|
|
1065
|
+
deleted: {
|
|
1066
|
+
letter: "D",
|
|
1067
|
+
tone: "text-doom-red",
|
|
1068
|
+
label: "deleted"
|
|
1069
|
+
},
|
|
1070
|
+
untracked: {
|
|
1071
|
+
letter: "U",
|
|
1072
|
+
tone: "text-doom-blue",
|
|
1073
|
+
label: "untracked"
|
|
1074
|
+
},
|
|
1075
|
+
conflicted: {
|
|
1076
|
+
letter: "C",
|
|
1077
|
+
tone: "text-doom-red",
|
|
1078
|
+
label: "conflicted"
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
function GitReviewBrowser({ files, activePath, commentCounts, onPick }) {
|
|
1082
|
+
const [query, setQuery] = useState("");
|
|
1083
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
1084
|
+
const shown = needle === "" ? files : files.filter((file) => file.path.toLocaleLowerCase().includes(needle));
|
|
1085
|
+
const groups = needle === "" ? groupByDirectory(shown, (file) => file.path) : [{
|
|
1086
|
+
prefix: "",
|
|
1087
|
+
items: [...shown]
|
|
1088
|
+
}];
|
|
1089
|
+
const ordered = groups.flatMap((group) => group.items);
|
|
1090
|
+
/** Arrow keys pick the neighbouring row and move focus with it, so the diff follows the cursor. */
|
|
1091
|
+
const onKeyDown = (event) => {
|
|
1092
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
|
1093
|
+
if (ordered.length === 0) return;
|
|
1094
|
+
event.preventDefault();
|
|
1095
|
+
const current = ordered.findIndex((file) => file.path === activePath);
|
|
1096
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
1097
|
+
const next = current === -1 ? 0 : (current + step + ordered.length) % ordered.length;
|
|
1098
|
+
const file = ordered[next];
|
|
1099
|
+
if (file === void 0) return;
|
|
1100
|
+
onPick(file.path);
|
|
1101
|
+
event.currentTarget.closest("[data-git-review-list]")?.querySelectorAll("[data-git-review-row]")[next]?.focus();
|
|
1102
|
+
};
|
|
1103
|
+
return /* @__PURE__ */ jsxs("nav", {
|
|
1104
|
+
"data-testid": "git-review-browser",
|
|
1105
|
+
"aria-label": "changed files",
|
|
1106
|
+
className: "flex min-h-0 w-64 shrink-0 flex-col border-r border-doom-border-soft",
|
|
1107
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
1108
|
+
className: "flex shrink-0 flex-col gap-1 px-3 pt-2.5 pb-1.5",
|
|
1109
|
+
children: [/* @__PURE__ */ jsx(Input, {
|
|
1110
|
+
"data-testid": "git-review-search",
|
|
1111
|
+
"aria-label": "search changed file paths",
|
|
1112
|
+
size: "sm",
|
|
1113
|
+
value: query,
|
|
1114
|
+
placeholder: "search paths…",
|
|
1115
|
+
onChange: (event) => setQuery(event.target.value)
|
|
1116
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1117
|
+
className: "text-2xs text-doom-faint",
|
|
1118
|
+
children: needle === "" ? `${String(files.length)} changed` : `${String(shown.length)} of ${String(files.length)} match`
|
|
1119
|
+
})]
|
|
1120
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1121
|
+
"data-git-review-list": true,
|
|
1122
|
+
className: "min-h-0 flex-1 overflow-y-auto pb-2",
|
|
1123
|
+
children: [groups.map((group) => /* @__PURE__ */ jsxs("div", { children: [group.prefix === "" ? null : /* @__PURE__ */ jsx("div", {
|
|
1124
|
+
className: "truncate px-3 pt-2 pb-1 text-2xs font-bold tracking-wide text-doom-faint",
|
|
1125
|
+
children: group.prefix
|
|
1126
|
+
}), group.items.map((file) => {
|
|
1127
|
+
const mark = STATUS_MARK[file.status];
|
|
1128
|
+
const comments = commentCounts[file.path] ?? 0;
|
|
1129
|
+
const active = file.path === activePath;
|
|
1130
|
+
return /* @__PURE__ */ jsxs(Button, {
|
|
1131
|
+
variant: "ghost",
|
|
1132
|
+
size: "card",
|
|
1133
|
+
"data-git-review-row": true,
|
|
1134
|
+
"aria-current": active ? "true" : void 0,
|
|
1135
|
+
"data-testid": `git-review-browser-file-${file.path}`,
|
|
1136
|
+
title: `${file.path} (${mark.label})`,
|
|
1137
|
+
onClick: () => onPick(file.path),
|
|
1138
|
+
onKeyDown,
|
|
1139
|
+
className: `min-w-0 flex-row items-center gap-1.5 rounded-none border-l-2 py-1.5 ${group.prefix === "" ? "px-3" : "pr-3 pl-5"} ${active ? "border-doom-blue bg-doom-panel" : "border-transparent hover:bg-doom-panel/60"}`,
|
|
1140
|
+
children: [
|
|
1141
|
+
/* @__PURE__ */ jsx("span", {
|
|
1142
|
+
"aria-label": mark.label,
|
|
1143
|
+
className: `w-2.5 shrink-0 text-2xs font-bold ${mark.tone}`,
|
|
1144
|
+
children: mark.letter
|
|
1145
|
+
}),
|
|
1146
|
+
/* @__PURE__ */ jsx("span", {
|
|
1147
|
+
className: "min-w-0 flex-1 truncate text-xs font-bold text-doom-hi",
|
|
1148
|
+
children: groupRowLabel(group.prefix, file.path)
|
|
1149
|
+
}),
|
|
1150
|
+
comments === 0 ? null : /* @__PURE__ */ jsxs("span", {
|
|
1151
|
+
className: "flex shrink-0 items-center gap-0.5 text-2xs text-doom-blue",
|
|
1152
|
+
title: `${String(comments)} comments`,
|
|
1153
|
+
children: [/* @__PURE__ */ jsx(MessageIcon, {
|
|
1154
|
+
"aria-hidden": true,
|
|
1155
|
+
className: "h-2.5 w-2.5"
|
|
1156
|
+
}), comments]
|
|
1157
|
+
}),
|
|
1158
|
+
file.binary ? /* @__PURE__ */ jsx("span", {
|
|
1159
|
+
className: "shrink-0 text-2xs text-doom-faint",
|
|
1160
|
+
children: "binary"
|
|
1161
|
+
}) : /* @__PURE__ */ jsxs("span", {
|
|
1162
|
+
className: "shrink-0 text-2xs",
|
|
1163
|
+
children: [
|
|
1164
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1165
|
+
className: "text-doom-green",
|
|
1166
|
+
children: ["+", file.added]
|
|
1167
|
+
}),
|
|
1168
|
+
" ",
|
|
1169
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1170
|
+
className: "text-doom-red",
|
|
1171
|
+
children: ["-", file.removed]
|
|
1172
|
+
})
|
|
1173
|
+
]
|
|
1174
|
+
})
|
|
1175
|
+
]
|
|
1176
|
+
}, file.path);
|
|
1177
|
+
})] }, group.prefix === "" ? "(root)" : group.prefix)), ordered.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
1178
|
+
className: "px-3 py-2 text-2xs text-doom-faint",
|
|
1179
|
+
children: "no path matches"
|
|
1180
|
+
}) : null]
|
|
1181
|
+
})]
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
//#endregion
|
|
1185
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_components/GitSyncBar.tsx
|
|
1186
|
+
/**
|
|
1187
|
+
* The review tab's header: what is being reviewed, and the manual sync that
|
|
1188
|
+
* goes with it.
|
|
1189
|
+
*
|
|
1190
|
+
* DESIGN PATTERNS:
|
|
1191
|
+
* - Everything here is a report or a request. The hub runs the git, one
|
|
1192
|
+
* command per session, and answers on the channel: pending while it runs,
|
|
1193
|
+
* an error target when it fails, a paused rebase when it stops on conflicts.
|
|
1194
|
+
* - A push the remote rejects is not an error to shrug at: the strip asks
|
|
1195
|
+
* before forcing, and the force it sends is a lease, which git refuses if
|
|
1196
|
+
* the remote holds commits this branch never fetched and integrated.
|
|
1197
|
+
* - A paused rebase blocks the sync buttons. The two ways out are named on the
|
|
1198
|
+
* banner, and abort says what it throws away.
|
|
1199
|
+
*
|
|
1200
|
+
* AVOID:
|
|
1201
|
+
* - Colour classes outside the token set.
|
|
1202
|
+
*/
|
|
1203
|
+
function GitSyncBar({ changes, mergeBase, pending, error, errorTarget, onSync, onForcePush, onAskAgent, onDismissError }) {
|
|
1204
|
+
const busy = pending !== void 0;
|
|
1205
|
+
const paused = changes?.rebase !== void 0;
|
|
1206
|
+
const forceRequired = errorTarget?.action === "push" && "forceRequired" in errorTarget && errorTarget.forceRequired === true;
|
|
1207
|
+
const syncError = error !== void 0 && !forceRequired ? error : void 0;
|
|
1208
|
+
const upstream = changes?.upstream;
|
|
1209
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1210
|
+
"data-testid": "git-sync-bar",
|
|
1211
|
+
className: "flex shrink-0 flex-col gap-2 border-b border-doom-border-soft px-3 py-2.5 sm:px-[26px]",
|
|
1212
|
+
children: [
|
|
1213
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1214
|
+
className: "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5",
|
|
1215
|
+
children: [
|
|
1216
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1217
|
+
className: "flex min-w-0 items-center gap-1.5",
|
|
1218
|
+
children: [
|
|
1219
|
+
/* @__PURE__ */ jsx(BranchIcon, {
|
|
1220
|
+
"aria-hidden": true,
|
|
1221
|
+
className: "h-3.5 w-3.5 shrink-0 text-doom-faint"
|
|
1222
|
+
}),
|
|
1223
|
+
/* @__PURE__ */ jsx("span", {
|
|
1224
|
+
"data-testid": "git-review-branch",
|
|
1225
|
+
className: "truncate text-sm font-bold text-doom-hi",
|
|
1226
|
+
children: changes?.branch ?? "detached HEAD"
|
|
1227
|
+
}),
|
|
1228
|
+
/* @__PURE__ */ jsx("span", {
|
|
1229
|
+
className: "shrink-0 text-xs text-doom-faint",
|
|
1230
|
+
children: changes?.base === void 0 ? "uncommitted changes" : `vs ${changes.base}`
|
|
1231
|
+
}),
|
|
1232
|
+
mergeBase === void 0 ? null : /* @__PURE__ */ jsxs("span", {
|
|
1233
|
+
className: "shrink-0 text-2xs text-doom-faint",
|
|
1234
|
+
children: ["from ", mergeBase]
|
|
1235
|
+
})
|
|
1236
|
+
]
|
|
1237
|
+
}),
|
|
1238
|
+
changes === void 0 ? null : /* @__PURE__ */ jsxs("span", {
|
|
1239
|
+
"data-testid": "git-review-totals",
|
|
1240
|
+
className: "flex shrink-0 items-center gap-1.5 text-xs",
|
|
1241
|
+
children: [
|
|
1242
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1243
|
+
className: "font-bold text-doom-green",
|
|
1244
|
+
children: ["+", changes.added]
|
|
1245
|
+
}),
|
|
1246
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1247
|
+
className: "font-bold text-doom-red",
|
|
1248
|
+
children: ["-", changes.removed]
|
|
1249
|
+
}),
|
|
1250
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1251
|
+
className: "text-doom-faint",
|
|
1252
|
+
children: [
|
|
1253
|
+
changes.files,
|
|
1254
|
+
changes.truncated ? "+" : "",
|
|
1255
|
+
" ",
|
|
1256
|
+
changes.files === 1 ? "file" : "files"
|
|
1257
|
+
]
|
|
1258
|
+
})
|
|
1259
|
+
]
|
|
1260
|
+
}),
|
|
1261
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
1262
|
+
upstream === void 0 ? /* @__PURE__ */ jsx("span", {
|
|
1263
|
+
className: "shrink-0 text-2xs text-doom-faint",
|
|
1264
|
+
children: "no upstream yet"
|
|
1265
|
+
}) : /* @__PURE__ */ jsxs("span", {
|
|
1266
|
+
className: "shrink-0 text-2xs text-doom-faint",
|
|
1267
|
+
title: upstream.ref,
|
|
1268
|
+
children: [
|
|
1269
|
+
"↑",
|
|
1270
|
+
upstream.ahead,
|
|
1271
|
+
" ↓",
|
|
1272
|
+
upstream.behind,
|
|
1273
|
+
" ",
|
|
1274
|
+
upstream.ref
|
|
1275
|
+
]
|
|
1276
|
+
}),
|
|
1277
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1278
|
+
className: "flex shrink-0 items-center gap-1.5",
|
|
1279
|
+
children: [
|
|
1280
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1281
|
+
size: "xs",
|
|
1282
|
+
"data-testid": "git-sync-pull",
|
|
1283
|
+
disabled: busy || paused || upstream === void 0,
|
|
1284
|
+
title: upstream === void 0 ? "push once to set an upstream" : `fetch, then rebase onto ${upstream.ref}`,
|
|
1285
|
+
onClick: () => onSync("pull"),
|
|
1286
|
+
children: "pull"
|
|
1287
|
+
}),
|
|
1288
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1289
|
+
size: "xs",
|
|
1290
|
+
"data-testid": "git-sync-push",
|
|
1291
|
+
disabled: busy || paused || changes?.branch === void 0,
|
|
1292
|
+
title: upstream === void 0 ? "push and set the upstream" : `push to ${upstream.ref}`,
|
|
1293
|
+
onClick: () => onSync("push"),
|
|
1294
|
+
children: "push"
|
|
1295
|
+
}),
|
|
1296
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1297
|
+
size: "xs",
|
|
1298
|
+
"data-testid": "git-sync-rebase",
|
|
1299
|
+
disabled: busy || paused || changes?.base === void 0,
|
|
1300
|
+
title: changes?.base === void 0 ? "no base branch to rebase onto" : `fetch, then rebase onto ${changes.base}`,
|
|
1301
|
+
onClick: () => onSync("rebase"),
|
|
1302
|
+
children: "rebase"
|
|
1303
|
+
})
|
|
1304
|
+
]
|
|
1305
|
+
})
|
|
1306
|
+
]
|
|
1307
|
+
}),
|
|
1308
|
+
pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
|
|
1309
|
+
"data-testid": "git-sync-pending",
|
|
1310
|
+
className: "flex min-w-0 items-center gap-1.5",
|
|
1311
|
+
children: [/* @__PURE__ */ jsx(Spinner, {
|
|
1312
|
+
className: "h-3 w-3 shrink-0 text-doom-faint",
|
|
1313
|
+
label: pending
|
|
1314
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1315
|
+
className: "min-w-0 truncate text-xs text-doom-dim",
|
|
1316
|
+
children: pending
|
|
1317
|
+
})]
|
|
1318
|
+
}),
|
|
1319
|
+
syncError === void 0 ? null : /* @__PURE__ */ jsxs("div", {
|
|
1320
|
+
role: "alert",
|
|
1321
|
+
"data-testid": "git-sync-error",
|
|
1322
|
+
className: "flex min-w-0 items-start gap-1.5 text-2xs text-doom-red",
|
|
1323
|
+
children: [
|
|
1324
|
+
/* @__PURE__ */ jsx(AlertIcon, {
|
|
1325
|
+
"aria-hidden": true,
|
|
1326
|
+
className: "mt-px h-3 w-3 shrink-0"
|
|
1327
|
+
}),
|
|
1328
|
+
/* @__PURE__ */ jsx("span", {
|
|
1329
|
+
className: "min-w-0 flex-1",
|
|
1330
|
+
children: syncError
|
|
1331
|
+
}),
|
|
1332
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1333
|
+
variant: "ghost",
|
|
1334
|
+
size: "xs",
|
|
1335
|
+
className: "h-auto min-h-0 px-1 py-0 text-2xs",
|
|
1336
|
+
onClick: onDismissError,
|
|
1337
|
+
children: "dismiss"
|
|
1338
|
+
})
|
|
1339
|
+
]
|
|
1340
|
+
}),
|
|
1341
|
+
forceRequired ? /* @__PURE__ */ jsxs("div", {
|
|
1342
|
+
"data-testid": "git-push-force-confirm",
|
|
1343
|
+
className: "flex min-w-0 flex-wrap items-center gap-2 rounded-md border border-doom-edge-yellow bg-doom-tint-yellow px-2.5 py-2",
|
|
1344
|
+
children: [
|
|
1345
|
+
/* @__PURE__ */ jsx("span", {
|
|
1346
|
+
className: "min-w-0 flex-1 text-xs text-doom-hi",
|
|
1347
|
+
children: "The remote branch has commits yours replaced, usually after a rebase. Force push with lease? It is refused if the remote holds commits you never fetched and integrated."
|
|
1348
|
+
}),
|
|
1349
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1350
|
+
variant: "danger-outline",
|
|
1351
|
+
size: "xs",
|
|
1352
|
+
"data-testid": "git-push-force",
|
|
1353
|
+
disabled: busy,
|
|
1354
|
+
onClick: onForcePush,
|
|
1355
|
+
children: "force push"
|
|
1356
|
+
}),
|
|
1357
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1358
|
+
variant: "ghost",
|
|
1359
|
+
size: "xs",
|
|
1360
|
+
"data-testid": "git-push-force-cancel",
|
|
1361
|
+
onClick: onDismissError,
|
|
1362
|
+
children: "cancel"
|
|
1363
|
+
})
|
|
1364
|
+
]
|
|
1365
|
+
}) : null,
|
|
1366
|
+
changes?.rebase === void 0 ? null : /* @__PURE__ */ jsxs("div", {
|
|
1367
|
+
"data-testid": "git-rebase-banner",
|
|
1368
|
+
className: "flex min-w-0 flex-col gap-1.5 rounded-md border border-doom-edge-red bg-doom-tint-red px-2.5 py-2",
|
|
1369
|
+
children: [
|
|
1370
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1371
|
+
className: "flex min-w-0 items-center gap-1.5",
|
|
1372
|
+
children: [/* @__PURE__ */ jsx(StatusBadge, {
|
|
1373
|
+
tone: "error",
|
|
1374
|
+
size: "xs",
|
|
1375
|
+
children: "rebase paused"
|
|
1376
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1377
|
+
className: "text-xs text-doom-hi",
|
|
1378
|
+
children: changes.rebase.conflicts.length === 0 ? "git stopped the rebase" : `conflicts in ${String(changes.rebase.conflicts.length)} ${changes.rebase.conflicts.length === 1 ? "file" : "files"}`
|
|
1379
|
+
})]
|
|
1380
|
+
}),
|
|
1381
|
+
changes.rebase.conflicts.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
|
|
1382
|
+
className: "flex flex-col gap-0.5 pl-1",
|
|
1383
|
+
children: changes.rebase.conflicts.map((file) => /* @__PURE__ */ jsx("li", {
|
|
1384
|
+
className: "truncate font-mono text-2xs text-doom-dim",
|
|
1385
|
+
children: file
|
|
1386
|
+
}, file))
|
|
1387
|
+
}),
|
|
1388
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1389
|
+
className: "flex flex-wrap items-center gap-1.5",
|
|
1390
|
+
children: [
|
|
1391
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1392
|
+
variant: "primary",
|
|
1393
|
+
size: "xs",
|
|
1394
|
+
"data-testid": "git-rebase-ask-agent",
|
|
1395
|
+
disabled: busy,
|
|
1396
|
+
onClick: onAskAgent,
|
|
1397
|
+
children: "ask agent to resolve"
|
|
1398
|
+
}),
|
|
1399
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1400
|
+
variant: "danger-outline",
|
|
1401
|
+
size: "xs",
|
|
1402
|
+
"data-testid": "git-rebase-abort",
|
|
1403
|
+
disabled: busy,
|
|
1404
|
+
onClick: () => onSync("abort-rebase"),
|
|
1405
|
+
children: "abort rebase"
|
|
1406
|
+
}),
|
|
1407
|
+
/* @__PURE__ */ jsx("span", {
|
|
1408
|
+
className: "text-2xs text-doom-faint",
|
|
1409
|
+
children: "abort discards any conflict resolution in progress"
|
|
1410
|
+
})
|
|
1411
|
+
]
|
|
1412
|
+
})
|
|
1413
|
+
]
|
|
1414
|
+
})
|
|
1415
|
+
]
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
const NO_FILES = [];
|
|
1419
|
+
/** Stable, URL-safe element id for one file's section. */
|
|
1420
|
+
function sectionId(index) {
|
|
1421
|
+
return `git-review-file-${String(index)}`;
|
|
1422
|
+
}
|
|
1423
|
+
function sideOf(row) {
|
|
1424
|
+
return row.marker === "-" ? "old" : "new";
|
|
1425
|
+
}
|
|
1426
|
+
function GitReviewView({ sync, summary, files, comments, onLoadFile, onAddComment, onRemoveComment, onSendReview, onDiscard, sendError, initialDraft }) {
|
|
1427
|
+
const entries = summary.state === "ready" ? summary.summary.files : NO_FILES;
|
|
1428
|
+
const [openOverride, setOpenOverride] = useState({});
|
|
1429
|
+
const [picked, setPicked] = useState();
|
|
1430
|
+
const activePath = picked ?? entries[0]?.path;
|
|
1431
|
+
const [draft, setDraft] = useState(initialDraft);
|
|
1432
|
+
useEffect(() => {
|
|
1433
|
+
for (const file of entries.slice(0, 20)) if (files[file.path] === void 0) onLoadFile(file.path);
|
|
1434
|
+
}, [
|
|
1435
|
+
entries,
|
|
1436
|
+
files,
|
|
1437
|
+
onLoadFile
|
|
1438
|
+
]);
|
|
1439
|
+
const commentCounts = {};
|
|
1440
|
+
for (const comment of comments) commentCounts[comment.path] = (commentCounts[comment.path] ?? 0) + 1;
|
|
1441
|
+
const isOpen = (path, index) => openOverride[path] ?? index < 20;
|
|
1442
|
+
const open = (path) => {
|
|
1443
|
+
setOpenOverride((current) => current[path] === true ? current : {
|
|
1444
|
+
...current,
|
|
1445
|
+
[path]: true
|
|
1446
|
+
});
|
|
1447
|
+
if (files[path] === void 0) onLoadFile(path);
|
|
1448
|
+
};
|
|
1449
|
+
const pick = (path) => {
|
|
1450
|
+
setPicked(path);
|
|
1451
|
+
open(path);
|
|
1452
|
+
const index = entries.findIndex((file) => file.path === path);
|
|
1453
|
+
globalThis.document?.getElementById(sectionId(index))?.scrollIntoView({ block: "start" });
|
|
1454
|
+
};
|
|
1455
|
+
const toggle = (path, index) => {
|
|
1456
|
+
if (isOpen(path, index)) setOpenOverride((current) => ({
|
|
1457
|
+
...current,
|
|
1458
|
+
[path]: false
|
|
1459
|
+
}));
|
|
1460
|
+
else open(path);
|
|
1461
|
+
};
|
|
1462
|
+
const submitDraft = (body) => {
|
|
1463
|
+
if (draft === void 0) return;
|
|
1464
|
+
onAddComment({
|
|
1465
|
+
path: draft.path,
|
|
1466
|
+
relPath: draft.path,
|
|
1467
|
+
side: draft.selection.side,
|
|
1468
|
+
startLine: draft.selection.startLine,
|
|
1469
|
+
endLine: draft.selection.endLine,
|
|
1470
|
+
snippet: draft.selection.snippet,
|
|
1471
|
+
body: body.trim()
|
|
1472
|
+
});
|
|
1473
|
+
setDraft(void 0);
|
|
1474
|
+
};
|
|
1475
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1476
|
+
"data-testid": "git-review-panel",
|
|
1477
|
+
className: "flex min-h-0 flex-1 flex-col",
|
|
1478
|
+
children: [
|
|
1479
|
+
/* @__PURE__ */ jsx(GitSyncBar, {
|
|
1480
|
+
...sync,
|
|
1481
|
+
...summary.state === "ready" && summary.summary.mergeBase !== void 0 ? { mergeBase: summary.summary.mergeBase } : {}
|
|
1482
|
+
}),
|
|
1483
|
+
summary.state === "loading" ? /* @__PURE__ */ jsxs("div", {
|
|
1484
|
+
"data-testid": "git-review-loading",
|
|
1485
|
+
className: "flex flex-1 items-start gap-2 px-4 py-6",
|
|
1486
|
+
children: [/* @__PURE__ */ jsx(Spinner, {
|
|
1487
|
+
className: "h-3 w-3 text-doom-faint",
|
|
1488
|
+
label: "reading changes"
|
|
1489
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1490
|
+
className: "text-xs text-doom-dim",
|
|
1491
|
+
children: "reading changes…"
|
|
1492
|
+
})]
|
|
1493
|
+
}) : summary.state === "error" ? /* @__PURE__ */ jsx("p", {
|
|
1494
|
+
role: "alert",
|
|
1495
|
+
"data-testid": "git-review-error",
|
|
1496
|
+
className: "flex-1 px-4 py-6 text-xs text-doom-red",
|
|
1497
|
+
children: summary.error
|
|
1498
|
+
}) : !summary.summary.repository ? /* @__PURE__ */ jsx(EmptyState, {
|
|
1499
|
+
"data-testid": "git-review-no-repository",
|
|
1500
|
+
className: "px-4 py-8",
|
|
1501
|
+
title: "not a git checkout",
|
|
1502
|
+
description: "this session's folder is not inside a git repository, so there is nothing to review."
|
|
1503
|
+
}) : entries.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
|
|
1504
|
+
"data-testid": "git-review-empty",
|
|
1505
|
+
className: "px-4 py-8",
|
|
1506
|
+
title: "no changes",
|
|
1507
|
+
description: summary.summary.base === void 0 ? "nothing uncommitted in this checkout." : `nothing differs from ${summary.summary.base}, committed or not.`
|
|
1508
|
+
}) : /* @__PURE__ */ jsxs("div", {
|
|
1509
|
+
className: "flex min-h-0 flex-1",
|
|
1510
|
+
children: [/* @__PURE__ */ jsx(GitReviewBrowser, {
|
|
1511
|
+
files: entries,
|
|
1512
|
+
commentCounts,
|
|
1513
|
+
onPick: pick,
|
|
1514
|
+
...activePath === void 0 ? {} : { activePath }
|
|
1515
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1516
|
+
"data-testid": "git-review-diffs",
|
|
1517
|
+
className: "min-h-0 min-w-0 flex-1 overflow-y-auto",
|
|
1518
|
+
children: [summary.summary.truncated ? /* @__PURE__ */ jsx("p", {
|
|
1519
|
+
className: "px-4 pt-2 text-2xs text-doom-faint",
|
|
1520
|
+
children: "more files changed than the review lists; the rest are left out."
|
|
1521
|
+
}) : null, entries.map((file, index) => /* @__PURE__ */ jsx(FileSection, {
|
|
1522
|
+
id: sectionId(index),
|
|
1523
|
+
entry: file,
|
|
1524
|
+
state: files[file.path],
|
|
1525
|
+
collapsed: !isOpen(file.path, index),
|
|
1526
|
+
comments: comments.filter((comment) => comment.path === file.path),
|
|
1527
|
+
draft: draft?.path === file.path ? draft.selection : void 0,
|
|
1528
|
+
onToggle: () => toggle(file.path, index),
|
|
1529
|
+
onSelect: (selection) => {
|
|
1530
|
+
setPicked(file.path);
|
|
1531
|
+
setDraft({
|
|
1532
|
+
path: file.path,
|
|
1533
|
+
selection
|
|
1534
|
+
});
|
|
1535
|
+
},
|
|
1536
|
+
onSubmitDraft: submitDraft,
|
|
1537
|
+
onCancelDraft: () => setDraft(void 0),
|
|
1538
|
+
onRemoveComment,
|
|
1539
|
+
onRetry: () => onLoadFile(file.path)
|
|
1540
|
+
}, file.path))]
|
|
1541
|
+
})]
|
|
1542
|
+
}),
|
|
1543
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1544
|
+
"data-testid": "git-review-footer",
|
|
1545
|
+
className: "flex shrink-0 flex-wrap items-center gap-2 border-t border-doom-border-soft bg-doom-panel px-3 py-2 sm:px-[26px]",
|
|
1546
|
+
children: [
|
|
1547
|
+
/* @__PURE__ */ jsx(MessageIcon, {
|
|
1548
|
+
"aria-hidden": true,
|
|
1549
|
+
className: "h-3 w-3 text-doom-faint"
|
|
1550
|
+
}),
|
|
1551
|
+
/* @__PURE__ */ jsx("span", {
|
|
1552
|
+
"data-testid": "git-review-comment-count",
|
|
1553
|
+
className: "text-xs text-doom-dim",
|
|
1554
|
+
children: comments.length === 0 ? "no comments yet: select lines or click a line number" : `${String(comments.length)} ${comments.length === 1 ? "comment" : "comments"}`
|
|
1555
|
+
}),
|
|
1556
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
1557
|
+
sendError === void 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
1558
|
+
role: "alert",
|
|
1559
|
+
"data-testid": "git-review-send-error",
|
|
1560
|
+
className: "min-w-0 truncate text-2xs text-doom-red",
|
|
1561
|
+
children: sendError
|
|
1562
|
+
}),
|
|
1563
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1564
|
+
variant: "ghost",
|
|
1565
|
+
size: "xs",
|
|
1566
|
+
"data-testid": "git-review-discard",
|
|
1567
|
+
disabled: comments.length === 0,
|
|
1568
|
+
onClick: onDiscard,
|
|
1569
|
+
children: "discard"
|
|
1570
|
+
}),
|
|
1571
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1572
|
+
variant: "primary",
|
|
1573
|
+
size: "sm",
|
|
1574
|
+
"data-testid": "git-review-send",
|
|
1575
|
+
disabled: comments.length === 0,
|
|
1576
|
+
onClick: onSendReview,
|
|
1577
|
+
children: "send review"
|
|
1578
|
+
})
|
|
1579
|
+
]
|
|
1580
|
+
})
|
|
1581
|
+
]
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
const STATUS_WORD = {
|
|
1585
|
+
added: "added",
|
|
1586
|
+
modified: "modified",
|
|
1587
|
+
deleted: "deleted",
|
|
1588
|
+
untracked: "new, untracked",
|
|
1589
|
+
conflicted: "conflicted"
|
|
1590
|
+
};
|
|
1591
|
+
/** One file: a pinned header, then its diff with comments drawn under the lines they are about. */
|
|
1592
|
+
function FileSection({ id, entry, state, collapsed, comments, draft, onToggle, onSelect, onSubmitDraft, onCancelDraft, onRemoveComment, onRetry }) {
|
|
1593
|
+
const renderAfterRow = (row) => {
|
|
1594
|
+
const side = sideOf(row);
|
|
1595
|
+
const here = comments.filter((comment) => (comment.side ?? "new") === side && (comment.endLine ?? comment.startLine) === row.line);
|
|
1596
|
+
const drafting = draft !== void 0 && draft.side === side && draft.endLine === row.line;
|
|
1597
|
+
if (here.length === 0 && !drafting) return null;
|
|
1598
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1599
|
+
className: "flex flex-col gap-1.5 border-y border-doom-border-soft bg-doom-bg px-2 py-1.5 whitespace-normal",
|
|
1600
|
+
children: [here.map((comment) => /* @__PURE__ */ jsxs("div", {
|
|
1601
|
+
"data-testid": `git-review-comment-${comment.id}`,
|
|
1602
|
+
className: "flex min-w-0 items-start gap-2 rounded-md border border-doom-border-soft bg-doom-panel px-2 py-1.5",
|
|
1603
|
+
children: [
|
|
1604
|
+
/* @__PURE__ */ jsx(MessageIcon, {
|
|
1605
|
+
"aria-hidden": true,
|
|
1606
|
+
className: "mt-0.5 h-3 w-3 shrink-0 text-doom-blue"
|
|
1607
|
+
}),
|
|
1608
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1609
|
+
className: "flex min-w-0 flex-1 flex-col gap-0.5",
|
|
1610
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1611
|
+
className: "text-2xs text-doom-faint",
|
|
1612
|
+
children: reviewCommentAnchor(comment)
|
|
1613
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1614
|
+
className: "text-xs whitespace-pre-wrap text-doom-hi",
|
|
1615
|
+
children: comment.body
|
|
1616
|
+
})]
|
|
1617
|
+
}),
|
|
1618
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1619
|
+
variant: "ghost",
|
|
1620
|
+
size: "icon",
|
|
1621
|
+
"aria-label": "remove comment",
|
|
1622
|
+
title: "remove comment",
|
|
1623
|
+
onClick: () => onRemoveComment(comment.id),
|
|
1624
|
+
children: /* @__PURE__ */ jsx(CloseIcon, { className: "h-3 w-3" })
|
|
1625
|
+
})
|
|
1626
|
+
]
|
|
1627
|
+
}, comment.id)), drafting ? /* @__PURE__ */ jsx("div", {
|
|
1628
|
+
className: "rounded-md border border-doom-border bg-doom-panel",
|
|
1629
|
+
children: /* @__PURE__ */ jsx(ReviewCommentDraft, {
|
|
1630
|
+
snippet: draft.snippet,
|
|
1631
|
+
startLine: draft.startLine,
|
|
1632
|
+
endLine: draft.endLine,
|
|
1633
|
+
side: draft.side,
|
|
1634
|
+
testIdPrefix: "git-review-comment",
|
|
1635
|
+
onSubmit: onSubmitDraft,
|
|
1636
|
+
onCancel: onCancelDraft
|
|
1637
|
+
})
|
|
1638
|
+
}) : null]
|
|
1639
|
+
});
|
|
1640
|
+
};
|
|
1641
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
1642
|
+
id,
|
|
1643
|
+
"data-testid": `git-review-file-${entry.path}`,
|
|
1644
|
+
className: "border-b border-doom-border-soft",
|
|
1645
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
1646
|
+
variant: "ghost",
|
|
1647
|
+
size: "card",
|
|
1648
|
+
"aria-expanded": !collapsed,
|
|
1649
|
+
onClick: onToggle,
|
|
1650
|
+
className: "sticky top-0 z-10 flex-row items-center gap-1.5 rounded-none border-b border-doom-border-soft bg-doom-rail px-3 py-1.5 hover:bg-doom-panel sm:px-4",
|
|
1651
|
+
children: [
|
|
1652
|
+
collapsed ? /* @__PURE__ */ jsx(ChevronRightIcon, {
|
|
1653
|
+
"aria-hidden": true,
|
|
1654
|
+
className: "h-3 w-3 shrink-0 text-doom-faint"
|
|
1655
|
+
}) : /* @__PURE__ */ jsx(ChevronDownIcon, {
|
|
1656
|
+
"aria-hidden": true,
|
|
1657
|
+
className: "h-3 w-3 shrink-0 text-doom-faint"
|
|
1658
|
+
}),
|
|
1659
|
+
/* @__PURE__ */ jsx("span", {
|
|
1660
|
+
className: "min-w-0 flex-1 truncate font-mono text-xs font-bold text-doom-hi",
|
|
1661
|
+
children: entry.path
|
|
1662
|
+
}),
|
|
1663
|
+
/* @__PURE__ */ jsx("span", {
|
|
1664
|
+
className: "shrink-0 text-2xs text-doom-faint",
|
|
1665
|
+
children: STATUS_WORD[entry.status]
|
|
1666
|
+
}),
|
|
1667
|
+
comments.length === 0 ? null : /* @__PURE__ */ jsxs("span", {
|
|
1668
|
+
className: "flex shrink-0 items-center gap-0.5 text-2xs text-doom-blue",
|
|
1669
|
+
children: [/* @__PURE__ */ jsx(MessageIcon, {
|
|
1670
|
+
"aria-hidden": true,
|
|
1671
|
+
className: "h-2.5 w-2.5"
|
|
1672
|
+
}), comments.length]
|
|
1673
|
+
}),
|
|
1674
|
+
entry.binary ? null : /* @__PURE__ */ jsxs("span", {
|
|
1675
|
+
className: "shrink-0 text-2xs",
|
|
1676
|
+
children: [
|
|
1677
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1678
|
+
className: "text-doom-green",
|
|
1679
|
+
children: ["+", entry.added]
|
|
1680
|
+
}),
|
|
1681
|
+
" ",
|
|
1682
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1683
|
+
className: "text-doom-red",
|
|
1684
|
+
children: ["-", entry.removed]
|
|
1685
|
+
})
|
|
1686
|
+
]
|
|
1687
|
+
})
|
|
1688
|
+
]
|
|
1689
|
+
}), collapsed ? null : state === void 0 || state.state === "loading" ? /* @__PURE__ */ jsxs("div", {
|
|
1690
|
+
className: "flex items-center gap-2 px-4 py-3",
|
|
1691
|
+
children: [/* @__PURE__ */ jsx(Spinner, {
|
|
1692
|
+
className: "h-3 w-3 text-doom-faint",
|
|
1693
|
+
label: `loading ${entry.path}`
|
|
1694
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1695
|
+
className: "text-2xs text-doom-faint",
|
|
1696
|
+
children: "loading diff…"
|
|
1697
|
+
})]
|
|
1698
|
+
}) : state.state === "error" ? /* @__PURE__ */ jsxs("div", {
|
|
1699
|
+
className: "flex items-center gap-2 px-4 py-3",
|
|
1700
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1701
|
+
role: "alert",
|
|
1702
|
+
className: "text-2xs text-doom-red",
|
|
1703
|
+
children: state.error
|
|
1704
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
1705
|
+
variant: "ghost",
|
|
1706
|
+
size: "xs",
|
|
1707
|
+
onClick: onRetry,
|
|
1708
|
+
children: "retry"
|
|
1709
|
+
})]
|
|
1710
|
+
}) : state.diff.binary ? /* @__PURE__ */ jsx("p", {
|
|
1711
|
+
className: "px-4 py-3 text-2xs text-doom-faint",
|
|
1712
|
+
children: "binary file, not drawn"
|
|
1713
|
+
}) : state.diff.tooLarge ? /* @__PURE__ */ jsx("p", {
|
|
1714
|
+
className: "px-4 py-3 text-2xs text-doom-faint",
|
|
1715
|
+
children: "this diff is too large to draw here"
|
|
1716
|
+
}) : /* @__PURE__ */ jsx(DiffView, {
|
|
1717
|
+
hunks: state.diff.hunks,
|
|
1718
|
+
testId: `git-review-diff-${id}`,
|
|
1719
|
+
onSelect,
|
|
1720
|
+
renderAfterRow,
|
|
1721
|
+
className: "py-1"
|
|
1722
|
+
})]
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
//#endregion
|
|
1726
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewPanel.tsx
|
|
1727
|
+
/**
|
|
1728
|
+
* The review tab: this session's changes against its base, the comments on
|
|
1729
|
+
* them, and the manual sync beside them.
|
|
1730
|
+
*
|
|
1731
|
+
* DESIGN PATTERNS:
|
|
1732
|
+
* - Opened from the '# diff' group as a transient tab, one per session.
|
|
1733
|
+
* - The hub's channel says when something changed (counts, base, a paused
|
|
1734
|
+
* rebase); the panel refetches the file list then, and each file's hunks
|
|
1735
|
+
* when it is opened. Nothing polls.
|
|
1736
|
+
* - Comments live in a session store and are cleared only after the host took
|
|
1737
|
+
* the message. Both messages go as `enqueue_automatic`, which queues behind a
|
|
1738
|
+
* running turn instead of failing.
|
|
1739
|
+
*
|
|
1740
|
+
* AVOID:
|
|
1741
|
+
* - Sending sync as a session frame. Sync is a channel command; the hub runs
|
|
1742
|
+
* it with the configured auth, one command per session.
|
|
1743
|
+
*/
|
|
1744
|
+
const REVIEW_TAB_ID = "git-review";
|
|
1745
|
+
function reviewTab() {
|
|
1746
|
+
return {
|
|
1747
|
+
id: REVIEW_TAB_ID,
|
|
1748
|
+
label: "review",
|
|
1749
|
+
panel: GitReviewPanel
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
function GitReviewPanel({ sessionId, sendSessionFrame }) {
|
|
1753
|
+
const activity = useStore(gitChanges.store, (state) => gitChanges.select(state, sessionId));
|
|
1754
|
+
const comments = useStore(gitReview.store, (state) => gitReview.select(state, sessionId).comments);
|
|
1755
|
+
const [summary, setSummary] = useState({ state: "loading" });
|
|
1756
|
+
const [files, setFiles] = useState({});
|
|
1757
|
+
const [sendError, setSendError] = useState();
|
|
1758
|
+
const [dismissedError, setDismissedError] = useState();
|
|
1759
|
+
const changes = activity.changes;
|
|
1760
|
+
const fingerprint = JSON.stringify([
|
|
1761
|
+
changes?.branch,
|
|
1762
|
+
changes?.base,
|
|
1763
|
+
changes?.added,
|
|
1764
|
+
changes?.removed,
|
|
1765
|
+
changes?.files,
|
|
1766
|
+
changes?.rebase?.conflicts.length
|
|
1767
|
+
]);
|
|
1768
|
+
useEffect(() => {
|
|
1769
|
+
if (sessionId === null) return;
|
|
1770
|
+
const controller = new AbortController();
|
|
1771
|
+
fetchReviewSummary(sessionId, controller.signal).then((result) => {
|
|
1772
|
+
if (controller.signal.aborted) return;
|
|
1773
|
+
setSummary(result.ok ? {
|
|
1774
|
+
state: "ready",
|
|
1775
|
+
summary: result.summary
|
|
1776
|
+
} : {
|
|
1777
|
+
state: "error",
|
|
1778
|
+
error: result.error
|
|
1779
|
+
});
|
|
1780
|
+
setFiles({});
|
|
1781
|
+
}).catch((error) => {
|
|
1782
|
+
if (controller.signal.aborted) return;
|
|
1783
|
+
setSummary({
|
|
1784
|
+
state: "error",
|
|
1785
|
+
error: error instanceof Error ? error.message : "The changes could not be read."
|
|
1786
|
+
});
|
|
1787
|
+
});
|
|
1788
|
+
return () => controller.abort();
|
|
1789
|
+
}, [sessionId, fingerprint]);
|
|
1790
|
+
const loadFile = useCallback((path) => {
|
|
1791
|
+
if (sessionId === null) return;
|
|
1792
|
+
setFiles((current) => ({
|
|
1793
|
+
...current,
|
|
1794
|
+
[path]: { state: "loading" }
|
|
1795
|
+
}));
|
|
1796
|
+
const settle = (state) => setFiles((current) => ({
|
|
1797
|
+
...current,
|
|
1798
|
+
[path]: state
|
|
1799
|
+
}));
|
|
1800
|
+
fetchReviewFile(sessionId, path).then((result) => settle(result.ok ? {
|
|
1801
|
+
state: "ready",
|
|
1802
|
+
diff: result.diff
|
|
1803
|
+
} : {
|
|
1804
|
+
state: "error",
|
|
1805
|
+
error: result.error
|
|
1806
|
+
})).catch((error) => settle({
|
|
1807
|
+
state: "error",
|
|
1808
|
+
error: error instanceof Error ? error.message : "The diff could not be read."
|
|
1809
|
+
}));
|
|
1810
|
+
}, [sessionId]);
|
|
1811
|
+
if (sessionId === null) return null;
|
|
1812
|
+
const branch = summary.state === "ready" ? summary.summary.branch : changes?.branch;
|
|
1813
|
+
const base = summary.state === "ready" ? summary.summary.base : changes?.base;
|
|
1814
|
+
const error = activity.error === dismissedError ? void 0 : activity.error;
|
|
1815
|
+
const send = (message, onSent) => {
|
|
1816
|
+
const failure = deliverToAgent(sendSessionFrame, sessionId, message);
|
|
1817
|
+
setSendError(failure);
|
|
1818
|
+
if (failure === void 0) onSent?.();
|
|
1819
|
+
};
|
|
1820
|
+
return /* @__PURE__ */ jsx(GitReviewView, {
|
|
1821
|
+
sync: {
|
|
1822
|
+
...changes === void 0 ? {} : { changes },
|
|
1823
|
+
...activity.pending === void 0 ? {} : { pending: activity.pending },
|
|
1824
|
+
...error === void 0 ? {} : { error },
|
|
1825
|
+
...activity.errorTarget === void 0 || error === void 0 ? {} : { errorTarget: activity.errorTarget },
|
|
1826
|
+
onSync: (action) => requestGitSync(sessionId, action),
|
|
1827
|
+
onForcePush: () => requestGitSync(sessionId, "push", true),
|
|
1828
|
+
onAskAgent: () => send(conflictPrompt(branch, changes?.rebase?.conflicts ?? [])),
|
|
1829
|
+
onDismissError: () => setDismissedError(activity.error)
|
|
1830
|
+
},
|
|
1831
|
+
summary,
|
|
1832
|
+
files,
|
|
1833
|
+
comments,
|
|
1834
|
+
onLoadFile: loadFile,
|
|
1835
|
+
onAddComment: (comment) => addReviewComment(sessionId, comment),
|
|
1836
|
+
onRemoveComment: (id) => removeReviewComment(sessionId, id),
|
|
1837
|
+
onSendReview: () => send(buildReviewPrompt(comments, reviewHeading(comments.length, branch, base)), () => clearReviewComments(sessionId)),
|
|
1838
|
+
onDiscard: () => clearReviewComments(sessionId),
|
|
1839
|
+
...sendError === void 0 ? {} : { sendError }
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
//#endregion
|
|
1843
|
+
//#region src/extensions/workspaces/sessions/(frontend)/activity-group/diff.web.ts
|
|
1844
|
+
var diff_web_default = defineActivityGroup({
|
|
1845
|
+
keys: "g d",
|
|
1846
|
+
activeSource: gitChangesActivitySource,
|
|
1847
|
+
marksBackgroundWork: false,
|
|
1848
|
+
transientTab: reviewTab,
|
|
1849
|
+
order: 28
|
|
1850
|
+
});
|
|
330
1851
|
//#endregion
|
|
331
1852
|
//#region src/types/webWorktrees.ts
|
|
332
1853
|
/**
|
|
@@ -678,9 +2199,70 @@ var git_web_default = defineActivityGroup({
|
|
|
678
2199
|
order: 30
|
|
679
2200
|
});
|
|
680
2201
|
//#endregion
|
|
2202
|
+
//#region src/extensions/workspaces/sessions/(frontend)/channel/gitChanges.web.ts
|
|
2203
|
+
var gitChanges_web_default = defineChannelFile(gitChangesChannel);
|
|
2204
|
+
//#endregion
|
|
681
2205
|
//#region src/extensions/workspaces/sessions/(frontend)/channel/gitWorktrees.web.ts
|
|
682
2206
|
var gitWorktrees_web_default = defineChannelFile(worktreesChannel);
|
|
683
2207
|
//#endregion
|
|
2208
|
+
//#region src/extensions/workspaces/sessions/(frontend)/fill/_components/DiffActivitySection.tsx
|
|
2209
|
+
/**
|
|
2210
|
+
* The '# diff' group's body: this session's code change against its base.
|
|
2211
|
+
*
|
|
2212
|
+
* DESIGN PATTERNS:
|
|
2213
|
+
* - One lane, `+A -R`. Files, base, upstream and a paused rebase live in the
|
|
2214
|
+
* review tab's header, which this lane and the group's name both open.
|
|
2215
|
+
* - Stays clickable at zero, because the review tab is also where pull, push
|
|
2216
|
+
* and rebase live.
|
|
2217
|
+
* - Separate from '# git': worktrees are repository management, this is the
|
|
2218
|
+
* session's own change.
|
|
2219
|
+
*
|
|
2220
|
+
* AVOID:
|
|
2221
|
+
* - Doing work here. This section reports and opens the tab.
|
|
2222
|
+
*/
|
|
2223
|
+
function DiffActivitySection({ sessionId, openTransientTab }) {
|
|
2224
|
+
const session = useStore(gitChanges.store, (state) => gitChanges.select(state, sessionId));
|
|
2225
|
+
const changes = session.changes;
|
|
2226
|
+
if (sessionId === null || changes === void 0) return /* @__PURE__ */ jsx("p", {
|
|
2227
|
+
"data-testid": "activity-summary-diff",
|
|
2228
|
+
className: "px-2 text-xs text-doom-faint",
|
|
2229
|
+
children: "idle"
|
|
2230
|
+
});
|
|
2231
|
+
const zero = changes.added === 0 && changes.removed === 0;
|
|
2232
|
+
const against = changes.base === void 0 ? "uncommitted" : `vs ${changes.base}`;
|
|
2233
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2234
|
+
className: "flex min-w-0 items-center gap-2 px-1",
|
|
2235
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
2236
|
+
variant: "ghost",
|
|
2237
|
+
size: "xs",
|
|
2238
|
+
"data-testid": "activity-diff-changes",
|
|
2239
|
+
"aria-label": `review changes: ${String(changes.added)} added, ${String(changes.removed)} removed ${against}`,
|
|
2240
|
+
title: `${String(changes.files)} ${changes.files === 1 ? "file" : "files"} ${against}`,
|
|
2241
|
+
className: `gap-1.5 px-1 text-xs font-bold ${zero ? "text-doom-faint" : ""}`,
|
|
2242
|
+
onClick: () => openTransientTab(reviewTab()),
|
|
2243
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
2244
|
+
className: zero ? "" : "text-doom-green",
|
|
2245
|
+
children: ["+", changes.added]
|
|
2246
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
2247
|
+
className: zero ? "" : "text-doom-red",
|
|
2248
|
+
children: ["-", changes.removed]
|
|
2249
|
+
})]
|
|
2250
|
+
}), session.pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
|
|
2251
|
+
className: "flex min-w-0 items-center gap-1.5",
|
|
2252
|
+
children: [/* @__PURE__ */ jsx(Spinner, {
|
|
2253
|
+
className: "h-3 w-3 shrink-0 text-doom-faint",
|
|
2254
|
+
label: session.pending
|
|
2255
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2256
|
+
className: "min-w-0 truncate text-xs text-doom-dim",
|
|
2257
|
+
children: session.pending
|
|
2258
|
+
})]
|
|
2259
|
+
})]
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
//#endregion
|
|
2263
|
+
//#region src/extensions/workspaces/sessions/(frontend)/fill/Diff.activity.diff.web.tsx
|
|
2264
|
+
var Diff_activity_diff_web_default = defineFill({ component: DiffActivitySection });
|
|
2265
|
+
//#endregion
|
|
684
2266
|
//#region src/extensions/workspaces/sessions/(frontend)/fill/_components/WorktreeCreateDialog.tsx
|
|
685
2267
|
function WorktreeSessionMenu({ sessionId, sessionReservationId }) {
|
|
686
2268
|
return /* @__PURE__ */ jsx(DropdownMenuItem, {
|
|
@@ -808,8 +2390,19 @@ function WorktreesActivitySection({ sessionId, openTransientTab }) {
|
|
|
808
2390
|
//#region src/extensions/workspaces/sessions/(frontend)/fill/Worktrees.activity.git.web.tsx
|
|
809
2391
|
var Worktrees_activity_git_web_default = defineFill({ component: WorktreesActivitySection });
|
|
810
2392
|
//#endregion
|
|
2393
|
+
//#region src/extensions/workspaces/sessions/(frontend)/lifecycle/_lib/start.web.ts
|
|
2394
|
+
/** Binds both hub channels' senders: worktrees, and this session's diff and sync. */
|
|
2395
|
+
function startGitRuntime(runtime) {
|
|
2396
|
+
const stopWorktrees = startWorktreeRuntime(runtime);
|
|
2397
|
+
const stopChanges = startGitChangesRuntime(runtime);
|
|
2398
|
+
return () => {
|
|
2399
|
+
stopChanges();
|
|
2400
|
+
stopWorktrees();
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
//#endregion
|
|
811
2404
|
//#region src/extensions/workspaces/sessions/(frontend)/lifecycle/start.web.ts
|
|
812
|
-
var start_web_default = defineWebLifecycle(
|
|
2405
|
+
var start_web_default = defineWebLifecycle(startGitRuntime);
|
|
813
2406
|
//#endregion
|
|
814
2407
|
//#region src/extensions/workspaces/sessions/(frontend)/tool/_components/RunWorktreeToolMessage.tsx
|
|
815
2408
|
const ACTION_LABEL = {
|
|
@@ -868,21 +2461,30 @@ const via = (identity, value) => value === void 0 ? void 0 : {
|
|
|
868
2461
|
};
|
|
869
2462
|
const webPlugin = defineWebPlugin({
|
|
870
2463
|
id: "git",
|
|
871
|
-
workspace: {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
2464
|
+
workspace: {
|
|
2465
|
+
get fills() {
|
|
2466
|
+
return [via({
|
|
2467
|
+
slot: "new-session",
|
|
2468
|
+
id: "git-new-session"
|
|
2469
|
+
}, GitNewSession_new_session_web_default)];
|
|
2470
|
+
},
|
|
2471
|
+
get repositorySettingsPanel() {
|
|
2472
|
+
return git_web_default$1;
|
|
2473
|
+
}
|
|
2474
|
+
},
|
|
877
2475
|
session: {
|
|
878
2476
|
get activityGroups() {
|
|
879
|
-
return [via({ name: "git" }, git_web_default)];
|
|
2477
|
+
return [via({ name: "diff" }, diff_web_default), via({ name: "git" }, git_web_default)];
|
|
880
2478
|
},
|
|
881
2479
|
get channels() {
|
|
882
|
-
return [via({ channel: "git_worktrees" }, gitWorktrees_web_default)];
|
|
2480
|
+
return [via({ channel: "git_changes" }, gitChanges_web_default), via({ channel: "git_worktrees" }, gitWorktrees_web_default)];
|
|
883
2481
|
},
|
|
884
2482
|
get fills() {
|
|
885
2483
|
return [
|
|
2484
|
+
via({
|
|
2485
|
+
slot: "activity.diff",
|
|
2486
|
+
id: "diff"
|
|
2487
|
+
}, Diff_activity_diff_web_default),
|
|
886
2488
|
via({
|
|
887
2489
|
slot: "session-menu",
|
|
888
2490
|
id: "worktree"
|