@agimon-ai/po-git 0.0.0-alpha.0 → 0.0.0-stage

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.
@@ -1,2785 +0,0 @@
1
- import { apiResponse, createApiClient, defineActivityGroup, defineApiRoutes, defineChannelFile, defineFill, defineGlobalStore, defineLeaderBinding, defineRepositorySettingsPanel, defineSessionStore, defineToolRenderer, defineWebLifecycle, defineWebPlugin, sessionApiAddress } from "@agimon-ai/po-core/web";
2
- import { AlertIcon, ArrowRightIcon, BranchIcon, Button, CheckIcon, ChevronDownIcon, ChevronRightIcon, CloseIcon, Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DiffView, Dot, DropdownMenuItem, EmptyState, Input, MessageIcon, MessageItem, MessageItemBody, MessageItemHeader, OptionRow, RadioGroup, RadioGroupCard, ReviewCommentDraft, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SettingsActions, SettingsCard, SettingsField, SettingsNote, SettingsRow, SettingsRowDescription, SettingsRowText, SettingsRowTitle, SettingsSection, SettingsSectionDescription, SettingsSectionHeader, SettingsSectionTitle, ShieldIcon, Spinner, StatusBadge, buildReviewPrompt, reviewCommentAnchor, toolTone } from "@agimon-ai/po-web-components";
3
- import { createElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
- import { sealedTransport } from "@agimon-ai/po-web-security/browser";
5
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
- import { useStore } from "@tanstack/react-store";
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
- });
71
- //#endregion
72
- //#region src/extensions/workspaces/(frontend)/_lib/newSessionForm.ts
73
- const MAX_LISTED = 200;
74
- /** Local branches first, then remote-only ones, filtered by a case-insensitive substring. */
75
- function branchOptions(branches, query) {
76
- const needle = query.trim().toLowerCase();
77
- const matches = (text) => needle === "" || text.toLowerCase().includes(needle);
78
- const local = branches.local.filter((branch) => matches(branch.name)).map((branch) => ({
79
- key: `local:${branch.name}`,
80
- name: branch.name,
81
- current: branch.name === branches.current,
82
- ...branch.checkedOutAt === void 0 ? {} : { busyAt: branch.checkedOutAt }
83
- }));
84
- const remote = branches.remote.filter((branch) => matches(`${branch.remote}/${branch.name}`)).map((branch) => ({
85
- key: `remote:${branch.remote}/${branch.name}`,
86
- name: branch.name,
87
- remote: branch.remote,
88
- current: false
89
- }));
90
- return [...local, ...remote].slice(0, MAX_LISTED);
91
- }
92
- /** Every base a new branch can start from: the default first, then remote-tracking names, then local branches. */
93
- function baseOptions(branches) {
94
- return [.../* @__PURE__ */ new Set([
95
- ...branches.defaultBase === void 0 ? [] : [branches.defaultBase],
96
- ...branches.remote.map((branch) => `${branch.remote}/${branch.name}`),
97
- ...branches.local.map((branch) => branch.name)
98
- ])];
99
- }
100
- /** Why the form cannot be submitted yet, or undefined when it can. */
101
- function formProblem$1(state) {
102
- if (state.mode === "existing") {
103
- if (state.selected === void 0) return "Pick a branch.";
104
- if (state.selected.busyAt !== void 0) return `That branch is checked out at ${state.selected.busyAt}.`;
105
- }
106
- if (state.mode === "new" && state.newBranch.trim() === "") return "Name the new branch.";
107
- }
108
- /** The worktree create request for the form, or undefined in plain mode. */
109
- function createRequest(state) {
110
- const name = state.name.trim();
111
- const named = name === "" ? {} : { name };
112
- if (state.mode === "existing" && state.selected !== void 0) return {
113
- mode: "existing-branch",
114
- branch: state.selected.name,
115
- ...state.selected.remote === void 0 ? {} : { remote: state.selected.remote },
116
- ...named
117
- };
118
- if (state.mode === "new") {
119
- const baseRef = state.baseRef.trim();
120
- return {
121
- mode: "new-branch",
122
- branch: state.newBranch.trim(),
123
- ...baseRef === "" ? {} : { baseRef },
124
- ...named
125
- };
126
- }
127
- }
128
- //#endregion
129
- //#region src/extensions/workspaces/(frontend)/fill/_components/GitNewSessionDialog.tsx
130
- const MODES = [
131
- {
132
- mode: "existing",
133
- label: "existing branch"
134
- },
135
- {
136
- mode: "new",
137
- label: "new branch"
138
- },
139
- {
140
- mode: "plain",
141
- label: "no branch"
142
- }
143
- ];
144
- function errorOf$1(body, status) {
145
- return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : `The cockpit answered ${String(status)}.`;
146
- }
147
- /**
148
- * The new-session dialog when git is installed: a worktree on an existing or a
149
- * new branch, or a plain session in the workspace root. A worktree session
150
- * starts at the top level of the rail, with no parent.
151
- */
152
- function GitNewSessionDialog({ newSession }) {
153
- const [branches, setBranches] = useState(null);
154
- const [loadError, setLoadError] = useState("");
155
- const [form, setForm] = useState({
156
- mode: "existing",
157
- newBranch: "",
158
- baseRef: "",
159
- name: ""
160
- });
161
- const [query, setQuery] = useState("");
162
- const [error, setError] = useState("");
163
- const [busy, setBusy] = useState(false);
164
- const workspaceId = newSession?.workspaceId;
165
- const request = newSession?.requestWithStepUp;
166
- useEffect(() => {
167
- if (workspaceId === void 0 || request === void 0) return;
168
- let stale = false;
169
- const url = api.workspace(workspaceId).branches.url();
170
- request(url).then(async (response) => {
171
- const body = await response.json().catch(() => void 0);
172
- if (stale) return;
173
- if (!response.ok) {
174
- setLoadError(errorOf$1(body, response.status));
175
- return;
176
- }
177
- const loaded = body;
178
- setBranches(loaded);
179
- setForm((current) => ({
180
- ...current,
181
- baseRef: loaded.defaultBase ?? ""
182
- }));
183
- }).catch(() => {
184
- if (!stale) setLoadError("The branches could not be loaded.");
185
- });
186
- return () => {
187
- stale = true;
188
- };
189
- }, [workspaceId, request]);
190
- if (newSession === void 0) return null;
191
- const gitReady = branches?.repository === true && loadError === "";
192
- const mode = gitReady || branches === null ? form.mode : "plain";
193
- const state = {
194
- ...form,
195
- mode
196
- };
197
- const problem = formProblem$1(state);
198
- const options = branches === null ? [] : branchOptions(branches, query);
199
- const bases = branches === null ? [] : baseOptions(branches);
200
- const update = (patch) => setForm((current) => ({
201
- ...current,
202
- ...patch
203
- }));
204
- const submit = async () => {
205
- if (busy || problem !== void 0) return;
206
- setBusy(true);
207
- setError("");
208
- const body = createRequest(state);
209
- let failure;
210
- if (body === void 0) failure = await newSession.createPlain(state.name);
211
- else try {
212
- const response = await newSession.requestWithStepUp(api.workspace(newSession.workspaceId).createSession.url(), {
213
- method: "POST",
214
- headers: { "Content-Type": "application/json" },
215
- body: JSON.stringify(body)
216
- });
217
- const answer = await response.json().catch(() => void 0);
218
- failure = response.ok ? await newSession.openCreated(answer.sessionId) : errorOf$1(answer, response.status);
219
- } catch {
220
- failure = "The cockpit is unreachable.";
221
- }
222
- if (failure === void 0) return;
223
- setError(failure);
224
- setBusy(false);
225
- };
226
- return /* @__PURE__ */ jsx(Dialog, {
227
- open: true,
228
- onOpenChange: (next) => !next && !busy && newSession.close(),
229
- children: /* @__PURE__ */ jsxs(DialogContent, {
230
- width: "md",
231
- "data-testid": "new-session-dialog",
232
- "aria-describedby": void 0,
233
- children: [/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: "new session" }) }), /* @__PURE__ */ jsxs(DialogBody, { children: [
234
- /* @__PURE__ */ jsx("p", {
235
- className: "truncate text-sm text-doom-dim",
236
- title: newSession.workspaceRoot,
237
- children: newSession.workspaceRoot
238
- }),
239
- gitReady ? /* @__PURE__ */ jsx("div", {
240
- role: "radiogroup",
241
- "aria-label": "session kind",
242
- className: "flex gap-1",
243
- children: MODES.map((entry) => /* @__PURE__ */ jsx(Button, {
244
- role: "radio",
245
- "aria-checked": mode === entry.mode,
246
- variant: mode === entry.mode ? "outline" : "ghost",
247
- size: "sm",
248
- "data-testid": `git-new-session-mode-${entry.mode}`,
249
- disabled: busy,
250
- className: mode === entry.mode ? "border-doom-blue/60 text-doom-hi" : void 0,
251
- onClick: () => update({ mode: entry.mode }),
252
- children: entry.label
253
- }, entry.mode))
254
- }) : branches === null && loadError === "" ? /* @__PURE__ */ jsx("p", {
255
- className: "text-sm text-doom-dim",
256
- children: "reading branches…"
257
- }) : /* @__PURE__ */ jsx("p", {
258
- "data-testid": "git-new-session-plain-note",
259
- className: "text-sm text-doom-dim",
260
- children: loadError || "This workspace is not a git checkout, so the session starts in its folder."
261
- }),
262
- mode === "existing" && gitReady ? /* @__PURE__ */ jsxs("div", {
263
- className: "flex flex-col gap-1",
264
- children: [/* @__PURE__ */ jsx(Input, {
265
- "data-testid": "git-new-session-branch-search",
266
- "aria-label": "find a branch",
267
- value: query,
268
- autoFocus: true,
269
- disabled: busy,
270
- onChange: (event) => setQuery(event.target.value),
271
- placeholder: "find a branch"
272
- }), /* @__PURE__ */ jsxs("div", {
273
- role: "listbox",
274
- "aria-label": "branches",
275
- className: "max-h-48 overflow-y-auto rounded border border-doom-border bg-doom-deep p-1",
276
- children: [options.length === 0 ? /* @__PURE__ */ jsx("p", {
277
- className: "px-2 py-1 text-sm text-doom-dim",
278
- children: "no matching branches"
279
- }) : null, options.map((option) => /* @__PURE__ */ jsxs(OptionRow, {
280
- density: "compact",
281
- active: form.selected?.key === option.key,
282
- disabled: option.busyAt !== void 0 || busy,
283
- "data-testid": "git-new-session-branch-option",
284
- "data-branch": option.name,
285
- "data-remote": option.remote,
286
- onClick: () => update({ selected: option }),
287
- className: `w-full px-2.5 py-1 text-base ${option.busyAt !== void 0 ? "opacity-50" : ""}`,
288
- children: [/* @__PURE__ */ jsx("span", {
289
- className: "min-w-0 flex-1 truncate",
290
- children: option.remote === void 0 ? option.name : `${option.remote}/${option.name}`
291
- }), /* @__PURE__ */ jsx("span", {
292
- className: "shrink-0 text-sm text-doom-dim",
293
- children: option.current && option.busyAt !== void 0 ? "current · checked out" : option.current ? "current" : option.busyAt !== void 0 ? "checked out" : option.remote !== void 0 ? "remote" : ""
294
- })]
295
- }, option.key))]
296
- })]
297
- }) : null,
298
- mode === "new" && gitReady ? /* @__PURE__ */ jsxs("div", {
299
- className: "flex flex-col gap-2",
300
- children: [/* @__PURE__ */ jsxs("label", {
301
- htmlFor: "git-new-session-new-branch",
302
- className: "flex flex-col gap-1",
303
- children: [/* @__PURE__ */ jsx("span", {
304
- className: "text-sm text-doom-dim",
305
- children: "new branch"
306
- }), /* @__PURE__ */ jsx(Input, {
307
- id: "git-new-session-new-branch",
308
- "data-testid": "git-new-session-new-branch",
309
- value: form.newBranch,
310
- autoFocus: true,
311
- disabled: busy,
312
- onChange: (event) => update({ newBranch: event.target.value }),
313
- placeholder: "feature/my-change"
314
- })]
315
- }), /* @__PURE__ */ jsxs("span", {
316
- className: "flex flex-col gap-1",
317
- children: [/* @__PURE__ */ jsx("span", {
318
- className: "text-sm text-doom-dim",
319
- children: "from"
320
- }), /* @__PURE__ */ jsxs(Select, {
321
- value: form.baseRef,
322
- onValueChange: (baseRef) => update({ baseRef }),
323
- disabled: busy,
324
- children: [/* @__PURE__ */ jsx(SelectTrigger, {
325
- "data-testid": "git-new-session-base",
326
- "aria-label": "base branch",
327
- children: /* @__PURE__ */ jsx(SelectValue, { placeholder: "base branch" })
328
- }), /* @__PURE__ */ jsx(SelectContent, { children: bases.map((base) => /* @__PURE__ */ jsx(SelectItem, {
329
- value: base,
330
- children: base === branches?.current ? `${base} (current)` : base
331
- }, base)) })]
332
- })]
333
- })]
334
- }) : null,
335
- /* @__PURE__ */ jsxs("label", {
336
- htmlFor: "new-session-name",
337
- className: "flex flex-col gap-1",
338
- children: [/* @__PURE__ */ jsxs("span", {
339
- className: "text-sm text-doom-dim",
340
- children: ["name ", /* @__PURE__ */ jsxs("span", {
341
- className: "text-doom-dim",
342
- children: [
343
- "(optional",
344
- mode === "plain" ? "" : "; defaults to the branch",
345
- ")"
346
- ]
347
- })]
348
- }), /* @__PURE__ */ jsx(Input, {
349
- id: "new-session-name",
350
- "data-testid": "new-session-name",
351
- value: form.name,
352
- autoFocus: mode === "plain",
353
- disabled: busy,
354
- onChange: (event) => update({ name: event.target.value }),
355
- onKeyDown: (event) => {
356
- if (event.key === "Enter") submit();
357
- },
358
- placeholder: "session name"
359
- })]
360
- }),
361
- error ? /* @__PURE__ */ jsx("pre", {
362
- "data-testid": "new-session-error",
363
- className: "max-h-28 overflow-y-auto rounded border border-doom-edge-red bg-doom-tint-red/40 px-2.5 py-2 text-sm whitespace-pre-wrap text-doom-red",
364
- children: error
365
- }) : null,
366
- /* @__PURE__ */ jsxs(DialogFooter, { children: [/* @__PURE__ */ jsx(Button, {
367
- variant: "outline",
368
- "data-testid": "new-session-cancel",
369
- disabled: busy,
370
- onClick: () => newSession.close(),
371
- children: "cancel"
372
- }), /* @__PURE__ */ jsx(Button, {
373
- variant: "primary",
374
- "data-testid": "new-session-create",
375
- title: problem,
376
- disabled: busy || problem !== void 0,
377
- onClick: () => void submit(),
378
- children: busy ? mode === "plain" ? "creating…" : "creating worktree…" : "create"
379
- })] })
380
- ] })]
381
- })
382
- });
383
- }
384
- //#endregion
385
- //#region src/extensions/workspaces/(frontend)/fill/GitNewSession.new-session.web.tsx
386
- var GitNewSession_new_session_web_default = defineFill({ component: GitNewSessionDialog });
387
- function formFromView(view) {
388
- return {
389
- method: view.method,
390
- keyPath: view.ssh?.keyPath ?? "",
391
- host: view.https?.host ?? "github.com",
392
- username: view.https?.username ?? "",
393
- token: ""
394
- };
395
- }
396
- const HOST_PATTERN = /^[a-z0-9.-]{1,253}(:\d{1,5})?$/u;
397
- /** Whether a value holds an ASCII control character; a newline in a credential field could add a line to git's credential protocol. */
398
- function hasControl(value) {
399
- for (let index = 0; index < value.length; index += 1) {
400
- const code = value.charCodeAt(index);
401
- if (code < 32 || code === 127) return true;
402
- }
403
- return false;
404
- }
405
- /** Whether the saved token can stay: only when one exists and the host has not moved. */
406
- function keepsToken(form, saved) {
407
- return saved.https?.hasToken === true && saved.https.host === form.host.trim().toLowerCase();
408
- }
409
- /** The first thing wrong with the form, in words, or undefined when it can be saved. */
410
- function formProblem(form, saved) {
411
- if (form.method === "ssh") {
412
- const keyPath = form.keyPath.trim();
413
- if (keyPath === "") return void 0;
414
- if (hasControl(keyPath)) return "The key path has a control character in it.";
415
- if (!keyPath.startsWith("/") && !keyPath.startsWith("~/")) return "Give the key as an absolute path or one under ~/.";
416
- return;
417
- }
418
- if (form.method === "https") {
419
- const host = form.host.trim().toLowerCase();
420
- if (!HOST_PATTERN.test(host)) return "The host should look like github.com, with an optional :port.";
421
- if (form.username.trim() === "") return "A username is required.";
422
- if (hasControl(form.username) || hasControl(form.token)) return "Remove the control character from the username or token.";
423
- 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.";
424
- }
425
- }
426
- /** What a save sends. The token goes only when the reader typed one. */
427
- function saveRequestOf(form) {
428
- if (form.method === "ssh") {
429
- const keyPath = form.keyPath.trim();
430
- return keyPath === "" ? { method: "ssh" } : {
431
- method: "ssh",
432
- keyPath
433
- };
434
- }
435
- if (form.method === "https") return {
436
- method: "https",
437
- host: form.host.trim().toLowerCase(),
438
- username: form.username.trim(),
439
- ...form.token === "" ? {} : { token: form.token }
440
- };
441
- return { method: "none" };
442
- }
443
- //#endregion
444
- //#region src/extensions/workspaces/(frontend)/repository-settings-panel/_components/GitAuthForm.tsx
445
- /**
446
- * A workspace's git remote panel body: how po-git reaches the remote when
447
- * you pull, push or rebase from this workspace's sessions in the cockpit.
448
- *
449
- * DESIGN PATTERNS:
450
- * - Per workspace. Worktree sessions use the setup of the workspace they
451
- * belong to. A workspace with nothing set uses this machine's own git.
452
- * - Three choices as cards, the way the tunnel picker offers them: this
453
- * machine's own git, an SSH key, or an HTTPS token. The fields a choice
454
- * needs follow in one card of form rows.
455
- * - The token is write-only. The field is never prefilled; a saved token shows
456
- * as a badge, and leaving the field empty keeps it.
457
- * - Form state is the reader's until they save. The owner remounts the form
458
- * after a save so it starts again from what the server now holds.
459
- *
460
- * AVOID:
461
- * - Echoing a token back. The view the server sends has none to echo.
462
- */
463
- const METHODS = [
464
- {
465
- method: "none",
466
- title: "This machine's git",
467
- detail: "Whatever git here already does: your credential helper, SSH config and agent."
468
- },
469
- {
470
- method: "ssh",
471
- title: "SSH key",
472
- detail: "A key file on this machine, or your ssh-agent."
473
- },
474
- {
475
- method: "https",
476
- title: "HTTPS token",
477
- detail: "A username and personal access token for one host."
478
- }
479
- ];
480
- function GitAuthForm({ saved, saving, saveError, savedNotice = false, onSave }) {
481
- const [form, setForm] = useState(() => formFromView(saved));
482
- const update = (patch) => setForm((current) => ({
483
- ...current,
484
- ...patch
485
- }));
486
- const problem = formProblem(form, saved);
487
- const tokenSaved = saved.https?.hasToken === true && saved.https.host === form.host.trim().toLowerCase();
488
- const host = form.host.trim() === "" ? "this host" : form.host.trim();
489
- return /* @__PURE__ */ jsxs(SettingsSection, {
490
- "data-testid": "git-auth-form",
491
- children: [
492
- /* @__PURE__ */ jsxs(SettingsSectionHeader, { children: [/* @__PURE__ */ jsx(SettingsSectionTitle, { children: "Connection" }), /* @__PURE__ */ jsx(SettingsSectionDescription, { children: "Worktree sessions use the setup of the workspace they belong to." })] }),
493
- /* @__PURE__ */ jsx(RadioGroup, {
494
- "aria-label": "How git reaches your remote",
495
- value: form.method,
496
- onValueChange: (value) => update({ method: value }),
497
- className: "grid grid-cols-1 gap-3",
498
- children: METHODS.map((choice) => /* @__PURE__ */ jsxs(RadioGroupCard, {
499
- value: choice.method,
500
- "data-testid": `git-auth-method-${choice.method}`,
501
- className: "flex items-start gap-2 rounded-lg p-4",
502
- children: [/* @__PURE__ */ jsxs("span", {
503
- className: "flex min-w-0 flex-1 flex-col gap-1",
504
- children: [/* @__PURE__ */ jsx("span", {
505
- className: "text-base font-medium text-doom-hi",
506
- children: choice.title
507
- }), /* @__PURE__ */ jsx("span", {
508
- className: "text-sm leading-relaxed text-doom-dim",
509
- children: choice.detail
510
- })]
511
- }), form.method === choice.method ? /* @__PURE__ */ jsx(CheckIcon, { className: "size-4 shrink-0 text-doom-green" }) : null]
512
- }, choice.method))
513
- }),
514
- form.method === "ssh" ? /* @__PURE__ */ jsx(SettingsCard, { children: /* @__PURE__ */ jsx(SettingsField, {
515
- id: "git-auth-key-path",
516
- label: "Key path",
517
- description: /* @__PURE__ */ jsxs(Fragment, { children: [
518
- "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 ",
519
- /* @__PURE__ */ jsx("code", {
520
- className: "font-mono",
521
- children: "ssh -T git@github.com"
522
- }),
523
- " once in a terminal to trust it."
524
- ] }),
525
- children: /* @__PURE__ */ jsx(Input, {
526
- id: "git-auth-key-path",
527
- "data-testid": "git-auth-key-path",
528
- value: form.keyPath,
529
- placeholder: "~/.ssh/id_ed25519",
530
- spellCheck: false,
531
- onChange: (event) => update({ keyPath: event.target.value }),
532
- className: "font-mono text-sm"
533
- })
534
- }) }) : null,
535
- form.method === "https" ? /* @__PURE__ */ jsxs(SettingsCard, { children: [
536
- /* @__PURE__ */ jsx(SettingsField, {
537
- id: "git-auth-host",
538
- label: "Host",
539
- description: "The one host this token is sent to.",
540
- children: /* @__PURE__ */ jsx(Input, {
541
- id: "git-auth-host",
542
- "data-testid": "git-auth-host",
543
- value: form.host,
544
- spellCheck: false,
545
- onChange: (event) => update({ host: event.target.value }),
546
- className: "font-mono text-sm"
547
- })
548
- }),
549
- /* @__PURE__ */ jsx(SettingsField, {
550
- id: "git-auth-username",
551
- label: "Username",
552
- children: /* @__PURE__ */ jsx(Input, {
553
- id: "git-auth-username",
554
- "data-testid": "git-auth-username",
555
- value: form.username,
556
- autoComplete: "username",
557
- spellCheck: false,
558
- onChange: (event) => update({ username: event.target.value }),
559
- className: "text-base"
560
- })
561
- }),
562
- /* @__PURE__ */ jsx(SettingsField, {
563
- id: "git-auth-token",
564
- label: "Token",
565
- description: /* @__PURE__ */ jsxs(Fragment, { children: [
566
- tokenSaved ? /* @__PURE__ */ jsxs("span", {
567
- "data-testid": "git-auth-token-saved",
568
- className: "text-doom-green",
569
- children: ["A token is saved.", " "]
570
- }) : null,
571
- "Sent only to ",
572
- host,
573
- ". A remote on any other host keeps using this machine's own git credentials."
574
- ] }),
575
- children: /* @__PURE__ */ jsx(Input, {
576
- id: "git-auth-token",
577
- "data-testid": "git-auth-token",
578
- type: "password",
579
- value: form.token,
580
- autoComplete: "new-password",
581
- placeholder: tokenSaved ? "Leave blank to keep it" : "Personal access token",
582
- onChange: (event) => update({ token: event.target.value }),
583
- className: "text-base"
584
- })
585
- })
586
- ] }) : null,
587
- /* @__PURE__ */ jsxs(SettingsActions, {
588
- className: "gap-4",
589
- children: [/* @__PURE__ */ jsx(Button, {
590
- variant: "primary",
591
- size: "sm",
592
- "data-testid": "git-auth-save",
593
- disabled: problem !== void 0 || saving,
594
- loading: saving,
595
- loadingLabel: "Saving",
596
- onClick: () => onSave(saveRequestOf(form)),
597
- children: "Save connection"
598
- }), problem !== void 0 ? /* @__PURE__ */ jsx(SettingsNote, {
599
- "data-testid": "git-auth-problem",
600
- className: "text-doom-dim",
601
- children: problem
602
- }) : saveError !== void 0 ? /* @__PURE__ */ jsx(SettingsNote, {
603
- role: "alert",
604
- "data-testid": "git-auth-error",
605
- className: "text-doom-red",
606
- children: saveError
607
- }) : savedNotice ? /* @__PURE__ */ jsx(SettingsNote, {
608
- "data-testid": "git-auth-saved",
609
- className: "text-doom-green",
610
- children: "Saved."
611
- }) : null]
612
- }),
613
- /* @__PURE__ */ jsxs(SettingsNote, {
614
- className: "flex items-start gap-2",
615
- children: [/* @__PURE__ */ jsx(ShieldIcon, {
616
- "aria-hidden": true,
617
- className: "mt-0.5 size-3.5 shrink-0"
618
- }), /* @__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 ~/.po/git/credentials.json, readable only by you, and never in the system keychain." })]
619
- })
620
- ]
621
- });
622
- }
623
- //#endregion
624
- //#region src/extensions/workspaces/(frontend)/repository-settings-panel/_components/GitAuthPanel.tsx
625
- /**
626
- * A workspace's git remote panel: loads that workspace's saved auth and saves a
627
- * new one. Lives under settings, in the repository section the host scopes.
628
- *
629
- * DESIGN PATTERNS:
630
- * - Per workspace. The host hands the selected repository; its opaque id is
631
- * the only thing sent, and the hub resolves it to an admitted root. A
632
- * browser never names a path.
633
- * - Reads through the host's sealed request and writes through its step-up
634
- * request, so a remote device proves itself with a passkey before it can
635
- * change credentials. The URLs come from the generated client.
636
- * - After a save the form remounts from what the server returned, which is how
637
- * the token field empties and the "token saved" badge appears.
638
- */
639
- function isAuthView(value) {
640
- return typeof value === "object" && value !== null && [
641
- "none",
642
- "ssh",
643
- "https"
644
- ].includes(String(value.method));
645
- }
646
- function errorOf(body, status) {
647
- return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : `The cockpit answered ${String(status)}.`;
648
- }
649
- /** The one-row card every non-form state of this panel is told in. */
650
- function StateCard({ testId, title, children, action }) {
651
- return /* @__PURE__ */ jsx(SettingsCard, {
652
- "data-testid": testId,
653
- children: /* @__PURE__ */ jsxs(SettingsRow, { children: [/* @__PURE__ */ jsxs(SettingsRowText, { children: [/* @__PURE__ */ jsx(SettingsRowTitle, { children: title }), /* @__PURE__ */ jsx(SettingsRowDescription, { children })] }), action] })
654
- });
655
- }
656
- function GitAuthPanel({ repository, request, requestWithStepUp }) {
657
- const repositoryId = repository?.id;
658
- const [loaded, setLoaded] = useState();
659
- const [loadError, setLoadError] = useState();
660
- const [saving, setSaving] = useState(false);
661
- const [saveError, setSaveError] = useState();
662
- const [savedNotice, setSavedNotice] = useState(false);
663
- const [generation, setGeneration] = useState(0);
664
- const [attempt, setAttempt] = useState(0);
665
- useEffect(() => {
666
- if (repositoryId === void 0) return;
667
- let stale = false;
668
- request(api.workspace(repositoryId).auth.url({ query: { repositoryId } })).then(async (response) => {
669
- const body = await response.json().catch(() => void 0);
670
- if (stale) return;
671
- if (response.ok && isAuthView(body)) setLoaded({
672
- repositoryId,
673
- saved: body
674
- });
675
- else setLoadError({
676
- repositoryId,
677
- error: errorOf(body, response.status)
678
- });
679
- }).catch(() => {
680
- if (!stale) setLoadError({
681
- repositoryId,
682
- error: "The cockpit is unreachable."
683
- });
684
- });
685
- return () => {
686
- stale = true;
687
- };
688
- }, [
689
- repositoryId,
690
- request,
691
- attempt
692
- ]);
693
- if (repository === null || repositoryId === void 0) return /* @__PURE__ */ jsx(StateCard, {
694
- testId: "git-auth-no-repository",
695
- title: "No repository picked",
696
- children: "Git remote access is set per workspace. Pick one above."
697
- });
698
- const save = async (next) => {
699
- setSaving(true);
700
- setSaveError(void 0);
701
- setSavedNotice(false);
702
- try {
703
- const response = await requestWithStepUp(api.workspace(repositoryId).saveAuth.url({ query: { repositoryId } }), {
704
- method: "PUT",
705
- headers: { "Content-Type": "application/json" },
706
- body: JSON.stringify(next)
707
- });
708
- const body = await response.json().catch(() => void 0);
709
- if (response.ok && isAuthView(body)) {
710
- setLoaded({
711
- repositoryId,
712
- saved: body
713
- });
714
- setGeneration((current) => current + 1);
715
- setSavedNotice(true);
716
- } else setSaveError(errorOf(body, response.status));
717
- } catch {
718
- setSaveError("The cockpit is unreachable.");
719
- } finally {
720
- setSaving(false);
721
- }
722
- };
723
- if (loadError?.repositoryId === repositoryId) return /* @__PURE__ */ jsx("div", {
724
- role: "alert",
725
- "data-testid": "git-auth-load-error",
726
- children: /* @__PURE__ */ jsxs(StateCard, {
727
- testId: "git-auth-load-error-card",
728
- title: "Po couldn't read this workspace's git settings just now",
729
- action: /* @__PURE__ */ jsx(Button, {
730
- variant: "outline",
731
- size: "sm",
732
- onClick: () => {
733
- setLoadError(void 0);
734
- setAttempt((current) => current + 1);
735
- },
736
- children: "Try again"
737
- }),
738
- children: [loadError.error, " Nothing has changed."]
739
- })
740
- });
741
- if (loaded?.repositoryId !== repositoryId) return /* @__PURE__ */ jsx(StateCard, {
742
- testId: "git-auth-loading",
743
- title: "Reading git settings",
744
- children: /* @__PURE__ */ jsxs("span", {
745
- className: "inline-flex items-center gap-2",
746
- children: [/* @__PURE__ */ jsx(Spinner, {
747
- className: "size-3 text-doom-faint",
748
- label: "Loading git settings"
749
- }), "This takes a moment."]
750
- })
751
- });
752
- return /* @__PURE__ */ jsx(GitAuthForm, {
753
- saved: loaded.saved,
754
- saving,
755
- savedNotice,
756
- onSave: (next) => void save(next),
757
- ...saveError === void 0 ? {} : { saveError }
758
- }, `${repositoryId}-${String(generation)}`);
759
- }
760
- //#endregion
761
- //#region src/extensions/workspaces/(frontend)/repository-settings-panel/git.web.tsx
762
- var git_web_default$1 = defineRepositorySettingsPanel({
763
- label: "Git remote",
764
- detail: "How this workspace's sessions reach the remote when you pull, push or rebase from the cockpit.",
765
- order: 50,
766
- component: GitAuthPanel
767
- });
768
- //#endregion
769
- //#region src/types/webWorktrees.ts
770
- /**
771
- * The cockpit's view of this session's worktrees.
772
- *
773
- * DESIGN PATTERNS:
774
- * - Declarations only. The channel frame type is a constant so the hub and the
775
- * page cannot drift apart on a string.
776
- * - A view is the subset of a record the dock needs, not the record. The
777
- * registry may hold paths and provenance the page has no business rendering.
778
- *
779
- * AVOID:
780
- * - Adding behaviour here. This file is imported by browser code, so it must
781
- * stay free of node builtins.
782
- */
783
- /** The data channel carrying one session's worktrees to the page. */
784
- const GIT_WORKTREES_TYPE = "git_worktrees";
785
- //#endregion
786
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/worktreesActivityStore.ts
787
- /**
788
- * The dock's view of this session's worktrees, and the commands it sends.
789
- *
790
- * DESIGN PATTERNS:
791
- * - Session-scoped store fed by one hub channel. The hub is authoritative; the
792
- * page never reads a registry and never guesses.
793
- * - Actions are channel frames, not prompts. The hub half owns the tool's
794
- * operations, so the panel names the work and the hub performs it; a
795
- * worktree made from the dock is the same object the tool makes, without
796
- * spending a turn of the conversation to ask for it.
797
- * - The sender lives in a store rather than a module variable, because a web
798
- * plugin keeps no mutable module state and the page may reconnect.
799
- *
800
- * AVOID:
801
- * - Calling the hub package API from here. A dock slot gets no sealed request
802
- * transport, and raw fetch breaks the remote cockpit.
803
- * - `sendSessionFrame` for a command. It envelopes the frame as a session
804
- * command bound for the agent, which never reaches a channel's `receive`.
805
- */
806
- const worktreeActivity = defineSessionStore({
807
- worktrees: [],
808
- pending: void 0,
809
- error: void 0,
810
- errorTarget: void 0
811
- });
812
- /** Captures the clicked card, so a focus change cannot retarget an open dialog. */
813
- const worktreeCreateDialog = defineGlobalStore(null);
814
- /**
815
- * The page's hub socket sender, held for as long as the plugin runs.
816
- *
817
- * Undefined before `startWorktreeRuntime` and after it disposes; a command
818
- * sent in that window is dropped rather than queued, because a queue would
819
- * replay a worktree create against a socket that has since changed sessions.
820
- */
821
- const worktreeSender = defineGlobalStore(void 0);
822
- /** Binds the page's hub socket. The plugin host calls this once per page. */
823
- function startWorktreeRuntime(runtime) {
824
- const send = (frame) => {
825
- runtime.sendHubFrame(frame);
826
- };
827
- worktreeSender.update(() => send);
828
- return () => {
829
- worktreeSender.update((current) => current === send ? void 0 : current);
830
- worktreeCreateDialog.update(() => null);
831
- };
832
- }
833
- /**
834
- * Whether this session has worktree work in flight.
835
- *
836
- * A standing worktree is state, not work: it sits there until someone closes
837
- * it, so counting it would leave the session reading as busy forever, with a
838
- * "background work is still running" notice under a conversation where
839
- * nothing is running. Only a create or close actually in progress counts.
840
- *
841
- * The group stays visible either way. Declaring an active source at all is
842
- * what keeps it in the dock; this answers how it is drawn, not whether it is
843
- * there.
844
- */
845
- const worktreeActivitySource = {
846
- subscribe(listener) {
847
- const subscription = worktreeActivity.store.subscribe(listener);
848
- return () => subscription.unsubscribe();
849
- },
850
- isActive(sessionId) {
851
- return worktreeActivity.select(worktreeActivity.store.state, sessionId).pending !== void 0;
852
- }
853
- };
854
- function isRecord$2(value) {
855
- return typeof value === "object" && value !== null && !Array.isArray(value);
856
- }
857
- function optionalText$1(value) {
858
- return typeof value === "string" && value !== "" ? value : void 0;
859
- }
860
- function optionalErrorTarget$1(value) {
861
- if (!isRecord$2(value)) return void 0;
862
- if (value.action === "create") return { action: "create" };
863
- if (value.action === "close" && typeof value.id === "string" && value.id !== "") return {
864
- action: "close",
865
- id: value.id
866
- };
867
- }
868
- /** The plugin's session data channel: 'git_worktrees' payloads into the store. */
869
- const worktreesChannel = worktreeActivity.channel({
870
- channel: GIT_WORKTREES_TYPE,
871
- parse(input) {
872
- if (!isRecord$2(input) || !Array.isArray(input.worktrees)) return null;
873
- return {
874
- worktrees: input.worktrees.filter(isRecord$2),
875
- pending: optionalText$1(input.pending),
876
- error: optionalText$1(input.error),
877
- errorTarget: optionalErrorTarget$1(input.errorTarget)
878
- };
879
- },
880
- reduce(_current, next) {
881
- return next;
882
- }
883
- });
884
- /** Sends one command to the session's worktrees channel. */
885
- function command(sessionId, payload) {
886
- worktreeSender.store.state?.({
887
- type: GIT_WORKTREES_TYPE,
888
- sessionId,
889
- payload
890
- });
891
- }
892
- /** Asks the hub for a worktree. The hub answers on the same channel. */
893
- function requestWorktreeCreate(sessionId, branch, baseRef, reservationId) {
894
- const trimmedBase = baseRef.trim();
895
- command(sessionId, {
896
- action: "create",
897
- branch: branch.trim(),
898
- ...trimmedBase === "" ? {} : { baseRef: trimmedBase },
899
- ...reservationId === void 0 ? {} : { reservationId }
900
- });
901
- }
902
- /** Asks the hub to close one worktree. Refusal on a dirty tree stays the hub's call. */
903
- function requestWorktreeClose(sessionId, id) {
904
- command(sessionId, {
905
- action: "close",
906
- id
907
- });
908
- }
909
- //#endregion
910
- //#region src/extensions/workspaces/sessions/(frontend)/_components/WorktreesPanel.tsx
911
- /**
912
- * The '# git' group's tab: this session's worktrees, and the form that makes one.
913
- *
914
- * DESIGN PATTERNS:
915
- * - Lives in the activity dock, opened by the group's name. A worktree is work
916
- * in progress belonging to the session that started it, so it sits beside
917
- * agents, runners and workflows rather than in a settings screen.
918
- * - Header, form, then cards, the same rhythm the runners and workflows panels
919
- * use. A worktree row is a card because it carries three facts and two
920
- * actions, which a single line cannot hold without becoming a puzzle.
921
- * - Local form state is never reset by a data refresh. Direct lifecycle events
922
- * update the list without discarding a half-typed branch name.
923
- * - Acts through the plugin's own hub channel, so a worktree made here and one
924
- * made by the tool take exactly the same path.
925
- * - Creating waits on the cockpit starting a session, so the hub reports each
926
- * phase on that channel and the header shows it turning. A button that looks
927
- * idle for two minutes reads as broken.
928
- *
929
- * AVOID:
930
- * - Deriving form state from refreshed store data instead of the user's input.
931
- * - Colour classes outside the token set. `doom-error`, `doom-line` and
932
- * `doom-bright` are not tokens: they compile to nothing and the row silently
933
- * loses its border.
934
- */
935
- const WORKTREES_TAB_ID = "git-worktrees";
936
- function worktreesTab() {
937
- return {
938
- id: WORKTREES_TAB_ID,
939
- label: "worktrees",
940
- panel: WorktreesPanel
941
- };
942
- }
943
- function WorktreesPanel({ sessionId, sessionReservationId }) {
944
- const session = useStore(worktreeActivity.store, (state) => worktreeActivity.select(state, sessionId));
945
- const [branch, setBranch] = useState("");
946
- const [baseRef, setBaseRef] = useState("");
947
- const busy = session.pending !== void 0;
948
- const canCreate = sessionId !== null && branch.trim() !== "" && !busy;
949
- const createError = session.errorTarget?.action === "create" ? session.error : void 0;
950
- const closeErrorId = session.errorTarget?.action === "close" ? session.errorTarget.id : void 0;
951
- const panelError = session.error !== void 0 && (session.errorTarget === void 0 || closeErrorId !== void 0 && !session.worktrees.some((worktree) => worktree.id === closeErrorId));
952
- const create = () => {
953
- if (sessionId === null || !canCreate) return;
954
- requestWorktreeCreate(sessionId, branch, baseRef, sessionReservationId);
955
- if (sessionReservationId === void 0) {
956
- setBranch("");
957
- setBaseRef("");
958
- }
959
- };
960
- return /* @__PURE__ */ jsxs("div", {
961
- "data-testid": "git-worktrees-panel",
962
- className: "flex min-h-0 flex-1 flex-col overflow-auto",
963
- children: [
964
- /* @__PURE__ */ jsxs("div", {
965
- className: "flex min-h-11 shrink-0 items-center gap-2.5 border-b border-doom-border-soft px-3 sm:px-[26px]",
966
- children: [
967
- /* @__PURE__ */ jsx("span", {
968
- className: "text-lg font-semibold text-doom-hi",
969
- children: "worktrees"
970
- }),
971
- /* @__PURE__ */ jsx("span", {
972
- "data-testid": "git-worktrees-count",
973
- className: "text-sm text-doom-dim",
974
- children: session.worktrees.length === 0 ? "none yet" : `${String(session.worktrees.length)} open`
975
- }),
976
- /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
977
- session.pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
978
- "data-testid": "git-worktree-pending",
979
- className: "flex min-w-0 items-center gap-1.5",
980
- children: [/* @__PURE__ */ jsx(Spinner, {
981
- className: "h-3 w-3 shrink-0 text-doom-faint",
982
- label: session.pending
983
- }), /* @__PURE__ */ jsx("span", {
984
- className: "min-w-0 truncate text-sm text-doom-dim",
985
- children: session.pending
986
- })]
987
- })
988
- ]
989
- }),
990
- /* @__PURE__ */ jsxs("div", {
991
- className: "flex shrink-0 flex-col gap-1.5 border-b border-doom-border-soft px-3 py-2.5 sm:px-[26px]",
992
- children: [/* @__PURE__ */ jsxs("div", {
993
- className: "flex items-center gap-2",
994
- children: [
995
- /* @__PURE__ */ jsx(Input, {
996
- "data-testid": "git-worktree-branch",
997
- "aria-label": "Branch",
998
- size: "sm",
999
- className: "min-w-0 flex-1",
1000
- placeholder: "wt/fix-auth",
1001
- value: branch,
1002
- onChange: (event) => setBranch(event.target.value),
1003
- onKeyDown: (event) => {
1004
- if (event.key === "Enter") create();
1005
- }
1006
- }),
1007
- /* @__PURE__ */ jsx(Input, {
1008
- "data-testid": "git-worktree-base",
1009
- "aria-label": "Base ref",
1010
- size: "sm",
1011
- placeholder: "base (current)",
1012
- value: baseRef,
1013
- onChange: (event) => setBaseRef(event.target.value),
1014
- className: "w-32 shrink-0"
1015
- }),
1016
- /* @__PURE__ */ jsx(Button, {
1017
- "data-testid": "git-worktree-create",
1018
- size: "sm",
1019
- disabled: !canCreate,
1020
- onClick: create,
1021
- children: "Create"
1022
- })
1023
- ]
1024
- }), createError === void 0 ? /* @__PURE__ */ jsx("p", {
1025
- className: "text-sm text-doom-dim",
1026
- children: "Creates the worktree outside the repository and starts a session nested under this one."
1027
- }) : /* @__PURE__ */ jsx("p", {
1028
- role: "alert",
1029
- "data-testid": "git-worktree-error",
1030
- className: "text-sm text-doom-red",
1031
- children: createError
1032
- })]
1033
- }),
1034
- panelError ? /* @__PURE__ */ jsx("p", {
1035
- role: "alert",
1036
- "data-testid": "git-worktree-error",
1037
- className: "px-3 pt-2 text-sm text-doom-red sm:px-[26px]",
1038
- children: session.error
1039
- }) : null,
1040
- session.worktrees.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
1041
- "data-testid": "git-worktrees-empty",
1042
- className: "px-4 py-8",
1043
- title: "no worktrees yet",
1044
- description: "a worktree is its own checkout on its own branch, with its own session nested under this one."
1045
- }) : /* @__PURE__ */ jsx("ul", {
1046
- "data-testid": "git-worktrees-list",
1047
- className: "flex flex-col gap-2.5 px-3 py-3 sm:px-[26px]",
1048
- children: session.worktrees.map((worktree) => /* @__PURE__ */ jsx(WorktreeCard, {
1049
- worktree,
1050
- sessionId,
1051
- busy,
1052
- error: closeErrorId === worktree.id ? session.error : void 0
1053
- }, worktree.id))
1054
- })
1055
- ]
1056
- });
1057
- }
1058
- /** One worktree: what it is, where it is, and the one thing you can do to it. */
1059
- function WorktreeCard({ worktree, sessionId, busy, error }) {
1060
- return /* @__PURE__ */ jsxs("li", {
1061
- "data-testid": `git-worktree-${worktree.id}`,
1062
- className: "flex min-w-0 flex-col gap-1.5 rounded-md border border-doom-border-soft bg-doom-panel p-2.5",
1063
- children: [
1064
- /* @__PURE__ */ jsxs("div", {
1065
- className: "flex min-w-0 items-center gap-1.5",
1066
- children: [
1067
- /* @__PURE__ */ jsx(Dot, { tone: worktree.orphaned ? "red" : worktree.sessionId === null ? "muted" : "blue" }),
1068
- /* @__PURE__ */ jsx("span", {
1069
- className: "min-w-0 flex-1 truncate text-base font-semibold text-doom-hi",
1070
- children: worktree.branch
1071
- }),
1072
- worktree.orphaned ? /* @__PURE__ */ jsx(StatusBadge, {
1073
- tone: "error",
1074
- size: "xs",
1075
- children: "orphaned"
1076
- }) : null,
1077
- worktree.unowned ? /* @__PURE__ */ jsx(StatusBadge, {
1078
- tone: "info",
1079
- size: "xs",
1080
- children: "unowned"
1081
- }) : null
1082
- ]
1083
- }),
1084
- /* @__PURE__ */ jsx("span", {
1085
- className: "min-w-0 truncate text-sm text-doom-dim",
1086
- children: worktree.path
1087
- }),
1088
- /* @__PURE__ */ jsxs("div", {
1089
- className: "flex items-center gap-1.5 pt-0.5",
1090
- children: [/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }), sessionId === null ? null : /* @__PURE__ */ jsx(Button, {
1091
- variant: "danger-outline",
1092
- size: "xs",
1093
- disabled: busy,
1094
- "data-testid": `git-worktree-close-${worktree.id}`,
1095
- className: "px-2 ",
1096
- onClick: () => requestWorktreeClose(sessionId, worktree.id),
1097
- children: "close"
1098
- })]
1099
- }),
1100
- error === void 0 ? null : /* @__PURE__ */ jsx("p", {
1101
- role: "alert",
1102
- "data-testid": "git-worktree-error",
1103
- className: "text-sm text-doom-red",
1104
- children: error
1105
- })
1106
- ]
1107
- });
1108
- }
1109
- //#endregion
1110
- //#region src/types/gitReview.ts
1111
- /**
1112
- * The cockpit's view of what this session changed against its base: the counts
1113
- * the '# diff' group shows, what the review tab lists and draws, and the manual
1114
- * sync that sits beside them.
1115
- *
1116
- * DESIGN PATTERNS:
1117
- * - Declarations only, shared by the hub and the page.
1118
- * - Its own channel, apart from worktrees. Worktrees are repository
1119
- * management; the diff is this session's code change.
1120
- * - A diff row carries one line number whose file follows from its marker: the
1121
- * new file's for context and additions, the old file's for removals. It is
1122
- * the same shape the shared DiffView draws.
1123
- *
1124
- * AVOID:
1125
- * - Behaviour or node builtins. Browser code imports this file.
1126
- */
1127
- /** The session channel carrying this checkout's changes and the sync around them. */
1128
- const GIT_CHANGES_TYPE = "git_changes";
1129
- //#endregion
1130
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitChangesStore.ts
1131
- /**
1132
- * This session's code change against its base, and the manual sync beside it.
1133
- *
1134
- * DESIGN PATTERNS:
1135
- * - Its own session store and hub channel, apart from worktrees. Worktrees are
1136
- * repository management; this is the session's diff.
1137
- * - The hub is authoritative: it computes the counts, runs pull, push and
1138
- * rebase one at a time, and reports pending, errors and a paused rebase here.
1139
- * - The sender lives in a store rather than a module variable, because a web
1140
- * plugin keeps no mutable module state and the page may reconnect.
1141
- *
1142
- * AVOID:
1143
- * - `sendSessionFrame` for a sync command. That frame is bound for the agent
1144
- * and never reaches a channel's `receive`.
1145
- */
1146
- const gitChanges = defineSessionStore({
1147
- changes: void 0,
1148
- pending: void 0,
1149
- error: void 0,
1150
- errorTarget: void 0
1151
- });
1152
- /** The page's hub socket sender; undefined before start and after dispose, when a command is dropped. */
1153
- const changesSender = defineGlobalStore(void 0);
1154
- /** Binds the page's hub socket for sync commands. The plugin host calls this once per page. */
1155
- function startGitChangesRuntime(runtime) {
1156
- const send = (frame) => {
1157
- runtime.sendHubFrame(frame);
1158
- };
1159
- changesSender.update(() => send);
1160
- return () => {
1161
- changesSender.update((current) => current === send ? void 0 : current);
1162
- };
1163
- }
1164
- /** Whether a sync command is running for this session. */
1165
- const gitChangesActivitySource = {
1166
- subscribe(listener) {
1167
- const subscription = gitChanges.store.subscribe(listener);
1168
- return () => subscription.unsubscribe();
1169
- },
1170
- isActive(sessionId) {
1171
- return gitChanges.select(gitChanges.store.state, sessionId).pending !== void 0;
1172
- }
1173
- };
1174
- function isRecord$1(value) {
1175
- return typeof value === "object" && value !== null && !Array.isArray(value);
1176
- }
1177
- function optionalText(value) {
1178
- return typeof value === "string" && value !== "" ? value : void 0;
1179
- }
1180
- function count(value) {
1181
- return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
1182
- }
1183
- /** The page never trusts a frame's shape: a malformed field drops, the rest still renders. */
1184
- const MAX_CONFLICTS = 100;
1185
- function optionalChanges(value) {
1186
- if (!isRecord$1(value)) return void 0;
1187
- const added = count(value.added);
1188
- const removed = count(value.removed);
1189
- const files = count(value.files);
1190
- if (added === void 0 || removed === void 0 || files === void 0) return void 0;
1191
- const branch = optionalText(value.branch);
1192
- const base = optionalText(value.base);
1193
- const upstream = isRecord$1(value.upstream) ? value.upstream : void 0;
1194
- const ahead = count(upstream?.ahead);
1195
- const behind = count(upstream?.behind);
1196
- const upstreamRef = optionalText(upstream?.ref);
1197
- const conflicts = isRecord$1(value.rebase) && Array.isArray(value.rebase.conflicts) ? value.rebase.conflicts.filter((file) => typeof file === "string").slice(0, MAX_CONFLICTS) : void 0;
1198
- return {
1199
- added,
1200
- removed,
1201
- files,
1202
- ...branch === void 0 ? {} : { branch },
1203
- ...base === void 0 ? {} : { base },
1204
- ...value.truncated === true ? { truncated: true } : {},
1205
- ...upstreamRef === void 0 || ahead === void 0 || behind === void 0 ? {} : { upstream: {
1206
- ref: upstreamRef,
1207
- ahead,
1208
- behind
1209
- } },
1210
- ...conflicts === void 0 ? {} : { rebase: { conflicts } }
1211
- };
1212
- }
1213
- function optionalErrorTarget(value) {
1214
- if (!isRecord$1(value)) return void 0;
1215
- if (value.action === "pull" || value.action === "rebase" || value.action === "abort-rebase") return { action: value.action };
1216
- if (value.action === "push") return value.forceRequired === true ? {
1217
- action: "push",
1218
- forceRequired: true
1219
- } : { action: "push" };
1220
- }
1221
- /** The session data channel: 'git_changes' payloads into the store. */
1222
- const gitChangesChannel = gitChanges.channel({
1223
- channel: GIT_CHANGES_TYPE,
1224
- parse(input) {
1225
- if (!isRecord$1(input)) return null;
1226
- return {
1227
- changes: optionalChanges(input.changes),
1228
- pending: optionalText(input.pending),
1229
- error: optionalText(input.error),
1230
- errorTarget: optionalErrorTarget(input.errorTarget)
1231
- };
1232
- },
1233
- reduce(_current, next) {
1234
- return next;
1235
- }
1236
- });
1237
- /**
1238
- * Asks the hub to pull, push, rebase or abort a paused rebase. `force` is only
1239
- * ever sent after the reader confirmed a lease force for a rejected push.
1240
- */
1241
- function requestGitSync(sessionId, action, force = false) {
1242
- const payload = action === "push" && force ? {
1243
- action: "push",
1244
- force: true
1245
- } : { action };
1246
- changesSender.store.state?.({
1247
- type: GIT_CHANGES_TYPE,
1248
- sessionId,
1249
- payload
1250
- });
1251
- }
1252
- //#endregion
1253
- //#region src/extensions/workspaces/sessions/(frontend)/activity-group/git.web.ts
1254
- var git_web_default = defineActivityGroup({
1255
- keys: "g w",
1256
- icon: BranchIcon,
1257
- activeSource: {
1258
- subscribe(listener) {
1259
- const stopWorktrees = worktreeActivitySource.subscribe(listener);
1260
- const stopChanges = gitChangesActivitySource.subscribe(listener);
1261
- return () => {
1262
- stopWorktrees();
1263
- stopChanges();
1264
- };
1265
- },
1266
- isActive(sessionId) {
1267
- return worktreeActivitySource.isActive(sessionId) || gitChangesActivitySource.isActive(sessionId);
1268
- }
1269
- },
1270
- marksBackgroundWork: false,
1271
- transientTab: worktreesTab,
1272
- order: 30
1273
- });
1274
- //#endregion
1275
- //#region src/extensions/workspaces/sessions/(frontend)/channel/gitChanges.web.ts
1276
- var gitChanges_web_default = defineChannelFile(gitChangesChannel);
1277
- //#endregion
1278
- //#region src/extensions/workspaces/sessions/(frontend)/channel/gitWorktrees.web.ts
1279
- var gitWorktrees_web_default = defineChannelFile(worktreesChannel);
1280
- //#endregion
1281
- //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/WorktreeCreateDialog.tsx
1282
- function WorktreeSessionMenu({ sessionId, sessionReservationId }) {
1283
- return /* @__PURE__ */ jsx(DropdownMenuItem, {
1284
- disabled: sessionId === null,
1285
- "data-testid": `session-worktree-${sessionReservationId ?? sessionId}`,
1286
- onSelect: () => {
1287
- if (sessionId !== null) worktreeCreateDialog.update(() => ({
1288
- sessionId,
1289
- ...sessionReservationId === void 0 ? {} : { reservationId: sessionReservationId }
1290
- }));
1291
- },
1292
- children: "create worktree session…"
1293
- });
1294
- }
1295
- function WorktreeCreateOverlay(props) {
1296
- const target = useStore(worktreeCreateDialog.store);
1297
- const { holdSessionChannels } = props;
1298
- useEffect(() => {
1299
- if (target === null) return;
1300
- return holdSessionChannels?.(target.sessionId, (frame) => {
1301
- if (frame.type !== worktreesChannel.channel) return;
1302
- const payload = worktreesChannel.parse(frame.payload);
1303
- if (payload !== null) worktreesChannel.apply(target.sessionId, payload);
1304
- });
1305
- }, [target, holdSessionChannels]);
1306
- if (target === null) return null;
1307
- return /* @__PURE__ */ jsx(Dialog, {
1308
- open: true,
1309
- onOpenChange: (open) => {
1310
- if (!open) worktreeCreateDialog.update(() => null);
1311
- },
1312
- children: /* @__PURE__ */ jsxs(DialogContent, {
1313
- width: "lg",
1314
- "data-testid": "session-worktree-dialog",
1315
- children: [/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: "create worktree session" }) }), /* @__PURE__ */ jsxs(DialogBody, { children: [/* @__PURE__ */ jsx(DialogDescription, { children: "Creates a separate checkout and a new session. Existing sessions stay in their current directories." }), /* @__PURE__ */ createElement(WorktreesPanel, {
1316
- ...props,
1317
- key: `${target.sessionId}:${target.reservationId ?? ""}`,
1318
- sessionId: target.sessionId,
1319
- sessionReservationId: target.reservationId
1320
- })] })]
1321
- })
1322
- });
1323
- }
1324
- //#endregion
1325
- //#region src/extensions/workspaces/sessions/(frontend)/fill/Worktree.session-menu.web.tsx
1326
- var Worktree_session_menu_web_default = defineFill({ component: WorktreeSessionMenu });
1327
- //#endregion
1328
- //#region src/extensions/workspaces/sessions/(frontend)/fill/WorktreeCreate.overlay.web.tsx
1329
- var WorktreeCreate_overlay_web_default = defineFill({ component: WorktreeCreateOverlay });
1330
- //#endregion
1331
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewApi.ts
1332
- /**
1333
- * The review tab's half of the session API: the changed-file list and one
1334
- * file's hunks.
1335
- *
1336
- * The generated client owns the URL and the sealed transport, so nothing here
1337
- * spells a route or reaches for `fetch`. What travels is file contents, which a
1338
- * tunnel's relay must never see in the clear.
1339
- */
1340
- const UNREACHABLE = "The session is unreachable.";
1341
- function isRecord(value) {
1342
- return typeof value === "object" && value !== null && !Array.isArray(value);
1343
- }
1344
- /** The message a reader is shown: the route's own words, or why there were none. */
1345
- function messageOf(result) {
1346
- if (result.status === 0) return UNREACHABLE;
1347
- return result.error === "" ? `The session answered ${String(result.status)}.` : result.error;
1348
- }
1349
- async function fetchReviewSummary(sessionId, signal) {
1350
- const result = await api.session(sessionId).review(signal === void 0 ? {} : { signal });
1351
- if (!result.ok) return {
1352
- ok: false,
1353
- error: messageOf(result)
1354
- };
1355
- if (!isRecord(result.data) || !Array.isArray(result.data.files)) return {
1356
- ok: false,
1357
- error: "The session answered with no file list."
1358
- };
1359
- return {
1360
- ok: true,
1361
- summary: result.data
1362
- };
1363
- }
1364
- async function fetchReviewFile(sessionId, path, signal) {
1365
- const result = await api.session(sessionId).reviewFile({
1366
- query: { path },
1367
- ...signal === void 0 ? {} : { signal }
1368
- });
1369
- if (!result.ok) return {
1370
- ok: false,
1371
- error: messageOf(result)
1372
- };
1373
- if (!isRecord(result.data) || !Array.isArray(result.data.hunks)) return {
1374
- ok: false,
1375
- error: "The session answered with no diff."
1376
- };
1377
- return {
1378
- ok: true,
1379
- diff: result.data
1380
- };
1381
- }
1382
- //#endregion
1383
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewStore.ts
1384
- const gitReview = defineSessionStore({ comments: [] });
1385
- function addReviewComment(sessionId, comment) {
1386
- const id = globalThis.crypto.randomUUID();
1387
- gitReview.update(sessionId, (current) => ({ comments: [...current.comments, {
1388
- ...comment,
1389
- id
1390
- }] }));
1391
- }
1392
- function removeReviewComment(sessionId, id) {
1393
- gitReview.update(sessionId, (current) => ({ comments: current.comments.filter((comment) => comment.id !== id) }));
1394
- }
1395
- function clearReviewComments(sessionId) {
1396
- gitReview.update(sessionId, () => ({ comments: [] }));
1397
- }
1398
- //#endregion
1399
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewView.ts
1400
- /**
1401
- * Pure wording for what the review tab sends the agent, and the one send.
1402
- *
1403
- * Both messages go as `follow_up`: the host starts a turn when idle, or after
1404
- * the current one, without treating a review send as an explicit Queue action.
1405
- * A `prompt` frame instead fails while a turn runs.
1406
- */
1407
- /** The opening line of a review, naming what was reviewed. */
1408
- function reviewHeading(count, branch, base) {
1409
- 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."}`;
1410
- }
1411
- /** What "ask agent to resolve" sends: the files, and the steps that finish a paused rebase. */
1412
- function conflictPrompt(branch, conflicts) {
1413
- const files = conflicts.length === 0 ? "- (git reports no unmerged paths yet)" : conflicts.map((file) => `- ${file}`).join("\n");
1414
- return [
1415
- `A rebase of ${branch ?? "this checkout"} stopped on conflicts in:`,
1416
- "",
1417
- files,
1418
- "",
1419
- "Please resolve them:",
1420
- "1. Fix the conflict markers in each file, keeping the intent of both sides.",
1421
- "2. `git add` each resolved file.",
1422
- "3. Run `GIT_EDITOR=true git rebase --continue`, and repeat if it stops again.",
1423
- "",
1424
- "Do not push. I will push from the cockpit once the rebase is done."
1425
- ].join("\n");
1426
- }
1427
- /**
1428
- * Hands one deferred follow-up to the session. Returns the reason it failed,
1429
- * or undefined once sent. The sender throws when the session is disconnected.
1430
- */
1431
- function deliverToAgent(send, sessionId, message) {
1432
- try {
1433
- send(sessionId, {
1434
- type: "follow_up",
1435
- message
1436
- });
1437
- return;
1438
- } catch (error) {
1439
- return error instanceof Error ? error.message : "The session did not take the message.";
1440
- }
1441
- }
1442
- //#endregion
1443
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewTree.ts
1444
- function buildTree(files) {
1445
- const root = {
1446
- path: "",
1447
- dirs: /* @__PURE__ */ new Map(),
1448
- files: []
1449
- };
1450
- for (const file of files) {
1451
- const parts = file.path.split("/");
1452
- parts.pop();
1453
- let dir = root;
1454
- for (const part of parts) {
1455
- let next = dir.dirs.get(part);
1456
- if (next === void 0) {
1457
- next = {
1458
- path: dir.path === "" ? part : `${dir.path}/${part}`,
1459
- dirs: /* @__PURE__ */ new Map(),
1460
- files: []
1461
- };
1462
- dir.dirs.set(part, next);
1463
- }
1464
- dir = next;
1465
- }
1466
- dir.files.push(file);
1467
- }
1468
- return root;
1469
- }
1470
- /** Folders first, then files, each alphabetical; single-child folder chains render as one row. */
1471
- function flattenTree(files, isOpen) {
1472
- const rows = [];
1473
- const walk = (dir, depth) => {
1474
- for (const [segment, child] of [...dir.dirs].sort(([left], [right]) => left.localeCompare(right))) {
1475
- let name = segment;
1476
- let node = child;
1477
- while (node.files.length === 0 && node.dirs.size === 1) {
1478
- const [[nextName, next]] = [...node.dirs];
1479
- name = `${name}/${nextName}`;
1480
- node = next;
1481
- }
1482
- const open = isOpen(node.path);
1483
- rows.push({
1484
- kind: "dir",
1485
- path: node.path,
1486
- name,
1487
- depth,
1488
- open
1489
- });
1490
- if (open) walk(node, depth + 1);
1491
- }
1492
- for (const file of [...dir.files].sort((left, right) => left.path.localeCompare(right.path))) rows.push({
1493
- kind: "file",
1494
- file,
1495
- depth
1496
- });
1497
- };
1498
- walk(buildTree(files), 0);
1499
- return rows;
1500
- }
1501
- //#endregion
1502
- //#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewBrowser.tsx
1503
- /**
1504
- * The review tab's file list: every changed file as a folder tree (the VS Code
1505
- * explorer shape), with its status, its line counts and its comments.
1506
- *
1507
- * DESIGN PATTERNS:
1508
- * - Picking a row scrolls the diff pane to that file; the list never opens a
1509
- * tab of its own. One tab holds the whole review.
1510
- * - Rows show only the file name, so a long path never truncates away the part
1511
- * that tells files apart. Folders say the rest; the full path is the tooltip.
1512
- * - Folder chains with a single child collapse into one row (`src/web/lib`),
1513
- * like VS Code's compact folders, so deep trees stay shallow.
1514
- * - Folders fold on click; a search shows every match with its folders open.
1515
- * - Each row is a button, reachable with Tab; arrow keys move and pick in one
1516
- * step across the visible files, so the diff follows the cursor.
1517
- *
1518
- * AVOID:
1519
- * - Colour classes outside the token set.
1520
- */
1521
- const STATUS_MARK = {
1522
- added: {
1523
- letter: "A",
1524
- tone: "text-doom-green",
1525
- label: "added"
1526
- },
1527
- modified: {
1528
- letter: "M",
1529
- tone: "text-doom-yellow",
1530
- label: "modified"
1531
- },
1532
- deleted: {
1533
- letter: "D",
1534
- tone: "text-doom-red",
1535
- label: "deleted"
1536
- },
1537
- untracked: {
1538
- letter: "U",
1539
- tone: "text-doom-blue",
1540
- label: "untracked"
1541
- },
1542
- conflicted: {
1543
- letter: "C",
1544
- tone: "text-doom-red",
1545
- label: "conflicted"
1546
- }
1547
- };
1548
- const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
1549
- /** Left padding per depth level; tight so deep files keep room for their names. */
1550
- const indent = (depth) => ({ paddingLeft: `${String(.75 + depth * .5)}rem` });
1551
- function GitReviewBrowser({ files, activePath, commentCounts, onPick }) {
1552
- const [query, setQuery] = useState("");
1553
- const [collapsed, setCollapsed] = useState(/* @__PURE__ */ new Set());
1554
- const needle = query.trim().toLocaleLowerCase();
1555
- const shown = needle === "" ? files : files.filter((file) => file.path.toLocaleLowerCase().includes(needle));
1556
- const rows = flattenTree(shown, (dirPath) => needle !== "" || !collapsed.has(dirPath));
1557
- const ordered = rows.flatMap((row) => row.kind === "file" ? [row.file] : []);
1558
- const listRef = useRef(null);
1559
- useEffect(() => {
1560
- if (activePath === void 0) return;
1561
- (listRef.current?.querySelector(`[data-path="${CSS.escape(activePath)}"]`))?.scrollIntoView({ block: "nearest" });
1562
- }, [activePath]);
1563
- const toggle = (dirPath) => {
1564
- setCollapsed((prev) => {
1565
- const next = new Set(prev);
1566
- if (!next.delete(dirPath)) next.add(dirPath);
1567
- return next;
1568
- });
1569
- };
1570
- const allDirs = () => new Set(flattenTree(files, () => true).flatMap((row) => row.kind === "dir" ? [row.path] : []));
1571
- /** Arrow keys pick the neighbouring row and move focus with it, so the diff follows the cursor. */
1572
- const onKeyDown = (event) => {
1573
- if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
1574
- if (ordered.length === 0) return;
1575
- event.preventDefault();
1576
- const current = ordered.findIndex((file) => file.path === activePath);
1577
- const step = event.key === "ArrowDown" ? 1 : -1;
1578
- const next = current === -1 ? 0 : (current + step + ordered.length) % ordered.length;
1579
- const file = ordered[next];
1580
- if (file === void 0) return;
1581
- onPick(file.path);
1582
- event.currentTarget.closest("[data-git-review-list]")?.querySelectorAll("[data-git-review-row]")[next]?.focus();
1583
- };
1584
- return /* @__PURE__ */ jsxs("nav", {
1585
- "data-testid": "git-review-browser",
1586
- "aria-label": "changed files",
1587
- className: "flex min-h-0 w-64 shrink-0 flex-col border-r border-doom-border-soft",
1588
- children: [/* @__PURE__ */ jsxs("div", {
1589
- className: "flex shrink-0 flex-col gap-1 px-3 pt-2.5 pb-1.5",
1590
- children: [/* @__PURE__ */ jsx(Input, {
1591
- "data-testid": "git-review-search",
1592
- "aria-label": "search changed file paths",
1593
- size: "sm",
1594
- value: query,
1595
- placeholder: "search paths…",
1596
- onChange: (event) => setQuery(event.target.value)
1597
- }), /* @__PURE__ */ jsxs("div", {
1598
- className: "flex items-center justify-between gap-2",
1599
- children: [/* @__PURE__ */ jsx("span", {
1600
- className: "text-sm text-doom-dim",
1601
- children: needle === "" ? `${String(files.length)} changed` : `${String(shown.length)} of ${String(files.length)} match`
1602
- }), needle === "" ? /* @__PURE__ */ jsx(Button, {
1603
- variant: "link",
1604
- size: "xs",
1605
- "data-testid": "git-review-fold-all",
1606
- className: "h-auto px-0",
1607
- onClick: () => setCollapsed(collapsed.size === 0 ? allDirs() : /* @__PURE__ */ new Set()),
1608
- children: collapsed.size === 0 ? "collapse all" : "expand all"
1609
- }) : null]
1610
- })]
1611
- }), /* @__PURE__ */ jsxs("div", {
1612
- ref: listRef,
1613
- "data-git-review-list": true,
1614
- className: "min-h-0 flex-1 overflow-y-auto pb-2",
1615
- children: [rows.map((row) => {
1616
- if (row.kind === "dir") {
1617
- const Chevron = row.open ? ChevronDownIcon : ChevronRightIcon;
1618
- return /* @__PURE__ */ jsxs(Button, {
1619
- variant: "ghost",
1620
- size: "card",
1621
- "aria-expanded": row.open,
1622
- "data-testid": `git-review-browser-dir-${row.path}`,
1623
- title: row.path,
1624
- onClick: () => toggle(row.path),
1625
- style: indent(row.depth),
1626
- className: "min-w-0 flex-row items-center gap-1 rounded-none border-l-2 border-transparent py-1 pr-3 hover:bg-doom-panel/60",
1627
- children: [/* @__PURE__ */ jsx(Chevron, {
1628
- "aria-hidden": true,
1629
- className: "h-3 w-3 shrink-0 text-doom-faint"
1630
- }), /* @__PURE__ */ jsx("span", {
1631
- className: "min-w-0 flex-1 truncate text-base text-doom-dim",
1632
- children: row.name
1633
- })]
1634
- }, `dir:${row.path}`);
1635
- }
1636
- const { file } = row;
1637
- const mark = STATUS_MARK[file.status];
1638
- const comments = commentCounts[file.path] ?? 0;
1639
- const active = file.path === activePath;
1640
- return /* @__PURE__ */ jsxs(Button, {
1641
- variant: "ghost",
1642
- size: "card",
1643
- "data-git-review-row": true,
1644
- "data-path": file.path,
1645
- "aria-current": active ? "true" : void 0,
1646
- "data-testid": `git-review-browser-file-${file.path}`,
1647
- title: `${file.path} (${mark.label})`,
1648
- onClick: () => onPick(file.path),
1649
- onKeyDown,
1650
- style: indent(row.depth),
1651
- className: `min-w-0 flex-row items-center gap-1.5 rounded-none border-l-2 py-1 pr-3 ${active ? "border-doom-blue bg-doom-panel" : "border-transparent hover:bg-doom-panel/60"}`,
1652
- children: [
1653
- /* @__PURE__ */ jsx("span", {
1654
- "aria-label": mark.label,
1655
- className: `w-3 shrink-0 text-center text-sm font-semibold ${mark.tone}`,
1656
- children: mark.letter
1657
- }),
1658
- /* @__PURE__ */ jsx("span", {
1659
- className: `min-w-0 flex-1 truncate text-base text-doom-hi ${active ? "font-semibold" : ""} ${file.status === "deleted" ? "line-through opacity-70" : ""}`,
1660
- children: baseName(file.path)
1661
- }),
1662
- comments === 0 ? null : /* @__PURE__ */ jsxs("span", {
1663
- className: "flex shrink-0 items-center gap-0.5 text-sm text-doom-blue",
1664
- title: `${String(comments)} comments`,
1665
- children: [/* @__PURE__ */ jsx(MessageIcon, {
1666
- "aria-hidden": true,
1667
- className: "h-2.5 w-2.5"
1668
- }), comments]
1669
- }),
1670
- file.binary ? /* @__PURE__ */ jsx("span", {
1671
- className: "shrink-0 text-sm text-doom-dim",
1672
- children: "binary"
1673
- }) : /* @__PURE__ */ jsxs("span", {
1674
- className: "shrink-0 text-2xs",
1675
- children: [
1676
- /* @__PURE__ */ jsxs("span", {
1677
- className: "text-doom-green",
1678
- children: ["+", file.added]
1679
- }),
1680
- " ",
1681
- /* @__PURE__ */ jsxs("span", {
1682
- className: "text-doom-red",
1683
- children: ["-", file.removed]
1684
- })
1685
- ]
1686
- })
1687
- ]
1688
- }, file.path);
1689
- }), ordered.length === 0 ? /* @__PURE__ */ jsx("p", {
1690
- className: "px-3 py-2 text-sm text-doom-dim",
1691
- children: "no path matches"
1692
- }) : null]
1693
- })]
1694
- });
1695
- }
1696
- //#endregion
1697
- //#region src/extensions/workspaces/sessions/(frontend)/_components/GitSyncBar.tsx
1698
- /**
1699
- * The review tab's header: what is being reviewed, and the manual sync that
1700
- * goes with it.
1701
- *
1702
- * DESIGN PATTERNS:
1703
- * - Everything here is a report or a request. The hub runs the git, one
1704
- * command per session, and answers on the channel: pending while it runs,
1705
- * an error target when it fails, a paused rebase when it stops on conflicts.
1706
- * - A push the remote rejects is not an error to shrug at: the strip asks
1707
- * before forcing, and the force it sends is a lease, which git refuses if
1708
- * the remote holds commits this branch never fetched and integrated.
1709
- * - A paused rebase blocks the sync buttons. The two ways out are named on the
1710
- * banner, and abort says what it throws away.
1711
- *
1712
- * AVOID:
1713
- * - Colour classes outside the token set.
1714
- */
1715
- function GitSyncBar({ changes, mergeBase, pending, error, errorTarget, onSync, onForcePush, onAskAgent, onDismissError }) {
1716
- const busy = pending !== void 0;
1717
- const paused = changes?.rebase !== void 0;
1718
- const forceRequired = errorTarget?.action === "push" && "forceRequired" in errorTarget && errorTarget.forceRequired === true;
1719
- const syncError = error !== void 0 && !forceRequired ? error : void 0;
1720
- const upstream = changes?.upstream;
1721
- return /* @__PURE__ */ jsxs("div", {
1722
- "data-testid": "git-sync-bar",
1723
- className: "flex shrink-0 flex-col gap-2 border-b border-doom-border-soft px-3 py-2.5 sm:px-[26px]",
1724
- children: [
1725
- /* @__PURE__ */ jsxs("div", {
1726
- className: "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5",
1727
- children: [
1728
- /* @__PURE__ */ jsxs("span", {
1729
- className: "flex min-w-0 items-center gap-1.5",
1730
- children: [
1731
- /* @__PURE__ */ jsx(BranchIcon, {
1732
- "aria-hidden": true,
1733
- className: "h-3.5 w-3.5 shrink-0 text-doom-faint"
1734
- }),
1735
- /* @__PURE__ */ jsx("span", {
1736
- "data-testid": "git-review-branch",
1737
- className: "truncate text-base font-semibold text-doom-hi",
1738
- children: changes?.branch ?? "detached HEAD"
1739
- }),
1740
- /* @__PURE__ */ jsx("span", {
1741
- className: "shrink-0 text-sm text-doom-dim",
1742
- children: changes?.base === void 0 ? "uncommitted changes" : `vs ${changes.base}`
1743
- }),
1744
- mergeBase === void 0 ? null : /* @__PURE__ */ jsxs("span", {
1745
- className: "shrink-0 text-sm text-doom-dim",
1746
- children: ["from ", mergeBase]
1747
- })
1748
- ]
1749
- }),
1750
- changes === void 0 ? null : /* @__PURE__ */ jsxs("span", {
1751
- "data-testid": "git-review-totals",
1752
- className: "flex shrink-0 items-center gap-1.5 text-sm",
1753
- children: [
1754
- /* @__PURE__ */ jsxs("span", {
1755
- className: "font-semibold text-doom-green",
1756
- children: ["+", changes.added]
1757
- }),
1758
- /* @__PURE__ */ jsxs("span", {
1759
- className: "font-semibold text-doom-red",
1760
- children: ["-", changes.removed]
1761
- }),
1762
- /* @__PURE__ */ jsxs("span", {
1763
- className: "text-doom-dim",
1764
- children: [
1765
- changes.files,
1766
- changes.truncated ? "+" : "",
1767
- " ",
1768
- changes.files === 1 ? "file" : "files"
1769
- ]
1770
- })
1771
- ]
1772
- }),
1773
- /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
1774
- upstream === void 0 ? /* @__PURE__ */ jsx("span", {
1775
- className: "shrink-0 text-sm text-doom-dim",
1776
- children: "no upstream yet"
1777
- }) : /* @__PURE__ */ jsxs("span", {
1778
- className: "shrink-0 text-sm text-doom-dim",
1779
- title: upstream.ref,
1780
- children: [
1781
- "↑",
1782
- upstream.ahead,
1783
- " ↓",
1784
- upstream.behind,
1785
- " ",
1786
- upstream.ref
1787
- ]
1788
- }),
1789
- /* @__PURE__ */ jsxs("span", {
1790
- className: "flex shrink-0 items-center gap-1.5",
1791
- children: [
1792
- /* @__PURE__ */ jsx(Button, {
1793
- size: "xs",
1794
- "data-testid": "git-sync-pull",
1795
- disabled: busy || paused || upstream === void 0,
1796
- title: upstream === void 0 ? "push once to set an upstream" : `fetch, then rebase onto ${upstream.ref}`,
1797
- onClick: () => onSync("pull"),
1798
- children: "pull"
1799
- }),
1800
- /* @__PURE__ */ jsx(Button, {
1801
- size: "xs",
1802
- "data-testid": "git-sync-push",
1803
- disabled: busy || paused || changes?.branch === void 0,
1804
- title: upstream === void 0 ? "push and set the upstream" : `push to ${upstream.ref}`,
1805
- onClick: () => onSync("push"),
1806
- children: "push"
1807
- }),
1808
- /* @__PURE__ */ jsx(Button, {
1809
- size: "xs",
1810
- "data-testid": "git-sync-rebase",
1811
- disabled: busy || paused || changes?.base === void 0,
1812
- title: changes?.base === void 0 ? "no base branch to rebase onto" : `fetch, then rebase onto ${changes.base}`,
1813
- onClick: () => onSync("rebase"),
1814
- children: "rebase"
1815
- })
1816
- ]
1817
- })
1818
- ]
1819
- }),
1820
- pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
1821
- "data-testid": "git-sync-pending",
1822
- className: "flex min-w-0 items-center gap-1.5",
1823
- children: [/* @__PURE__ */ jsx(Spinner, {
1824
- className: "h-3 w-3 shrink-0 text-doom-faint",
1825
- label: pending
1826
- }), /* @__PURE__ */ jsx("span", {
1827
- className: "min-w-0 truncate text-sm text-doom-dim",
1828
- children: pending
1829
- })]
1830
- }),
1831
- syncError === void 0 ? null : /* @__PURE__ */ jsxs("div", {
1832
- role: "alert",
1833
- "data-testid": "git-sync-error",
1834
- className: "flex min-w-0 items-start gap-1.5 text-sm text-doom-red",
1835
- children: [
1836
- /* @__PURE__ */ jsx(AlertIcon, {
1837
- "aria-hidden": true,
1838
- className: "mt-px h-3 w-3 shrink-0"
1839
- }),
1840
- /* @__PURE__ */ jsx("span", {
1841
- className: "min-w-0 flex-1",
1842
- children: syncError
1843
- }),
1844
- /* @__PURE__ */ jsx(Button, {
1845
- variant: "ghost",
1846
- size: "xs",
1847
- className: "h-auto min-h-0 px-1 py-0 ",
1848
- onClick: onDismissError,
1849
- children: "dismiss"
1850
- })
1851
- ]
1852
- }),
1853
- forceRequired ? /* @__PURE__ */ jsxs("div", {
1854
- "data-testid": "git-push-force-confirm",
1855
- 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",
1856
- children: [
1857
- /* @__PURE__ */ jsx("span", {
1858
- className: "min-w-0 flex-1 text-base text-doom-hi",
1859
- 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."
1860
- }),
1861
- /* @__PURE__ */ jsx(Button, {
1862
- variant: "danger-outline",
1863
- size: "xs",
1864
- "data-testid": "git-push-force",
1865
- disabled: busy,
1866
- onClick: onForcePush,
1867
- children: "force push"
1868
- }),
1869
- /* @__PURE__ */ jsx(Button, {
1870
- variant: "ghost",
1871
- size: "xs",
1872
- "data-testid": "git-push-force-cancel",
1873
- onClick: onDismissError,
1874
- children: "cancel"
1875
- })
1876
- ]
1877
- }) : null,
1878
- changes?.rebase === void 0 ? null : /* @__PURE__ */ jsxs("div", {
1879
- "data-testid": "git-rebase-banner",
1880
- 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",
1881
- children: [
1882
- /* @__PURE__ */ jsxs("span", {
1883
- className: "flex min-w-0 items-center gap-1.5",
1884
- children: [/* @__PURE__ */ jsx(StatusBadge, {
1885
- tone: "error",
1886
- size: "xs",
1887
- children: "rebase paused"
1888
- }), /* @__PURE__ */ jsx("span", {
1889
- className: "text-base text-doom-hi",
1890
- children: changes.rebase.conflicts.length === 0 ? "git stopped the rebase" : `conflicts in ${String(changes.rebase.conflicts.length)} ${changes.rebase.conflicts.length === 1 ? "file" : "files"}`
1891
- })]
1892
- }),
1893
- changes.rebase.conflicts.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1894
- className: "flex flex-col gap-0.5 pl-1",
1895
- children: changes.rebase.conflicts.map((file) => /* @__PURE__ */ jsx("li", {
1896
- className: "truncate font-mono text-sm text-doom-dim",
1897
- children: file
1898
- }, file))
1899
- }),
1900
- /* @__PURE__ */ jsxs("span", {
1901
- className: "flex flex-wrap items-center gap-1.5",
1902
- children: [
1903
- /* @__PURE__ */ jsx(Button, {
1904
- variant: "primary",
1905
- size: "xs",
1906
- "data-testid": "git-rebase-ask-agent",
1907
- disabled: busy,
1908
- onClick: onAskAgent,
1909
- children: "ask agent to resolve"
1910
- }),
1911
- /* @__PURE__ */ jsx(Button, {
1912
- variant: "danger-outline",
1913
- size: "xs",
1914
- "data-testid": "git-rebase-abort",
1915
- disabled: busy,
1916
- onClick: () => onSync("abort-rebase"),
1917
- children: "abort rebase"
1918
- }),
1919
- /* @__PURE__ */ jsx("span", {
1920
- className: "text-sm text-doom-dim",
1921
- children: "abort discards any conflict resolution in progress"
1922
- })
1923
- ]
1924
- })
1925
- ]
1926
- })
1927
- ]
1928
- });
1929
- }
1930
- //#endregion
1931
- //#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewView.tsx
1932
- /**
1933
- * The review tab, drawn from what it is handed: the sync header, the changed
1934
- * files on the left, every diff stacked on the right, and one footer that
1935
- * sends every comment as a single message.
1936
- *
1937
- * DESIGN PATTERNS:
1938
- * - Like a pull request's file view. Picking a file in the list scrolls to it;
1939
- * each file's header stays pinned while its diff scrolls past.
1940
- * - Only nearby file bodies mount and load; distant files retain their headers
1941
- * and measured-height placeholders.
1942
- * - A comment is drawn under the line it is about, on the side it is about.
1943
- * The open draft sits in the same place, so writing happens in context.
1944
- * - Only UI state lives here (the open draft, which files are collapsed, which
1945
- * is active). Data and comments come in as props; the panel owns fetching.
1946
- *
1947
- * AVOID:
1948
- * - Clearing comments here. The owner clears them once the host took the send.
1949
- */
1950
- const PICK_ALIGNMENT_MS = 300;
1951
- const ESTIMATED_ROW_HEIGHT = 21;
1952
- const MAX_DIFF_ROWS = 5e3;
1953
- const NO_FILES = [];
1954
- const NO_PATHS = /* @__PURE__ */ new Set();
1955
- /** Stable, URL-safe element id for one file's section. */
1956
- function sectionId(index) {
1957
- return `git-review-file-${String(index)}`;
1958
- }
1959
- function sideOf(row) {
1960
- return row.marker === "-" ? "old" : "new";
1961
- }
1962
- function GitReviewView({ sync, summary, files, comments, onLoadFile, onAddComment, onRemoveComment, onSendReview, onDiscard, onDraftChange, sendError, initialDraft }) {
1963
- const entries = summary.state === "ready" ? summary.summary.files : NO_FILES;
1964
- const [openOverride, setOpenOverride] = useState({});
1965
- const [picked, setPicked] = useState();
1966
- const [navigation, setNavigation] = useState();
1967
- const [proximity, setProximity] = useState({
1968
- entries: NO_FILES,
1969
- paths: NO_PATHS
1970
- });
1971
- const near = proximity.entries === entries ? proximity.paths : NO_PATHS;
1972
- const paneRef = useRef(null);
1973
- const [heights, setHeights] = useState(/* @__PURE__ */ new Map());
1974
- const pickedAt = useRef(0);
1975
- const activePath = entries.some((entry) => entry.path === picked) ? picked : entries[0]?.path;
1976
- const [draft, setDraft] = useState(initialDraft);
1977
- useEffect(() => {
1978
- const pane = paneRef.current;
1979
- if (pane === null || typeof IntersectionObserver === "undefined") return;
1980
- setHeights(/* @__PURE__ */ new Map());
1981
- let observer;
1982
- const observe = () => {
1983
- observer?.disconnect();
1984
- observer = new IntersectionObserver((changes) => {
1985
- for (const change of changes) {
1986
- if (change.isIntersecting) continue;
1987
- const section = change.target;
1988
- const path = section.dataset.reviewPath;
1989
- const body = section.querySelector("[data-review-body=\"measured\"]");
1990
- if (path !== void 0 && body !== null) {
1991
- const height = body.offsetHeight;
1992
- setHeights((measured) => new Map(measured).set(path, height));
1993
- }
1994
- }
1995
- setProximity((current) => {
1996
- const next = new Set(current.entries === entries ? current.paths : NO_PATHS);
1997
- for (const change of changes) {
1998
- const path = change.target.dataset.reviewPath;
1999
- if (path === void 0) continue;
2000
- if (change.isIntersecting) next.add(path);
2001
- else next.delete(path);
2002
- }
2003
- return {
2004
- entries,
2005
- paths: next
2006
- };
2007
- });
2008
- }, {
2009
- root: pane,
2010
- rootMargin: `${String(pane.clientHeight)}px 0px`
2011
- });
2012
- for (const section of pane.querySelectorAll("[data-review-path]")) observer.observe(section);
2013
- };
2014
- observe();
2015
- const resize = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(observe);
2016
- resize?.observe(pane);
2017
- let frame = 0;
2018
- const scroll = () => {
2019
- cancelAnimationFrame(frame);
2020
- frame = requestAnimationFrame(() => {
2021
- if (Date.now() - pickedAt.current < PICK_ALIGNMENT_MS) return;
2022
- const rect = pane.getBoundingClientRect();
2023
- const section = document.elementFromPoint(rect.left + 8, rect.top + 1)?.closest("[data-review-path]");
2024
- if (section !== null && section !== void 0 && pane.contains(section)) setPicked(section.dataset.reviewPath);
2025
- });
2026
- };
2027
- pane.addEventListener("scroll", scroll, { passive: true });
2028
- return () => {
2029
- observer.disconnect();
2030
- resize?.disconnect();
2031
- pane.removeEventListener("scroll", scroll);
2032
- cancelAnimationFrame(frame);
2033
- };
2034
- }, [entries]);
2035
- useLayoutEffect(() => {
2036
- if (navigation === void 0) return;
2037
- [...paneRef.current?.querySelectorAll("[data-review-path]") ?? []].find((element) => element.dataset.reviewPath === navigation)?.scrollIntoView({ block: "start" });
2038
- }, [navigation, openOverride]);
2039
- useEffect(() => {
2040
- if (navigation === void 0) return;
2041
- const timeout = setTimeout(() => setNavigation(void 0), PICK_ALIGNMENT_MS);
2042
- return () => clearTimeout(timeout);
2043
- }, [navigation]);
2044
- useEffect(() => {
2045
- for (const file of entries) if ((openOverride[file.path] ?? true) && (near.has(file.path) || navigation === file.path || draft?.path === file.path || comments.some((comment) => comment.path === file.path)) && files[file.path] === void 0) onLoadFile(file.path);
2046
- }, [
2047
- entries,
2048
- files,
2049
- near,
2050
- navigation,
2051
- draft,
2052
- comments,
2053
- openOverride,
2054
- onLoadFile
2055
- ]);
2056
- const commentCounts = {};
2057
- for (const comment of comments) commentCounts[comment.path] = (commentCounts[comment.path] ?? 0) + 1;
2058
- const isOpen = (path) => openOverride[path] ?? true;
2059
- const open = (path) => {
2060
- setOpenOverride((current) => current[path] === true ? current : {
2061
- ...current,
2062
- [path]: true
2063
- });
2064
- };
2065
- const pick = (path) => {
2066
- if (!entries.some((entry) => entry.path === path)) return;
2067
- pickedAt.current = Date.now();
2068
- setPicked(path);
2069
- setNavigation(path);
2070
- open(path);
2071
- };
2072
- const toggle = (path) => {
2073
- if (isOpen(path)) setOpenOverride((current) => ({
2074
- ...current,
2075
- [path]: false
2076
- }));
2077
- else open(path);
2078
- };
2079
- const submitDraft = (body) => {
2080
- if (draft === void 0) return;
2081
- onAddComment({
2082
- path: draft.path,
2083
- relPath: draft.path,
2084
- side: draft.selection.side,
2085
- startLine: draft.selection.startLine,
2086
- endLine: draft.selection.endLine,
2087
- snippet: draft.selection.snippet,
2088
- body: body.trim()
2089
- });
2090
- setDraft(void 0);
2091
- onDraftChange?.(false);
2092
- };
2093
- return /* @__PURE__ */ jsxs("div", {
2094
- "data-testid": "git-review-panel",
2095
- className: "flex min-h-0 flex-1 flex-col",
2096
- children: [
2097
- /* @__PURE__ */ jsx(GitSyncBar, {
2098
- ...sync,
2099
- ...summary.state === "ready" && summary.summary.mergeBase !== void 0 ? { mergeBase: summary.summary.mergeBase } : {}
2100
- }),
2101
- summary.state === "loading" ? /* @__PURE__ */ jsxs("div", {
2102
- "data-testid": "git-review-loading",
2103
- className: "flex flex-1 items-start gap-2 px-4 py-6",
2104
- children: [/* @__PURE__ */ jsx(Spinner, {
2105
- className: "h-3 w-3 text-doom-faint",
2106
- label: "reading changes"
2107
- }), /* @__PURE__ */ jsx("span", {
2108
- className: "text-sm text-doom-dim",
2109
- children: "reading changes…"
2110
- })]
2111
- }) : summary.state === "error" ? /* @__PURE__ */ jsx("p", {
2112
- role: "alert",
2113
- "data-testid": "git-review-error",
2114
- className: "flex-1 px-4 py-6 text-sm text-doom-red",
2115
- children: summary.error
2116
- }) : !summary.summary.repository ? /* @__PURE__ */ jsx(EmptyState, {
2117
- "data-testid": "git-review-no-repository",
2118
- className: "px-4 py-8",
2119
- title: "not a git checkout",
2120
- description: "this session's folder is not inside a git repository, so there is nothing to review."
2121
- }) : entries.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
2122
- "data-testid": "git-review-empty",
2123
- className: "px-4 py-8",
2124
- title: "no changes",
2125
- description: summary.summary.base === void 0 ? "nothing uncommitted in this checkout." : `nothing differs from ${summary.summary.base}, committed or not.`
2126
- }) : /* @__PURE__ */ jsxs("div", {
2127
- className: "flex min-h-0 flex-1",
2128
- children: [/* @__PURE__ */ jsx(GitReviewBrowser, {
2129
- files: entries,
2130
- commentCounts,
2131
- onPick: pick,
2132
- ...activePath === void 0 ? {} : { activePath }
2133
- }), /* @__PURE__ */ jsxs("div", {
2134
- ref: paneRef,
2135
- "data-testid": "git-review-diffs",
2136
- className: "min-h-0 min-w-0 flex-1 overflow-y-auto",
2137
- children: [summary.summary.truncated ? /* @__PURE__ */ jsx("p", {
2138
- className: "px-4 pt-2 text-sm text-doom-dim",
2139
- children: "more files changed than the review lists; the rest are left out."
2140
- }) : null, entries.map((file, index) => /* @__PURE__ */ jsx(FileSection, {
2141
- id: sectionId(index),
2142
- entry: file,
2143
- state: files[file.path],
2144
- collapsed: !isOpen(file.path),
2145
- mounted: near.has(file.path) || navigation === file.path || draft?.path === file.path || comments.some((comment) => comment.path === file.path),
2146
- placeholderHeight: heights.get(file.path) ?? (file.binary ? 40 : Math.min(MAX_DIFF_ROWS, file.added + file.removed + 6) * ESTIMATED_ROW_HEIGHT),
2147
- comments: comments.filter((comment) => comment.path === file.path),
2148
- draft: draft?.path === file.path ? draft.selection : void 0,
2149
- onToggle: () => toggle(file.path),
2150
- onSelect: (selection) => {
2151
- setPicked(file.path);
2152
- setDraft({
2153
- path: file.path,
2154
- selection
2155
- });
2156
- onDraftChange?.(true);
2157
- },
2158
- onSubmitDraft: submitDraft,
2159
- onCancelDraft: () => {
2160
- setDraft(void 0);
2161
- onDraftChange?.(false);
2162
- },
2163
- onRemoveComment,
2164
- onRetry: () => onLoadFile(file.path)
2165
- }, file.path))]
2166
- })]
2167
- }),
2168
- /* @__PURE__ */ jsxs("div", {
2169
- "data-testid": "git-review-footer",
2170
- 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]",
2171
- children: [
2172
- /* @__PURE__ */ jsx(MessageIcon, {
2173
- "aria-hidden": true,
2174
- className: "h-3 w-3 text-doom-faint"
2175
- }),
2176
- /* @__PURE__ */ jsx("span", {
2177
- "data-testid": "git-review-comment-count",
2178
- className: "text-sm text-doom-dim",
2179
- children: comments.length === 0 ? "no comments yet: select lines or click a line number" : `${String(comments.length)} ${comments.length === 1 ? "comment" : "comments"}`
2180
- }),
2181
- /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
2182
- sendError === void 0 ? null : /* @__PURE__ */ jsx("span", {
2183
- role: "alert",
2184
- "data-testid": "git-review-send-error",
2185
- className: "min-w-0 truncate text-sm text-doom-red",
2186
- children: sendError
2187
- }),
2188
- /* @__PURE__ */ jsx(Button, {
2189
- variant: "ghost",
2190
- size: "xs",
2191
- "data-testid": "git-review-discard",
2192
- disabled: comments.length === 0,
2193
- onClick: onDiscard,
2194
- children: "discard"
2195
- }),
2196
- /* @__PURE__ */ jsx(Button, {
2197
- variant: "primary",
2198
- size: "sm",
2199
- "data-testid": "git-review-send",
2200
- disabled: comments.length === 0,
2201
- onClick: onSendReview,
2202
- children: "send review"
2203
- })
2204
- ]
2205
- })
2206
- ]
2207
- });
2208
- }
2209
- const STATUS_WORD = {
2210
- added: "added",
2211
- modified: "modified",
2212
- deleted: "deleted",
2213
- untracked: "new, untracked",
2214
- conflicted: "conflicted"
2215
- };
2216
- /** One file: a pinned header, then its diff with comments drawn under the lines they are about. */
2217
- function FileSection({ id, entry, state, collapsed, mounted, placeholderHeight, comments, draft, onToggle, onSelect, onSubmitDraft, onCancelDraft, onRemoveComment, onRetry }) {
2218
- const renderAfterRow = (row) => {
2219
- const side = sideOf(row);
2220
- const here = comments.filter((comment) => (comment.side ?? "new") === side && (comment.endLine ?? comment.startLine) === row.line);
2221
- const drafting = draft !== void 0 && draft.side === side && draft.endLine === row.line;
2222
- if (here.length === 0 && !drafting) return null;
2223
- return /* @__PURE__ */ jsxs("div", {
2224
- className: "flex flex-col gap-1.5 border-y border-doom-border-soft bg-doom-bg px-2 py-1.5 font-sans whitespace-normal",
2225
- children: [here.map((comment) => /* @__PURE__ */ jsxs("div", {
2226
- "data-testid": `git-review-comment-${comment.id}`,
2227
- className: "flex min-w-0 items-start gap-2 rounded-md border border-doom-border-soft bg-doom-panel px-2 py-1.5",
2228
- children: [
2229
- /* @__PURE__ */ jsx(MessageIcon, {
2230
- "aria-hidden": true,
2231
- className: "mt-0.5 h-3 w-3 shrink-0 text-doom-blue"
2232
- }),
2233
- /* @__PURE__ */ jsxs("span", {
2234
- className: "flex min-w-0 flex-1 flex-col gap-0.5",
2235
- children: [/* @__PURE__ */ jsx("span", {
2236
- className: "font-mono text-sm text-doom-dim",
2237
- children: reviewCommentAnchor(comment)
2238
- }), /* @__PURE__ */ jsx("span", {
2239
- className: "text-base whitespace-pre-wrap text-doom-text",
2240
- children: comment.body
2241
- })]
2242
- }),
2243
- /* @__PURE__ */ jsx(Button, {
2244
- variant: "ghost",
2245
- size: "icon",
2246
- "aria-label": "remove comment",
2247
- title: "remove comment",
2248
- onClick: () => onRemoveComment(comment.id),
2249
- children: /* @__PURE__ */ jsx(CloseIcon, { className: "h-3 w-3" })
2250
- })
2251
- ]
2252
- }, comment.id)), drafting ? /* @__PURE__ */ jsx("div", {
2253
- className: "rounded-md border border-doom-border bg-doom-panel",
2254
- children: /* @__PURE__ */ jsx(ReviewCommentDraft, {
2255
- snippet: draft.snippet,
2256
- startLine: draft.startLine,
2257
- endLine: draft.endLine,
2258
- side: draft.side,
2259
- testIdPrefix: "git-review-comment",
2260
- onSubmit: onSubmitDraft,
2261
- onCancel: onCancelDraft
2262
- })
2263
- }) : null]
2264
- });
2265
- };
2266
- return /* @__PURE__ */ jsxs("section", {
2267
- id,
2268
- "data-review-path": entry.path,
2269
- "data-testid": `git-review-file-${entry.path}`,
2270
- className: "border-b border-doom-border-soft",
2271
- children: [/* @__PURE__ */ jsxs(Button, {
2272
- variant: "ghost",
2273
- size: "card",
2274
- "aria-expanded": !collapsed,
2275
- onClick: onToggle,
2276
- 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",
2277
- children: [
2278
- collapsed ? /* @__PURE__ */ jsx(ChevronRightIcon, {
2279
- "aria-hidden": true,
2280
- className: "h-3 w-3 shrink-0 text-doom-faint"
2281
- }) : /* @__PURE__ */ jsx(ChevronDownIcon, {
2282
- "aria-hidden": true,
2283
- className: "h-3 w-3 shrink-0 text-doom-faint"
2284
- }),
2285
- /* @__PURE__ */ jsx("span", {
2286
- className: "min-w-0 flex-1 truncate font-mono text-base font-semibold text-doom-hi",
2287
- children: entry.path
2288
- }),
2289
- /* @__PURE__ */ jsx("span", {
2290
- className: "shrink-0 text-sm font-semibold text-doom-dim",
2291
- children: STATUS_WORD[entry.status]
2292
- }),
2293
- comments.length === 0 ? null : /* @__PURE__ */ jsxs("span", {
2294
- className: "flex shrink-0 items-center gap-0.5 text-sm text-doom-blue",
2295
- children: [/* @__PURE__ */ jsx(MessageIcon, {
2296
- "aria-hidden": true,
2297
- className: "h-2.5 w-2.5"
2298
- }), comments.length]
2299
- }),
2300
- entry.binary ? null : /* @__PURE__ */ jsxs("span", {
2301
- className: "shrink-0 text-2xs",
2302
- children: [
2303
- /* @__PURE__ */ jsxs("span", {
2304
- className: "text-doom-green",
2305
- children: ["+", entry.added]
2306
- }),
2307
- " ",
2308
- /* @__PURE__ */ jsxs("span", {
2309
- className: "text-doom-red",
2310
- children: ["-", entry.removed]
2311
- })
2312
- ]
2313
- })
2314
- ]
2315
- }), collapsed ? null : !mounted ? /* @__PURE__ */ jsx("div", {
2316
- "aria-hidden": true,
2317
- style: { height: placeholderHeight }
2318
- }) : /* @__PURE__ */ jsx("div", {
2319
- "data-review-body": state === void 0 || state.state === "loading" ? "loading" : "measured",
2320
- style: state === void 0 || state.state === "loading" ? { minHeight: placeholderHeight } : void 0,
2321
- children: state === void 0 || state.state === "loading" ? /* @__PURE__ */ jsxs("div", {
2322
- className: "flex items-center gap-2 px-4 py-3",
2323
- children: [/* @__PURE__ */ jsx(Spinner, {
2324
- className: "h-3 w-3 text-doom-faint",
2325
- label: `loading ${entry.path}`
2326
- }), /* @__PURE__ */ jsx("span", {
2327
- className: "text-sm text-doom-dim",
2328
- children: "loading diff…"
2329
- })]
2330
- }) : state.state === "error" ? /* @__PURE__ */ jsxs("div", {
2331
- className: "flex items-center gap-2 px-4 py-3",
2332
- children: [/* @__PURE__ */ jsx("span", {
2333
- role: "alert",
2334
- className: "text-sm text-doom-red",
2335
- children: state.error
2336
- }), /* @__PURE__ */ jsx(Button, {
2337
- variant: "ghost",
2338
- size: "xs",
2339
- onClick: onRetry,
2340
- children: "retry"
2341
- })]
2342
- }) : state.diff.binary ? /* @__PURE__ */ jsx("p", {
2343
- className: "px-4 py-3 text-sm text-doom-dim",
2344
- children: "binary file, not drawn"
2345
- }) : state.diff.tooLarge ? /* @__PURE__ */ jsx("p", {
2346
- className: "px-4 py-3 text-sm text-doom-dim",
2347
- children: "this diff is too large to draw here"
2348
- }) : /* @__PURE__ */ jsx(DiffView, {
2349
- hunks: state.diff.hunks,
2350
- testId: `git-review-diff-${id}`,
2351
- onSelect,
2352
- renderAfterRow,
2353
- className: "py-1"
2354
- })
2355
- })]
2356
- });
2357
- }
2358
- //#endregion
2359
- //#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewPanel.tsx
2360
- /**
2361
- * The review tab: this session's changes against its base, the comments on
2362
- * them, and the manual sync beside them.
2363
- *
2364
- * DESIGN PATTERNS:
2365
- * - Opened from the '# diff' group as a transient tab, one per session.
2366
- * - The hub's channel says when something changed (counts, base, a paused
2367
- * rebase); the panel refetches the file list then, and each file's hunks
2368
- * when it is opened. Nothing polls.
2369
- * - Comments live in a session store and are cleared only after the host took
2370
- * the message. Both messages go as `enqueue_automatic`, which queues behind a
2371
- * running turn instead of failing.
2372
- *
2373
- * AVOID:
2374
- * - Sending sync as a session frame. Sync is a channel command; the hub runs
2375
- * it with the configured auth, one command per session.
2376
- */
2377
- const REVIEW_TAB_ID = "git-review";
2378
- function reviewTab() {
2379
- return {
2380
- id: REVIEW_TAB_ID,
2381
- label: "review",
2382
- panel: GitReviewPanel
2383
- };
2384
- }
2385
- function GitReviewPanel({ sessionId, sendSessionFrame }) {
2386
- const activity = useStore(gitChanges.store, (state) => gitChanges.select(state, sessionId));
2387
- const comments = useStore(gitReview.store, (state) => gitReview.select(state, sessionId).comments);
2388
- const [summary, setSummary] = useState({ state: "loading" });
2389
- const [files, setFiles] = useState({});
2390
- const [sendError, setSendError] = useState();
2391
- const [dismissedError, setDismissedError] = useState();
2392
- const [draftSnapshot, setDraftSnapshot] = useState();
2393
- const changes = activity.changes;
2394
- const generation = useRef(0);
2395
- const pendingFiles = useRef(/* @__PURE__ */ new Map());
2396
- const readyGeneration = useRef(false);
2397
- const fingerprint = JSON.stringify([
2398
- changes?.branch,
2399
- changes?.base,
2400
- changes?.added,
2401
- changes?.removed,
2402
- changes?.files,
2403
- changes?.rebase?.conflicts.length
2404
- ]);
2405
- const refreshFingerprint = draftSnapshot?.sessionId === sessionId ? draftSnapshot.fingerprint : fingerprint;
2406
- useEffect(() => {
2407
- generation.current += 1;
2408
- readyGeneration.current = false;
2409
- const pending = pendingFiles.current;
2410
- for (const request of pending.values()) request.abort();
2411
- pending.clear();
2412
- setSummary({ state: "loading" });
2413
- setFiles({});
2414
- setDraftSnapshot((current) => current?.sessionId === sessionId ? current : void 0);
2415
- if (sessionId === null) return;
2416
- const controller = new AbortController();
2417
- fetchReviewSummary(sessionId, controller.signal).then((result) => {
2418
- if (controller.signal.aborted) return;
2419
- readyGeneration.current = result.ok;
2420
- setSummary(result.ok ? {
2421
- state: "ready",
2422
- summary: result.summary
2423
- } : {
2424
- state: "error",
2425
- error: result.error
2426
- });
2427
- setFiles({});
2428
- }).catch((error) => {
2429
- if (controller.signal.aborted) return;
2430
- setSummary({
2431
- state: "error",
2432
- error: error instanceof Error ? error.message : "The changes could not be read."
2433
- });
2434
- });
2435
- return () => {
2436
- controller.abort();
2437
- generation.current += 1;
2438
- readyGeneration.current = false;
2439
- for (const request of pending.values()) request.abort();
2440
- pending.clear();
2441
- };
2442
- }, [sessionId, refreshFingerprint]);
2443
- const loadFile = useCallback((path) => {
2444
- if (sessionId === null || !readyGeneration.current || pendingFiles.current.has(path)) return;
2445
- const requestGeneration = generation.current;
2446
- const controller = new AbortController();
2447
- pendingFiles.current.set(path, controller);
2448
- setFiles((current) => ({
2449
- ...current,
2450
- [path]: { state: "loading" }
2451
- }));
2452
- const settle = (state) => {
2453
- if (controller.signal.aborted || requestGeneration !== generation.current) return;
2454
- setFiles((current) => ({
2455
- ...current,
2456
- [path]: state
2457
- }));
2458
- };
2459
- fetchReviewFile(sessionId, path, controller.signal).then((result) => settle(result.ok ? {
2460
- state: "ready",
2461
- diff: result.diff
2462
- } : {
2463
- state: "error",
2464
- error: result.error
2465
- })).catch((error) => settle({
2466
- state: "error",
2467
- error: error instanceof Error ? error.message : "The diff could not be read."
2468
- })).finally(() => {
2469
- if (pendingFiles.current.get(path) === controller) pendingFiles.current.delete(path);
2470
- });
2471
- }, [sessionId]);
2472
- if (sessionId === null) return null;
2473
- const branch = summary.state === "ready" ? summary.summary.branch : changes?.branch;
2474
- const base = summary.state === "ready" ? summary.summary.base : changes?.base;
2475
- const error = activity.error === dismissedError ? void 0 : activity.error;
2476
- const send = (message, onSent) => {
2477
- const failure = deliverToAgent(sendSessionFrame, sessionId, message);
2478
- setSendError(failure);
2479
- if (failure === void 0) onSent?.();
2480
- };
2481
- return /* @__PURE__ */ jsx(GitReviewView, {
2482
- onDraftChange: (open) => setDraftSnapshot((current) => open ? current?.sessionId === sessionId ? current : {
2483
- sessionId,
2484
- fingerprint
2485
- } : void 0),
2486
- sync: {
2487
- ...changes === void 0 ? {} : { changes },
2488
- ...activity.pending === void 0 ? {} : { pending: activity.pending },
2489
- ...error === void 0 ? {} : { error },
2490
- ...activity.errorTarget === void 0 || error === void 0 ? {} : { errorTarget: activity.errorTarget },
2491
- onSync: (action) => requestGitSync(sessionId, action),
2492
- onForcePush: () => requestGitSync(sessionId, "push", true),
2493
- onAskAgent: () => send(conflictPrompt(branch, changes?.rebase?.conflicts ?? [])),
2494
- onDismissError: () => setDismissedError(activity.error)
2495
- },
2496
- summary,
2497
- files,
2498
- comments,
2499
- onLoadFile: loadFile,
2500
- onAddComment: (comment) => addReviewComment(sessionId, comment),
2501
- onRemoveComment: (id) => removeReviewComment(sessionId, id),
2502
- onSendReview: () => send(buildReviewPrompt(comments, reviewHeading(comments.length, branch, base)), () => clearReviewComments(sessionId)),
2503
- onDiscard: () => clearReviewComments(sessionId),
2504
- ...sendError === void 0 ? {} : { sendError }
2505
- }, sessionId);
2506
- }
2507
- //#endregion
2508
- //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/DiffActivitySection.tsx
2509
- /**
2510
- * The '# git' group's branch row: this session's code change against its base.
2511
- *
2512
- * DESIGN PATTERNS:
2513
- * - One lane, branch and `+A -R`. Files, base, upstream and a paused rebase live
2514
- * in the review tab's header, which this lane opens.
2515
- * - Stays clickable at zero, because the review tab is also where pull, push
2516
- * and rebase live.
2517
- * - Reads the focused session's checkout, whether it is a worktree or not.
2518
- *
2519
- * AVOID:
2520
- * - Doing work here. This section reports and opens the tab.
2521
- */
2522
- function DiffActivitySection({ sessionId, openTransientTab }) {
2523
- const session = useStore(gitChanges.store, (state) => gitChanges.select(state, sessionId));
2524
- const changes = session.changes;
2525
- if (sessionId === null || changes === void 0) return null;
2526
- const zero = changes.added === 0 && changes.removed === 0;
2527
- const against = changes.base === void 0 ? "uncommitted" : `vs ${changes.base}`;
2528
- return /* @__PURE__ */ jsxs("div", {
2529
- className: "flex min-w-0 items-center gap-2",
2530
- children: [/* @__PURE__ */ jsxs(Button, {
2531
- variant: "ghost",
2532
- size: "xs",
2533
- "data-testid": "activity-diff-changes",
2534
- "aria-label": `review ${changes.branch ?? "detached HEAD"} changes: ${String(changes.added)} added, ${String(changes.removed)} removed ${against}`,
2535
- title: `${String(changes.files)} ${changes.files === 1 ? "file" : "files"} ${against}`,
2536
- className: `h-auto min-w-0 flex-1 shrink gap-2 px-0 py-0.5 hover:bg-transparent ${zero ? "text-doom-faint" : ""}`,
2537
- onClick: () => openTransientTab(reviewTab()),
2538
- children: [
2539
- /* @__PURE__ */ jsx("span", {
2540
- className: "min-w-0 flex-1 truncate text-left font-mono font-semibold text-doom-hi",
2541
- children: changes.branch ?? "detached HEAD"
2542
- }),
2543
- /* @__PURE__ */ jsxs("span", {
2544
- className: zero ? "" : "text-doom-green",
2545
- children: ["+", changes.added]
2546
- }),
2547
- /* @__PURE__ */ jsxs("span", {
2548
- className: zero ? "" : "text-doom-red",
2549
- children: ["-", changes.removed]
2550
- })
2551
- ]
2552
- }), session.pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
2553
- className: "flex min-w-0 items-center gap-1.5",
2554
- children: [/* @__PURE__ */ jsx(Spinner, {
2555
- className: "h-3 w-3 shrink-0 text-doom-faint",
2556
- label: session.pending
2557
- }), /* @__PURE__ */ jsx("span", {
2558
- className: "min-w-0 truncate text-sm text-doom-dim",
2559
- children: session.pending
2560
- })]
2561
- })]
2562
- });
2563
- }
2564
- //#endregion
2565
- //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/WorktreesActivitySection.tsx
2566
- /**
2567
- * The '# git' group's body in the activity dock.
2568
- *
2569
- * DESIGN PATTERNS:
2570
- * - The focused checkout's branch and diff stay visible, even with no child worktrees.
2571
- * - One line per worktree, the same rule the runners rail follows: enough to
2572
- * notice it, not enough to work with it. The group's own name opens the tab
2573
- * that has room.
2574
- * - Idle still shows the launcher. A repository with no worktrees is the state
2575
- * where someone most wants to make one.
2576
- *
2577
- * AVOID:
2578
- * - Doing work here. This section reports and launches; the panel and the tool
2579
- * own everything else.
2580
- */
2581
- function WorktreesActivitySection(props) {
2582
- return /* @__PURE__ */ jsxs("div", {
2583
- className: "flex min-w-0 flex-col gap-2",
2584
- children: [/* @__PURE__ */ jsx(DiffActivitySection, { ...props }), /* @__PURE__ */ jsx(WorktreesSummary, { ...props })]
2585
- });
2586
- }
2587
- function WorktreesSummary({ sessionId, openTransientTab }) {
2588
- const session = useStore(worktreeActivity.store, (state) => worktreeActivity.select(state, sessionId));
2589
- const worktrees = session.worktrees.filter((worktree) => worktree.sessionId !== sessionId);
2590
- if (worktrees.length === 0 && session.pending === void 0) return /* @__PURE__ */ jsxs("div", {
2591
- className: "flex flex-wrap items-center gap-x-4 gap-y-1",
2592
- children: [/* @__PURE__ */ jsx("p", {
2593
- "data-testid": "activity-summary-git",
2594
- className: "text-sm text-doom-dim",
2595
- children: "Idle"
2596
- }), sessionId === null ? null : /* @__PURE__ */ jsxs(Button, {
2597
- variant: "link",
2598
- size: "sm",
2599
- "data-testid": "activity-git-create",
2600
- className: "h-auto gap-1 px-0 text-doom-green",
2601
- onClick: () => openTransientTab(worktreesTab()),
2602
- children: ["Create a worktree", /* @__PURE__ */ jsx(ArrowRightIcon, {
2603
- "aria-hidden": true,
2604
- className: "size-3.5"
2605
- })]
2606
- })]
2607
- });
2608
- return /* @__PURE__ */ jsxs("div", {
2609
- "data-testid": "activity-git-worktrees",
2610
- className: "flex flex-col gap-0.5",
2611
- children: [session.pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
2612
- className: "flex min-w-0 items-center gap-1.5 px-1",
2613
- children: [/* @__PURE__ */ jsx(Spinner, {
2614
- className: "h-3 w-3 shrink-0 text-doom-faint",
2615
- label: session.pending
2616
- }), /* @__PURE__ */ jsx("span", {
2617
- className: "min-w-0 truncate text-sm text-doom-dim",
2618
- children: session.pending
2619
- })]
2620
- }), worktrees.map((worktree) => /* @__PURE__ */ jsx(Button, {
2621
- variant: "ghost",
2622
- size: "card",
2623
- "data-testid": `activity-git-worktree-${worktree.id}`,
2624
- className: "min-w-0 gap-1.5 px-1",
2625
- onClick: () => openTransientTab(worktreesTab()),
2626
- children: /* @__PURE__ */ jsxs("span", {
2627
- className: "flex w-full min-w-0 items-center gap-1.5",
2628
- children: [
2629
- /* @__PURE__ */ jsx(Dot, { tone: worktree.orphaned ? "red" : worktree.sessionId === null ? "muted" : "blue" }),
2630
- /* @__PURE__ */ jsx("span", {
2631
- className: "min-w-0 flex-1 truncate text-left text-base font-semibold text-doom-hi",
2632
- children: worktree.branch
2633
- }),
2634
- worktree.orphaned ? /* @__PURE__ */ jsx(StatusBadge, {
2635
- tone: "error",
2636
- size: "xs",
2637
- children: "orphaned"
2638
- }) : null,
2639
- worktree.unowned ? /* @__PURE__ */ jsx(StatusBadge, {
2640
- tone: "info",
2641
- size: "xs",
2642
- children: "unowned"
2643
- }) : null
2644
- ]
2645
- })
2646
- }, worktree.id))]
2647
- });
2648
- }
2649
- //#endregion
2650
- //#region src/extensions/workspaces/sessions/(frontend)/fill/Worktrees.activity.git.web.tsx
2651
- var Worktrees_activity_git_web_default = defineFill({ component: WorktreesActivitySection });
2652
- //#endregion
2653
- //#region src/extensions/workspaces/sessions/(frontend)/leader/review.web.ts
2654
- var review_web_default = defineLeaderBinding({
2655
- path: [{
2656
- key: "g",
2657
- label: "goal"
2658
- }, {
2659
- key: "d",
2660
- label: "diff",
2661
- detail: "review this checkout’s changes"
2662
- }],
2663
- run: (context) => context.openTransientTab(reviewTab())
2664
- });
2665
- //#endregion
2666
- //#region src/extensions/workspaces/sessions/(frontend)/lifecycle/_lib/start.web.ts
2667
- /** Binds both hub channels' senders: worktrees, and this session's diff and sync. */
2668
- function startGitRuntime(runtime) {
2669
- const stopWorktrees = startWorktreeRuntime(runtime);
2670
- const stopChanges = startGitChangesRuntime(runtime);
2671
- return () => {
2672
- stopChanges();
2673
- stopWorktrees();
2674
- };
2675
- }
2676
- //#endregion
2677
- //#region src/extensions/workspaces/sessions/(frontend)/lifecycle/start.web.ts
2678
- var start_web_default = defineWebLifecycle(startGitRuntime);
2679
- //#endregion
2680
- //#region src/extensions/workspaces/sessions/(frontend)/tool/_components/RunWorktreeToolMessage.tsx
2681
- const ACTION_LABEL = {
2682
- spawn_worktree: "Create worktree",
2683
- close_worktree: "Close worktree",
2684
- list: "List worktrees",
2685
- status: "Worktree status",
2686
- merge: "Merge worktree",
2687
- prune: "Prune worktrees",
2688
- send: "Message worktree",
2689
- messages: "Read worktree messages"
2690
- };
2691
- /**
2692
- * The timeline item for `run_worktree`.
2693
- *
2694
- * DESIGN PATTERNS:
2695
- * - Built on MessageItem, like every other tool renderer in the cockpit. That
2696
- * is what gives it the shared collapse toggle and the RUNNING/OK/ERROR badge
2697
- * rather than a hand-rolled ellipsis that never resolves.
2698
- * - The action and its subject go in the heading because that is what a reader
2699
- * scanning a transcript needs: "close_worktree a1b2c3d4" answers the question
2700
- * the raw JSON arguments make them decode.
2701
- * - A create takes as long as the cockpit needs to start a session, and the
2702
- * tool streams each phase into `output`, so the body is where the wait is
2703
- * visible.
2704
- */
2705
- function RunWorktreeToolMessage({ args, output, running, isError }) {
2706
- const action = typeof args.action === "string" ? args.action : "";
2707
- const subject = typeof args.branch === "string" ? args.branch : typeof args.id === "string" ? args.id : "";
2708
- return /* @__PURE__ */ jsxs(MessageItem, {
2709
- tone: toolTone({
2710
- running,
2711
- isError
2712
- }),
2713
- expandable: output !== "",
2714
- children: [/* @__PURE__ */ jsx(MessageItemHeader, {
2715
- title: ACTION_LABEL[action] ?? "Worktree",
2716
- children: subject === "" ? null : /* @__PURE__ */ jsx("span", {
2717
- className: "min-w-0 truncate font-mono text-doom-dim",
2718
- children: subject
2719
- })
2720
- }), output === "" ? null : /* @__PURE__ */ jsx(MessageItemBody, { children: /* @__PURE__ */ jsx("pre", {
2721
- className: `whitespace-pre-wrap font-mono text-sm ${isError ? "text-doom-red" : "text-doom-dim"}`,
2722
- children: output
2723
- }) })]
2724
- });
2725
- }
2726
- //#endregion
2727
- //#region src/extensions/workspaces/sessions/(frontend)/tool/runWorktree.web.tsx
2728
- var runWorktree_web_default = defineToolRenderer({ message: RunWorktreeToolMessage });
2729
- //#endregion
2730
- //#region generated/web.ts
2731
- const via = (identity, value) => value === void 0 ? void 0 : {
2732
- ...identity,
2733
- ...value
2734
- };
2735
- const webPlugin = defineWebPlugin({
2736
- id: "git",
2737
- workspace: {
2738
- get fills() {
2739
- return [via({
2740
- slot: "new-session",
2741
- id: "git-new-session"
2742
- }, GitNewSession_new_session_web_default)];
2743
- },
2744
- get repositorySettingsPanel() {
2745
- return git_web_default$1;
2746
- }
2747
- },
2748
- session: {
2749
- get activityGroups() {
2750
- return [via({ name: "git" }, git_web_default)];
2751
- },
2752
- get channels() {
2753
- return [via({ channel: "git_changes" }, gitChanges_web_default), via({ channel: "git_worktrees" }, gitWorktrees_web_default)];
2754
- },
2755
- get fills() {
2756
- return [
2757
- via({
2758
- slot: "session-menu",
2759
- id: "worktree"
2760
- }, Worktree_session_menu_web_default),
2761
- via({
2762
- slot: "overlay",
2763
- id: "worktree-create"
2764
- }, WorktreeCreate_overlay_web_default),
2765
- via({
2766
- slot: "activity.git",
2767
- id: "worktrees"
2768
- }, Worktrees_activity_git_web_default)
2769
- ];
2770
- },
2771
- get leaderBindings() {
2772
- return [via({ id: "git.review" }, review_web_default)];
2773
- },
2774
- get start() {
2775
- return start_web_default;
2776
- },
2777
- get toolRenderers() {
2778
- return [via({ tools: ["run_worktree"] }, runWorktree_web_default)];
2779
- }
2780
- }
2781
- });
2782
- //#endregion
2783
- export { webPlugin };
2784
-
2785
- //# sourceMappingURL=web.mjs.map