@agimon-ai/po-file-edit 0.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1344 @@
1
+ import { apiResponse, createApiClient, defineActivityGroup, defineApiRoutes, defineChannelFile, defineFileLinks, defineFill, defineLeaderBinding, defineSessionStore, defineWebPlugin, sessionApiAddress } from "@agimon-ai/po-core/web";
2
+ import { Badge, Breadcrumb, Button, CloseIcon, CodeEditor, Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DiffView, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, KebabIcon, Markdown, MediaPreview, ReviewCommentDraft, buildReviewPrompt, grammarKeyOf, groupByDirectory, groupRowLabel, mediaKindOf, reviewCommentAnchor } from "@agimon-ai/po-web-components";
3
+ import { useStore } from "@tanstack/react-store";
4
+ import { Fragment, useCallback, useEffect, useRef, useState } from "react";
5
+ import { sealedTransport } from "@agimon-ai/po-web-security/browser";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/types/webFiles.ts
8
+ /** The frame type the hub publishes and the web plugin claims; declared in the poWeb block too. */
9
+ const filesChannelType = "file_edits";
10
+ //#endregion
11
+ //#region src/extensions/workspaces/sessions/(frontend)/activity-group/files.web.ts
12
+ var files_web_default$2 = defineActivityGroup({
13
+ keys: "e f",
14
+ statusKey: "doom-file-edit-files",
15
+ hideWhenEmpty: true,
16
+ marksBackgroundWork: false,
17
+ order: 15
18
+ });
19
+ const files = defineSessionStore({
20
+ items: [],
21
+ detail: {},
22
+ loading: [],
23
+ errors: {},
24
+ comments: []
25
+ });
26
+ function isRecord$1(value) {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+ function isItem(value) {
30
+ return isRecord$1(value) && typeof value.path === "string" && typeof value.relPath === "string" && typeof value.tool === "string" && typeof value.at === "number" && typeof value.count === "number";
31
+ }
32
+ /** The plugin's session data channel: 'file_edits' payloads into the store. */
33
+ const filesChannel = files.channel({
34
+ channel: filesChannelType,
35
+ parse(input) {
36
+ if (!isRecord$1(input) || !Array.isArray(input.items)) return null;
37
+ return { items: input.items.filter(isItem) };
38
+ },
39
+ reduce: (current, { items }) => ({
40
+ ...current,
41
+ items
42
+ })
43
+ });
44
+ function markLoading(sessionId, filePath) {
45
+ files.update(sessionId, (current) => current.loading.includes(filePath) ? current : {
46
+ ...current,
47
+ loading: [...current.loading, filePath],
48
+ errors: withoutKey(current.errors, filePath)
49
+ });
50
+ }
51
+ function storeDetail(sessionId, detail) {
52
+ files.update(sessionId, (current) => ({
53
+ ...current,
54
+ detail: {
55
+ ...current.detail,
56
+ [detail.path]: detail
57
+ },
58
+ loading: current.loading.filter((path) => path !== detail.path),
59
+ errors: withoutKey(current.errors, detail.path)
60
+ }));
61
+ }
62
+ function storeError(sessionId, filePath, message) {
63
+ files.update(sessionId, (current) => ({
64
+ ...current,
65
+ loading: current.loading.filter((path) => path !== filePath),
66
+ errors: {
67
+ ...current.errors,
68
+ [filePath]: message
69
+ }
70
+ }));
71
+ }
72
+ function addComment(sessionId, comment) {
73
+ files.update(sessionId, (current) => ({
74
+ ...current,
75
+ comments: [...current.comments, comment]
76
+ }));
77
+ }
78
+ function removeComment(sessionId, id) {
79
+ files.update(sessionId, (current) => ({
80
+ ...current,
81
+ comments: current.comments.filter((comment) => comment.id !== id)
82
+ }));
83
+ }
84
+ function clearComments(sessionId, filePath) {
85
+ files.update(sessionId, (current) => ({
86
+ ...current,
87
+ comments: current.comments.filter((comment) => comment.path !== filePath)
88
+ }));
89
+ }
90
+ function withoutKey(source, key) {
91
+ return Object.fromEntries(Object.entries(source).filter(([entry]) => entry !== key));
92
+ }
93
+ //#endregion
94
+ //#region src/extensions/workspaces/sessions/(frontend)/channel/file-edits.web.ts
95
+ var file_edits_web_default = defineChannelFile(filesChannel);
96
+ //#endregion
97
+ //#region src/types/apiRoutes.ts
98
+ /**
99
+ * This package's routes, as data.
100
+ *
101
+ * No scope and no base path. The build reads both off
102
+ * `src/extensions/workspaces/sessions/(backend)/api/file-edits/`, so the mount
103
+ * is stated once, by the folder that creates it. They used to be written here,
104
+ * in the contract and again inside every URL the page built, and the copies
105
+ * were free to disagree.
106
+ *
107
+ * This is the one file the routes, the Hono app and the browser all read, so a
108
+ * path cannot move on one side alone.
109
+ */
110
+ var apiRoutes_default = defineApiRoutes({
111
+ detail: {
112
+ method: "GET",
113
+ path: "/detail",
114
+ query: ["path"],
115
+ response: apiResponse()
116
+ },
117
+ preview: {
118
+ method: "GET",
119
+ path: "/preview",
120
+ query: ["path"],
121
+ response: apiResponse()
122
+ },
123
+ save: {
124
+ method: "PUT",
125
+ path: "/content",
126
+ response: apiResponse()
127
+ },
128
+ /** The same path as `save`; the method is the whole of the difference. */
129
+ remove: {
130
+ method: "DELETE",
131
+ path: "/content",
132
+ query: ["path"]
133
+ },
134
+ /**
135
+ * The host's own bytes route, not this package's.
136
+ *
137
+ * `host: true` hangs it off the session root rather than under `/plugins`,
138
+ * which is how the cockpit already serves it. Declaring it here is the first
139
+ * time anything records that this package's previews depend on it.
140
+ */
141
+ file: {
142
+ method: "GET",
143
+ path: "/file",
144
+ query: ["path"],
145
+ host: true
146
+ }
147
+ });
148
+ const api = createApiClient(apiRoutes_default, {
149
+ scopes: ["session"],
150
+ basePath: "file-edits",
151
+ transport: (input, init) => sealedTransport.fetch(input, init),
152
+ sessionAddress: sessionApiAddress
153
+ });
154
+ //#endregion
155
+ //#region src/types/fileEditsApi.ts
156
+ /** Query parameter naming the file a request is about. */
157
+ const PATH_QUERY_PARAM = "path";
158
+ //#endregion
159
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/filesApi.ts
160
+ /**
161
+ * The page's half of this package's session API: one file's history, the
162
+ * manual save, and the deletion.
163
+ *
164
+ * These are adapters now, not a transport. The generated client owns the URL
165
+ * and the sealed transport with it, so nothing here spells a route or reaches
166
+ * for `fetch`. What stays is this package's own vocabulary: which refusals the
167
+ * reader is shown, and the stale-save contract the editor depends on.
168
+ *
169
+ * Sealing still matters and still happens, one layer down. What travels here is
170
+ * file contents in both directions, which is the worst thing in the cockpit to
171
+ * hand a tunnel's relay in the clear.
172
+ */
173
+ const UNREACHABLE = "The session is unreachable.";
174
+ function isRecord(value) {
175
+ return typeof value === "object" && value !== null && !Array.isArray(value);
176
+ }
177
+ /**
178
+ * Where a file's raw bytes are served from, for the ones the page shows rather
179
+ * than edits.
180
+ *
181
+ * This is the host's route, not this package's, which is why the route table
182
+ * marks it `host: true` and it lands beside the session rather than under
183
+ * `/plugins`. The cockpit already serves any file under a session's working
184
+ * directory, and that route carries the traversal guard, the size cap and the
185
+ * content-type mapping. A second one here would duplicate a security boundary
186
+ * to avoid duplicating a URL, which is the wrong way round.
187
+ */
188
+ function sessionFileUrl(sessionId, relPath) {
189
+ return api.session(sessionId).file.url({ query: { [PATH_QUERY_PARAM]: relPath } });
190
+ }
191
+ /** The message a reader is shown: the route's own words, or why there were none. */
192
+ function messageOf(result) {
193
+ if (result.status === 0) return UNREACHABLE;
194
+ return result.error === "" ? `The session answered ${result.status}.` : result.error;
195
+ }
196
+ async function fetchFileDetail(sessionId, filePath) {
197
+ const result = await api.session(sessionId).detail({ query: { [PATH_QUERY_PARAM]: filePath } });
198
+ if (!result.ok) return {
199
+ ok: false,
200
+ error: messageOf(result)
201
+ };
202
+ if (!isRecord(result.data)) return {
203
+ ok: false,
204
+ error: "The session answered with no detail."
205
+ };
206
+ return {
207
+ ok: true,
208
+ detail: result.data
209
+ };
210
+ }
211
+ /** One file the session never changed, read only; the route bounds it to the working directory. */
212
+ async function fetchFilePreview(sessionId, filePath) {
213
+ const result = await api.session(sessionId).preview({ query: { [PATH_QUERY_PARAM]: filePath } });
214
+ if (!result.ok) return {
215
+ ok: false,
216
+ error: messageOf(result)
217
+ };
218
+ if (!isRecord(result.data)) return {
219
+ ok: false,
220
+ error: "The session answered with no file."
221
+ };
222
+ return {
223
+ ok: true,
224
+ preview: result.data
225
+ };
226
+ }
227
+ async function saveFileContent(sessionId, filePath, expectedHash, content) {
228
+ const result = await api.session(sessionId).save({ body: {
229
+ path: filePath,
230
+ expectedHash,
231
+ content
232
+ } });
233
+ if (result.status === 409) {
234
+ const data = result.ok ? void 0 : result.data;
235
+ const hash = isRecord(data) && typeof data.hash === "string" ? data.hash : void 0;
236
+ return {
237
+ ok: false,
238
+ stale: true,
239
+ error: result.ok ? "The file changed since it was opened." : messageOf(result),
240
+ ...hash === void 0 ? {} : { hash }
241
+ };
242
+ }
243
+ if (!result.ok) return {
244
+ ok: false,
245
+ stale: false,
246
+ error: messageOf(result)
247
+ };
248
+ return {
249
+ ok: true,
250
+ hash: isRecord(result.data) && typeof result.data.hash === "string" ? result.data.hash : ""
251
+ };
252
+ }
253
+ /**
254
+ * Removes the file from disk.
255
+ *
256
+ * The route takes only a path, with no hash to prove the reader saw what is
257
+ * there now: a deletion discards the file whatever it holds, so a staleness
258
+ * check would guard nothing. The confirmation in front of it is the guard.
259
+ */
260
+ async function deleteFile(sessionId, filePath) {
261
+ const result = await api.session(sessionId).remove({ query: { [PATH_QUERY_PARAM]: filePath } });
262
+ return result.ok ? { ok: true } : {
263
+ ok: false,
264
+ error: messageOf(result)
265
+ };
266
+ }
267
+ //#endregion
268
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/fileView.ts
269
+ /**
270
+ * Pure view logic the file surfaces share: how a change is labelled, how a
271
+ * file is identified, and how a review reaches the agent.
272
+ *
273
+ * None of this touches React or the DOM beyond what it is handed, so the
274
+ * awkward parts are pinned by tests rather than by clicking through a browser.
275
+ * Wording a review and drawing a diff are shared with other review surfaces
276
+ * and live in po-web-components.
277
+ */
278
+ /** What a row says about the tool behind a change. */
279
+ const TOOL_LABEL = {
280
+ edit: "edit",
281
+ write: "write",
282
+ bash: "command",
283
+ user: "you"
284
+ };
285
+ /** Filters visible relative paths without changing the timeline's newest-first order. */
286
+ function filterFileItems(items, query) {
287
+ const normalized = query.trim().toLocaleLowerCase();
288
+ if (normalized.length === 0) return [...items];
289
+ return items.filter((item) => item.relPath.toLocaleLowerCase().includes(normalized));
290
+ }
291
+ function previewModeOf(filePath, unavailable) {
292
+ if (mediaKindOf(filePath) !== "download") return "media";
293
+ if (unavailable) return "unavailable";
294
+ const lower = filePath.toLowerCase();
295
+ if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "markdown";
296
+ if (lower.endsWith(".html") || lower.endsWith(".htm")) return "html";
297
+ return grammarKeyOf(filePath) === void 0 ? "text" : "code";
298
+ }
299
+ /** A short, stable fingerprint of a string; enough to separate two paths in an id. */
300
+ function fingerprint(value) {
301
+ let hash = 2166136261;
302
+ for (let index = 0; index < value.length; index += 1) {
303
+ hash ^= value.charCodeAt(index);
304
+ hash = Math.imul(hash, 16777619);
305
+ }
306
+ return (hash >>> 0).toString(36);
307
+ }
308
+ /** The transient tab id for one file; stable, so reopening focuses rather than duplicates. */
309
+ function fileTabId(filePath) {
310
+ const slug = filePath.replaceAll(/[^a-zA-Z0-9]+/gu, "-").replace(/^-|-$/gu, "");
311
+ return `files-file-${fingerprint(filePath)}-${slug.slice(-40)}`;
312
+ }
313
+ /**
314
+ * Sends a review as a deferred follow-up and reports whether it left.
315
+ *
316
+ * `follow_up` waits behind a running turn and starts when idle, without
317
+ * treating the review as an explicit Queue action. The sender throws when
318
+ * disconnected, so the caller keeps the comments and reports the reason.
319
+ */
320
+ function sendReviewFrame(send, comments) {
321
+ try {
322
+ send({
323
+ type: "follow_up",
324
+ message: buildReviewPrompt(comments)
325
+ });
326
+ return;
327
+ } catch (error) {
328
+ return error instanceof Error ? error.message : String(error);
329
+ }
330
+ }
331
+ //#endregion
332
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/DeleteFileDialog.tsx
333
+ function DeleteFileDialog({ relPath, open, onConfirm, onCancel }) {
334
+ return /* @__PURE__ */ jsx(Dialog, {
335
+ open,
336
+ onOpenChange: (next) => {
337
+ if (!next) onCancel();
338
+ },
339
+ children: /* @__PURE__ */ jsxs(DialogContent, {
340
+ width: "sm",
341
+ "data-testid": "files-delete-dialog",
342
+ "aria-describedby": void 0,
343
+ children: [/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: "delete file" }) }), /* @__PURE__ */ jsxs(DialogBody, { children: [/* @__PURE__ */ jsxs(DialogDescription, { children: [
344
+ "this removes ",
345
+ /* @__PURE__ */ jsx("span", {
346
+ className: "font-semibold text-doom-hi",
347
+ children: relPath
348
+ }),
349
+ " from disk. the session keeps its record of the change, but the file itself is gone."
350
+ ] }), /* @__PURE__ */ jsxs(DialogFooter, { children: [/* @__PURE__ */ jsx(Button, {
351
+ variant: "outline",
352
+ size: "md",
353
+ "data-testid": "files-delete-cancel",
354
+ autoFocus: true,
355
+ onClick: onCancel,
356
+ children: "cancel"
357
+ }), /* @__PURE__ */ jsx(Button, {
358
+ variant: "danger",
359
+ size: "md",
360
+ "data-testid": "files-delete-confirm",
361
+ onClick: onConfirm,
362
+ children: "delete"
363
+ })] })] })]
364
+ })
365
+ });
366
+ }
367
+ //#endregion
368
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/SessionMediaPreview.tsx
369
+ /** Media elements cannot issue the encrypted fetch required by remote session files. */
370
+ function SessionMediaPreview({ src, path, "data-testid": testId }) {
371
+ const [loaded, setLoaded] = useState();
372
+ useEffect(() => {
373
+ if (src === "") return;
374
+ const controller = new AbortController();
375
+ let url;
376
+ (async () => {
377
+ const response = await sealedTransport.fetch(src, {
378
+ signal: controller.signal,
379
+ cache: "no-store"
380
+ });
381
+ if (!response.ok) throw new Error(`Media could not be loaded (${response.status}).`);
382
+ const blob = await response.blob();
383
+ if (controller.signal.aborted) return;
384
+ url = URL.createObjectURL(blob);
385
+ setLoaded({
386
+ source: src,
387
+ url,
388
+ contentType: (response.headers.get("content-type") ?? blob.type).split(";", 1)[0]?.trim().toLowerCase() ?? ""
389
+ });
390
+ })().catch((error) => {
391
+ if (!controller.signal.aborted) setLoaded({
392
+ source: src,
393
+ error: error instanceof Error ? error.message : String(error)
394
+ });
395
+ });
396
+ return () => {
397
+ controller.abort();
398
+ if (url !== void 0) URL.revokeObjectURL(url);
399
+ };
400
+ }, [src]);
401
+ const current = loaded?.source === src ? loaded : void 0;
402
+ if (current?.url === void 0) return /* @__PURE__ */ jsx("p", {
403
+ role: current?.error ? "alert" : "status",
404
+ children: current?.error ?? "Loading media..."
405
+ });
406
+ return /* @__PURE__ */ jsx(MediaPreview, {
407
+ src: current.url,
408
+ path,
409
+ contentType: current.contentType,
410
+ "data-testid": testId
411
+ });
412
+ }
413
+ /** Resolves markdown images beside their document, leaving containment to the host. */
414
+ async function loadMarkdownImage(sessionId, documentPath, path) {
415
+ if (sessionId === null) throw new Error("The session is unavailable.");
416
+ const directory = documentPath.slice(0, documentPath.lastIndexOf("/") + 1);
417
+ const response = await sealedTransport.fetch(sessionFileUrl(sessionId, path.startsWith("/") ? path : `${directory}${path}`), { cache: "no-store" });
418
+ if (!response.ok) throw new Error(`Image could not be loaded (${response.status}).`);
419
+ const url = URL.createObjectURL(await response.blob());
420
+ return {
421
+ url,
422
+ dispose: () => URL.revokeObjectURL(url)
423
+ };
424
+ }
425
+ //#endregion
426
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/FilePanel.tsx
427
+ /** How a version heads its row: what ran, when, and what it moved. */
428
+ function versionSummary(version) {
429
+ const time = new Date(version.at).toLocaleTimeString();
430
+ const counts = version.hunks === void 0 ? "" : ` +${version.additions} -${version.removals}`;
431
+ return `${time} · ${TOOL_LABEL[version.tool] ?? version.tool}${counts}`;
432
+ }
433
+ function FilePanel({ filePath, relPath, sessionId, sendSessionFrame, closeTransientTab }) {
434
+ const session = useStore(files.store, (state) => files.select(state, sessionId));
435
+ const detail = session.detail[filePath];
436
+ const error = session.errors[filePath];
437
+ const loading = session.loading.includes(filePath);
438
+ const comments = session.comments.filter((comment) => comment.path === filePath);
439
+ const [view, setView] = useState("preview");
440
+ const [draft, setDraft] = useState(void 0);
441
+ const [source, setSource] = useState(void 0);
442
+ const [expanded, setExpanded] = useState(void 0);
443
+ const [saving, setSaving] = useState(false);
444
+ const [saveNote, setSaveNote] = useState(void 0);
445
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
446
+ const [reviewError, setReviewError] = useState(void 0);
447
+ const selection = useRef(void 0);
448
+ const revision = session.items.find((item) => item.path === filePath)?.count ?? 0;
449
+ useEffect(() => {
450
+ if (sessionId === null) return;
451
+ let cancelled = false;
452
+ markLoading(sessionId, filePath);
453
+ fetchFileDetail(sessionId, filePath).then((result) => {
454
+ if (cancelled) return;
455
+ if (result.ok) {
456
+ storeDetail(sessionId, result.detail);
457
+ setSource(void 0);
458
+ setSaveNote(void 0);
459
+ } else storeError(sessionId, filePath, result.error);
460
+ });
461
+ return () => {
462
+ cancelled = true;
463
+ };
464
+ }, [
465
+ sessionId,
466
+ filePath,
467
+ revision
468
+ ]);
469
+ const working = detail?.working;
470
+ const outside = relPath === ".." || relPath.startsWith("../") || relPath.startsWith("/");
471
+ const content = source ?? working?.content ?? "";
472
+ const previewMode = previewModeOf(relPath, working?.unavailable === true);
473
+ const loadImage = useCallback((path) => loadMarkdownImage(sessionId, relPath, path), [sessionId, relPath]);
474
+ const mediaSrc = sessionId === null ? "" : sessionFileUrl(sessionId, relPath);
475
+ const openDraft = (selection) => {
476
+ setDraft(selection);
477
+ };
478
+ /** Raises the comment box on whatever the editor last reported, if that was a selection. */
479
+ const openSelectionDraft = () => {
480
+ const range = selection.current;
481
+ if (range === void 0 || range.text.trim() === "") return;
482
+ openDraft({
483
+ snippet: range.text,
484
+ startLine: range.startLine,
485
+ endLine: range.endLine
486
+ });
487
+ };
488
+ const submitDraft = (body) => {
489
+ if (sessionId === null || draft === void 0) return;
490
+ addComment(sessionId, {
491
+ id: `${filePath}:${draft.side ?? "new"}:${draft.startLine ?? 0}:${comments.length}:${body.length}`,
492
+ path: filePath,
493
+ relPath,
494
+ ...draft.side === void 0 ? {} : { side: draft.side },
495
+ ...draft.startLine === void 0 ? {} : { startLine: draft.startLine },
496
+ ...draft.endLine === void 0 ? {} : { endLine: draft.endLine },
497
+ snippet: draft.snippet,
498
+ body
499
+ });
500
+ setDraft(void 0);
501
+ };
502
+ const sendReview = () => {
503
+ if (sessionId === null || comments.length === 0) return;
504
+ const failure = sendReviewFrame((frame) => sendSessionFrame(sessionId, frame), comments);
505
+ setReviewError(failure);
506
+ if (failure === void 0) clearComments(sessionId, filePath);
507
+ };
508
+ const confirmDelete = async () => {
509
+ if (sessionId === null) return;
510
+ setConfirmingDelete(false);
511
+ const result = await deleteFile(sessionId, filePath);
512
+ if (!result.ok) {
513
+ storeError(sessionId, filePath, result.error);
514
+ return;
515
+ }
516
+ closeTransientTab(fileTabId(filePath));
517
+ };
518
+ const save = async () => {
519
+ if (sessionId === null || working === void 0 || source === void 0) return;
520
+ setSaving(true);
521
+ const result = await saveFileContent(sessionId, filePath, working.hash, source);
522
+ setSaving(false);
523
+ if (result.ok) {
524
+ setSaveNote("saved");
525
+ setSource(void 0);
526
+ markLoading(sessionId, filePath);
527
+ const refreshed = await fetchFileDetail(sessionId, filePath);
528
+ if (refreshed.ok) storeDetail(sessionId, refreshed.detail);
529
+ else storeError(sessionId, filePath, refreshed.error);
530
+ return;
531
+ }
532
+ setSaveNote(result.error);
533
+ };
534
+ return /* @__PURE__ */ jsxs("div", {
535
+ "data-testid": "files-file-panel",
536
+ className: "flex min-h-0 flex-1 flex-col",
537
+ children: [
538
+ /* @__PURE__ */ jsxs("header", {
539
+ className: "flex shrink-0 items-center gap-2 border-b border-doom-border px-4 py-2",
540
+ children: [
541
+ /* @__PURE__ */ jsx(Breadcrumb, {
542
+ path: relPath,
543
+ "data-testid": "files-breadcrumb",
544
+ className: "min-w-0 flex-1"
545
+ }),
546
+ view === "edit" ? /* @__PURE__ */ jsx(Badge, {
547
+ tone: "neutral",
548
+ "data-testid": "files-editing",
549
+ className: "shrink-0 ",
550
+ children: "editing"
551
+ }) : null,
552
+ detail === void 0 ? null : /* @__PURE__ */ jsxs("span", {
553
+ className: "shrink-0 text-sm text-doom-dim",
554
+ children: [
555
+ "+",
556
+ detail.cumulative.additions,
557
+ " -",
558
+ detail.cumulative.removals
559
+ ]
560
+ }),
561
+ /* @__PURE__ */ jsx("nav", {
562
+ className: "flex shrink-0 items-center gap-1",
563
+ children: ["preview", "diff"].map((entry) => /* @__PURE__ */ jsx(Button, {
564
+ variant: view === entry ? "outline" : "ghost",
565
+ size: "xs",
566
+ "data-testid": `files-view-${entry}`,
567
+ onClick: () => setView(entry),
568
+ className: "",
569
+ children: entry
570
+ }, entry))
571
+ }),
572
+ /* @__PURE__ */ jsxs(DropdownMenu, { children: [/* @__PURE__ */ jsx(DropdownMenuTrigger, {
573
+ asChild: true,
574
+ children: /* @__PURE__ */ jsx(Button, {
575
+ variant: "ghost",
576
+ size: "icon",
577
+ "data-testid": "files-menu",
578
+ title: "file actions",
579
+ className: "shrink-0 text-doom-faint hover:text-doom-hi",
580
+ children: /* @__PURE__ */ jsx(KebabIcon, { className: "h-3 w-3" })
581
+ })
582
+ }), /* @__PURE__ */ jsxs(DropdownMenuContent, {
583
+ "data-testid": "files-menu-list",
584
+ children: [
585
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
586
+ "data-testid": "files-edit",
587
+ disabled: outside || working?.unavailable === true,
588
+ onSelect: () => setView("edit"),
589
+ children: "edit"
590
+ }),
591
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
592
+ variant: "destructive",
593
+ "data-testid": "files-delete",
594
+ disabled: outside || working?.unavailable === true,
595
+ onSelect: () => setConfirmingDelete(true),
596
+ children: "delete"
597
+ }),
598
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
599
+ "data-testid": "files-close",
600
+ onSelect: () => closeTransientTab(fileTabId(filePath)),
601
+ children: "close"
602
+ })
603
+ ]
604
+ })] })
605
+ ]
606
+ }),
607
+ /* @__PURE__ */ jsx(DeleteFileDialog, {
608
+ relPath,
609
+ open: confirmingDelete,
610
+ onConfirm: () => void confirmDelete(),
611
+ onCancel: () => setConfirmingDelete(false)
612
+ }),
613
+ loading && detail === void 0 ? /* @__PURE__ */ jsx("p", {
614
+ "data-testid": "files-loading",
615
+ className: "px-4 py-3 text-sm text-doom-dim",
616
+ children: "reading this file's history…"
617
+ }) : null,
618
+ error === void 0 ? null : /* @__PURE__ */ jsx("p", {
619
+ "data-testid": "files-error",
620
+ className: "px-4 py-3 text-sm text-doom-red",
621
+ children: error
622
+ }),
623
+ /* @__PURE__ */ jsx("div", {
624
+ className: "min-h-0 flex-1 overflow-y-auto",
625
+ children: detail === void 0 ? null : view === "diff" ? /* @__PURE__ */ jsxs("div", {
626
+ className: "flex flex-col",
627
+ children: [
628
+ detail.cumulative.note === void 0 ? null : /* @__PURE__ */ jsx("p", {
629
+ "data-testid": "files-cumulative-note",
630
+ className: "px-4 py-2 text-sm text-doom-yellow",
631
+ children: detail.cumulative.note
632
+ }),
633
+ detail.cumulative.hunks === void 0 ? null : /* @__PURE__ */ jsx(DiffView, {
634
+ hunks: detail.cumulative.hunks,
635
+ testId: "files-cumulative-diff",
636
+ onSelect: (selection) => openDraft(selection)
637
+ }),
638
+ /* @__PURE__ */ jsxs("section", {
639
+ className: "mt-2 border-t border-doom-border",
640
+ children: [/* @__PURE__ */ jsxs("h2", {
641
+ className: "px-4 py-2 text-lg font-semibold text-doom-hi ",
642
+ children: [
643
+ detail.versions.length,
644
+ " ",
645
+ detail.versions.length === 1 ? "change" : "changes",
646
+ " this session"
647
+ ]
648
+ }), detail.versions.map((version) => /* @__PURE__ */ jsxs("article", {
649
+ "data-testid": `files-version-${version.index}`,
650
+ children: [/* @__PURE__ */ jsx(Button, {
651
+ variant: "ghost",
652
+ size: "card",
653
+ "data-testid": `files-version-toggle-${version.index}`,
654
+ onClick: () => setExpanded(expanded === version.index ? void 0 : version.index),
655
+ className: "w-full gap-2 rounded-none px-4 py-1.5 hover:bg-doom-panel",
656
+ children: /* @__PURE__ */ jsxs("span", {
657
+ className: "flex w-full min-w-0 items-center gap-2",
658
+ children: [
659
+ /* @__PURE__ */ jsxs("span", {
660
+ className: "shrink-0 text-sm text-doom-dim",
661
+ children: ["#", version.index]
662
+ }),
663
+ /* @__PURE__ */ jsx("span", {
664
+ className: "min-w-0 flex-1 truncate text-left text-base text-doom-text",
665
+ children: versionSummary(version)
666
+ }),
667
+ version.origin === "scan" ? /* @__PURE__ */ jsx(Badge, {
668
+ tone: "neutral",
669
+ className: "shrink-0 ",
670
+ children: "no baseline"
671
+ }) : null,
672
+ /* @__PURE__ */ jsx("span", {
673
+ className: "shrink-0 text-sm text-doom-dim",
674
+ children: expanded === version.index ? "▾" : "▸"
675
+ })
676
+ ]
677
+ })
678
+ }), expanded !== version.index ? null : version.hunks === void 0 ? /* @__PURE__ */ jsx("p", {
679
+ className: "px-4 py-1.5 text-sm text-doom-dim",
680
+ children: version.note ?? "nothing to show"
681
+ }) : /* @__PURE__ */ jsx(DiffView, {
682
+ hunks: version.hunks,
683
+ testId: `files-version-diff-${version.index}`,
684
+ onSelect: (selection) => openDraft(selection)
685
+ })]
686
+ }, version.index))]
687
+ })
688
+ ]
689
+ }) : view === "edit" ? /* @__PURE__ */ jsx("div", {
690
+ className: "flex h-full flex-col gap-2 p-3",
691
+ children: working?.unavailable === true ? /* @__PURE__ */ jsx("p", {
692
+ "data-testid": "files-source-unavailable",
693
+ className: "text-sm text-doom-dim",
694
+ children: working.reason ?? "this file cannot be shown"
695
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
696
+ className: "min-h-[24rem] flex-1",
697
+ onMouseUp: openSelectionDraft,
698
+ onKeyUp: (event) => {
699
+ if (event.shiftKey) openSelectionDraft();
700
+ },
701
+ children: /* @__PURE__ */ jsx(CodeEditor, {
702
+ "data-testid": "files-source",
703
+ value: content,
704
+ path: relPath,
705
+ onChange: setSource,
706
+ onSelect: (range) => {
707
+ selection.current = range;
708
+ },
709
+ className: "h-full rounded border border-doom-border"
710
+ })
711
+ }), /* @__PURE__ */ jsxs("div", {
712
+ className: "flex shrink-0 items-center gap-2",
713
+ children: [
714
+ /* @__PURE__ */ jsx(Button, {
715
+ variant: "outline",
716
+ size: "xs",
717
+ "data-testid": "files-save",
718
+ loading: saving,
719
+ disabled: source === void 0 || saving,
720
+ onClick: () => void save(),
721
+ className: "",
722
+ children: "save to disk"
723
+ }),
724
+ source === void 0 ? null : /* @__PURE__ */ jsx(Button, {
725
+ variant: "ghost",
726
+ size: "xs",
727
+ "data-testid": "files-revert",
728
+ onClick: () => setSource(void 0),
729
+ className: "",
730
+ children: "discard edits"
731
+ }),
732
+ saveNote === void 0 ? null : /* @__PURE__ */ jsx("span", {
733
+ "data-testid": "files-save-note",
734
+ className: saveNote === "saved" ? "text-sm text-doom-green" : "text-sm text-doom-red",
735
+ children: saveNote
736
+ })
737
+ ]
738
+ })] })
739
+ }) : /* @__PURE__ */ jsxs("div", {
740
+ "data-testid": "files-preview",
741
+ "data-mode": previewMode,
742
+ className: previewMode === "code" ? "flex h-full flex-col gap-2 p-4 text-base text-doom-text" : "flex flex-col gap-2 p-4 text-base text-doom-text",
743
+ onMouseUp: () => {
744
+ if (previewMode === "code") {
745
+ openSelectionDraft();
746
+ return;
747
+ }
748
+ const text = globalThis.getSelection?.()?.toString() ?? "";
749
+ if (text.trim() !== "") openDraft({ snippet: text });
750
+ },
751
+ children: [previewMode === "unavailable" ? /* @__PURE__ */ jsx("p", {
752
+ "data-testid": "files-preview-unavailable",
753
+ className: "text-sm text-doom-dim",
754
+ children: working?.reason ?? "this file cannot be shown"
755
+ }) : null, previewMode === "media" || previewMode === "unavailable" ? /* @__PURE__ */ jsx(SessionMediaPreview, {
756
+ src: mediaSrc,
757
+ path: relPath,
758
+ "data-testid": "files-preview-media"
759
+ }) : previewMode === "markdown" ? /* @__PURE__ */ jsx(Markdown, {
760
+ text: content,
761
+ loadImage
762
+ }) : previewMode === "html" ? /* @__PURE__ */ jsx("iframe", {
763
+ "data-testid": "files-preview-html",
764
+ title: relPath,
765
+ sandbox: "",
766
+ srcDoc: content,
767
+ className: "h-[36rem] w-full rounded border border-doom-border bg-doom-deep"
768
+ }) : previewMode === "code" ? /* @__PURE__ */ jsx(CodeEditor, {
769
+ "data-testid": "files-preview-code",
770
+ value: content,
771
+ path: relPath,
772
+ readOnly: true,
773
+ onSelect: (range) => {
774
+ selection.current = range;
775
+ },
776
+ className: "min-h-[24rem] flex-1 rounded border border-doom-border"
777
+ }) : /* @__PURE__ */ jsx("pre", {
778
+ "data-testid": "files-preview-text",
779
+ className: "whitespace-pre-wrap break-words font-mono text-sm leading-normal text-doom-text",
780
+ children: content
781
+ })]
782
+ })
783
+ }),
784
+ draft === void 0 ? null : /* @__PURE__ */ jsx(ReviewCommentDraft, {
785
+ testIdPrefix: "files-comment",
786
+ snippet: draft.snippet,
787
+ ...draft.side === void 0 ? {} : { side: draft.side },
788
+ ...draft.startLine === void 0 ? {} : { startLine: draft.startLine },
789
+ ...draft.endLine === void 0 ? {} : { endLine: draft.endLine },
790
+ onSubmit: submitDraft,
791
+ onCancel: () => setDraft(void 0)
792
+ }),
793
+ comments.length === 0 ? null : /* @__PURE__ */ jsxs("footer", {
794
+ "data-testid": "files-review",
795
+ className: "shrink-0 border-t border-doom-border px-3 py-2",
796
+ children: [
797
+ /* @__PURE__ */ jsxs("div", {
798
+ className: "flex items-center gap-2 pb-1",
799
+ children: [/* @__PURE__ */ jsxs("span", {
800
+ className: "flex-1 text-base font-semibold text-doom-hi",
801
+ children: [
802
+ comments.length,
803
+ " ",
804
+ comments.length === 1 ? "comment" : "comments"
805
+ ]
806
+ }), /* @__PURE__ */ jsx(Button, {
807
+ variant: "outline",
808
+ size: "xs",
809
+ "data-testid": "files-send-review",
810
+ onClick: sendReview,
811
+ className: "",
812
+ children: "send review"
813
+ })]
814
+ }),
815
+ reviewError === void 0 ? null : /* @__PURE__ */ jsxs("p", {
816
+ role: "alert",
817
+ "data-testid": "files-review-error",
818
+ className: "pb-1 text-sm text-doom-red",
819
+ children: ["review not sent: ", reviewError]
820
+ }),
821
+ comments.map((comment) => /* @__PURE__ */ jsxs("div", {
822
+ "data-testid": "files-review-comment",
823
+ className: "flex items-start gap-2 py-0.5",
824
+ children: [
825
+ /* @__PURE__ */ jsx("span", {
826
+ className: "shrink-0 text-sm text-doom-dim",
827
+ children: reviewCommentAnchor(comment)
828
+ }),
829
+ /* @__PURE__ */ jsx("span", {
830
+ className: "min-w-0 flex-1 truncate text-base text-doom-text",
831
+ children: comment.body
832
+ }),
833
+ /* @__PURE__ */ jsx(Button, {
834
+ variant: "ghost",
835
+ size: "xs",
836
+ "data-testid": "files-review-remove",
837
+ onClick: () => {
838
+ if (sessionId !== null) removeComment(sessionId, comment.id);
839
+ },
840
+ className: "shrink-0 ",
841
+ children: "remove"
842
+ })
843
+ ]
844
+ }, comment.id))
845
+ ]
846
+ })
847
+ ]
848
+ });
849
+ }
850
+ /** The transient tab one file opens in; the same file reopens rather than duplicating. */
851
+ function fileTab(filePath, relPath) {
852
+ return {
853
+ id: fileTabId(filePath),
854
+ label: relPath.split("/").at(-1) ?? relPath,
855
+ panel: (props) => /* @__PURE__ */ jsx(FilePanel, {
856
+ ...props,
857
+ filePath,
858
+ relPath
859
+ })
860
+ };
861
+ }
862
+ //#endregion
863
+ //#region src/extensions/workspaces/sessions/(frontend)/file-links/_components/FilePreviewPanel.tsx
864
+ function FilePreviewPanel({ filePath, sessionId, closeTransientTab }) {
865
+ const [loaded, setLoaded] = useState(void 0);
866
+ useEffect(() => {
867
+ if (sessionId === null) return;
868
+ let cancelled = false;
869
+ fetchFilePreview(sessionId, filePath).then((result) => {
870
+ if (cancelled) return;
871
+ setLoaded(result.ok ? {
872
+ sessionId,
873
+ path: filePath,
874
+ preview: result.preview
875
+ } : {
876
+ sessionId,
877
+ path: filePath,
878
+ error: result.error
879
+ });
880
+ });
881
+ return () => {
882
+ cancelled = true;
883
+ };
884
+ }, [sessionId, filePath]);
885
+ const current = loaded?.path === filePath && loaded.sessionId === sessionId ? loaded : void 0;
886
+ const preview = current?.preview;
887
+ const error = current?.error;
888
+ const relPath = preview?.relPath ?? filePath;
889
+ const working = preview?.working;
890
+ const content = working?.content ?? "";
891
+ const previewMode = previewModeOf(relPath, working?.unavailable === true);
892
+ const loadImage = useCallback((path) => loadMarkdownImage(sessionId, relPath, path), [sessionId, relPath]);
893
+ const mediaSrc = sessionId === null ? "" : sessionFileUrl(sessionId, relPath);
894
+ return /* @__PURE__ */ jsxs("div", {
895
+ "data-testid": "files-preview-panel",
896
+ className: "flex min-h-0 flex-1 flex-col",
897
+ children: [
898
+ /* @__PURE__ */ jsxs("header", {
899
+ className: "flex shrink-0 items-center gap-2 border-b border-doom-border px-4 py-2",
900
+ children: [
901
+ /* @__PURE__ */ jsx(Breadcrumb, {
902
+ path: relPath,
903
+ "data-testid": "files-preview-breadcrumb",
904
+ className: "min-w-0 flex-1"
905
+ }),
906
+ /* @__PURE__ */ jsx("span", {
907
+ className: "shrink-0 text-sm text-doom-dim",
908
+ children: "unchanged"
909
+ }),
910
+ /* @__PURE__ */ jsx(Button, {
911
+ variant: "ghost",
912
+ size: "xs",
913
+ "data-testid": "files-preview-close",
914
+ onClick: () => closeTransientTab(filePreviewTabId(filePath)),
915
+ className: "shrink-0 ",
916
+ children: "close"
917
+ })
918
+ ]
919
+ }),
920
+ error === void 0 ? null : /* @__PURE__ */ jsx("p", {
921
+ "data-testid": "files-preview-error",
922
+ className: "px-4 py-3 text-sm text-doom-red",
923
+ children: error
924
+ }),
925
+ preview === void 0 && error === void 0 ? /* @__PURE__ */ jsx("p", {
926
+ "data-testid": "files-preview-loading",
927
+ className: "px-4 py-3 text-sm text-doom-dim",
928
+ children: "reading this file…"
929
+ }) : null,
930
+ preview === void 0 ? null : /* @__PURE__ */ jsx("div", {
931
+ className: "min-h-0 flex-1 overflow-y-auto",
932
+ children: /* @__PURE__ */ jsxs("div", {
933
+ "data-testid": "files-preview",
934
+ "data-mode": previewMode,
935
+ className: "flex h-full flex-col gap-2 p-4 text-base text-doom-text",
936
+ children: [previewMode === "unavailable" ? /* @__PURE__ */ jsx("p", {
937
+ "data-testid": "files-preview-unavailable",
938
+ className: "text-sm text-doom-dim",
939
+ children: working?.reason ?? "this file cannot be shown"
940
+ }) : null, previewMode === "media" || previewMode === "unavailable" ? /* @__PURE__ */ jsx(SessionMediaPreview, {
941
+ src: mediaSrc,
942
+ path: relPath,
943
+ "data-testid": "files-preview-media"
944
+ }) : previewMode === "markdown" ? /* @__PURE__ */ jsx(Markdown, {
945
+ text: content,
946
+ loadImage
947
+ }) : previewMode === "html" ? /* @__PURE__ */ jsx("iframe", {
948
+ "data-testid": "files-preview-html",
949
+ title: relPath,
950
+ sandbox: "",
951
+ srcDoc: content,
952
+ className: "h-[36rem] w-full rounded border border-doom-border bg-doom-deep"
953
+ }) : previewMode === "code" ? /* @__PURE__ */ jsx(CodeEditor, {
954
+ "data-testid": "files-preview-code",
955
+ value: content,
956
+ path: relPath,
957
+ readOnly: true,
958
+ className: "min-h-[24rem] flex-1 rounded border border-doom-border"
959
+ }) : /* @__PURE__ */ jsx("pre", {
960
+ "data-testid": "files-preview-text",
961
+ className: "whitespace-pre-wrap break-words font-mono text-sm leading-normal text-doom-text",
962
+ children: content
963
+ })]
964
+ })
965
+ })
966
+ ]
967
+ });
968
+ }
969
+ /**
970
+ * The id of the read-only tab, kept apart from the changed-file tab's own id:
971
+ * the same file can be read now and changed later, and the two tabs are not
972
+ * the same view of it.
973
+ */
974
+ function filePreviewTabId(filePath) {
975
+ return `${fileTabId(filePath)}-preview`;
976
+ }
977
+ /** The transient tab an unchanged file opens in; the same file reopens rather than duplicating. */
978
+ function filePreviewTab(filePath) {
979
+ return {
980
+ id: filePreviewTabId(filePath),
981
+ label: filePath.split("/").at(-1) ?? filePath,
982
+ panel: (props) => /* @__PURE__ */ jsx(FilePreviewPanel, {
983
+ ...props,
984
+ filePath
985
+ })
986
+ };
987
+ }
988
+ //#endregion
989
+ //#region src/extensions/workspaces/sessions/(frontend)/file-links/_lib/fileLinks.ts
990
+ /**
991
+ * The files a message names, as links into the same tab the activity dock
992
+ * opens.
993
+ *
994
+ * Only files this session recorded a change to are claimed. A message quotes a
995
+ * great deal that is shaped like nothing in particular, class names and globs
996
+ * and flags among it, and guessing at what is a path produces links that open
997
+ * an error. The recorded set is the one thing the page knows for certain, and
998
+ * it is also the set a reader is most likely to want open.
999
+ */
1000
+ /** A reference the way a message writes one: 'src/app.ts:42' or 'src/app.ts:42:9'. */
1001
+ const LINE_SUFFIX = /:\d+(?::\d+)?$/;
1002
+ function itemsFor(sessionId) {
1003
+ return files.select(files.store.state, sessionId).items;
1004
+ }
1005
+ function match(sessionId, path) {
1006
+ const candidate = path.trim().replace(LINE_SUFFIX, "");
1007
+ if (candidate === "") return void 0;
1008
+ return itemsFor(sessionId).find((item) => item.relPath === candidate || item.path === candidate);
1009
+ }
1010
+ //#endregion
1011
+ //#region src/extensions/workspaces/sessions/(frontend)/file-links/files.web.ts
1012
+ var files_web_default$1 = defineFileLinks({
1013
+ subscribe(listener) {
1014
+ const subscription = files.store.subscribe(listener);
1015
+ return () => subscription.unsubscribe();
1016
+ },
1017
+ fingerprint: (sessionId) => itemsFor(sessionId).map((item) => item.relPath).join("\n"),
1018
+ resolve: (sessionId, path) => {
1019
+ const item = match(sessionId, path);
1020
+ return item === void 0 ? void 0 : fileTab(item.path, item.relPath);
1021
+ },
1022
+ openPath: (sessionId, path) => {
1023
+ const item = match(sessionId, path);
1024
+ if (item !== void 0) return fileTab(item.path, item.relPath);
1025
+ const candidate = path.trim();
1026
+ return candidate === "" ? void 0 : filePreviewTab(candidate);
1027
+ }
1028
+ });
1029
+ //#endregion
1030
+ //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/FilesBrowser.tsx
1031
+ function FileMetadata({ item, label, recent = false }) {
1032
+ return /* @__PURE__ */ jsxs("span", {
1033
+ className: "flex min-w-0 w-full items-center gap-1.5",
1034
+ children: [
1035
+ /* @__PURE__ */ jsx("span", {
1036
+ className: "min-w-0 flex-1 truncate text-left text-base font-semibold text-doom-hi",
1037
+ children: label ?? item.relPath
1038
+ }),
1039
+ recent ? /* @__PURE__ */ jsx("span", {
1040
+ "data-recent": "true",
1041
+ title: "most recent change",
1042
+ className: "shrink-0 text-2xs text-doom-blue",
1043
+ children: "●"
1044
+ }) : null,
1045
+ item.count > 1 ? /* @__PURE__ */ jsxs("span", {
1046
+ className: "shrink-0 text-2xs text-doom-faint",
1047
+ children: [item.count, "×"]
1048
+ }) : null,
1049
+ /* @__PURE__ */ jsx("span", {
1050
+ className: `shrink-0 text-sm ${item.diffable ? "text-doom-dim" : "text-doom-yellow"}`,
1051
+ children: TOOL_LABEL[item.tool] ?? item.tool
1052
+ })
1053
+ ]
1054
+ });
1055
+ }
1056
+ function FileActivityRow({ item, onOpen }) {
1057
+ return /* @__PURE__ */ jsx(Button, {
1058
+ variant: "ghost",
1059
+ size: "card",
1060
+ "data-testid": `activity-file-${item.relPath}`,
1061
+ "data-file-diffable": item.diffable,
1062
+ title: item.diffable ? item.path : `${item.path} (changed by a command, so no diff was captured)`,
1063
+ onClick: onOpen,
1064
+ className: "min-w-0 gap-0.5 rounded-md px-1 py-1 hover:bg-doom-panel",
1065
+ children: /* @__PURE__ */ jsx(FileMetadata, { item })
1066
+ });
1067
+ }
1068
+ /**
1069
+ * The directory a run of rows shares.
1070
+ *
1071
+ * Deliberately not an option: the cursor counts files, and a header is a label
1072
+ * rather than something to open. Keeping it out of the listbox is what lets
1073
+ * arrow keys and Enter stay exactly what they were before the tree existed.
1074
+ */
1075
+ function FileGroupHeader({ prefix }) {
1076
+ return /* @__PURE__ */ jsx("div", {
1077
+ "data-testid": `files-browser-group-${prefix}`,
1078
+ className: "truncate px-4 pt-2 pb-1 text-sm font-semibold text-doom-dim",
1079
+ children: prefix
1080
+ });
1081
+ }
1082
+ function BrowserFileRow({ item, label, indented = false, recent = false, selected, onSelect, onOpen }) {
1083
+ return /* @__PURE__ */ jsx("div", {
1084
+ role: "option",
1085
+ "aria-selected": selected,
1086
+ tabIndex: -1,
1087
+ "data-testid": `files-browser-file-${item.relPath}`,
1088
+ "data-file-diffable": item.diffable,
1089
+ "data-selected": selected,
1090
+ title: item.diffable ? item.path : `${item.path} (changed by a command, so no diff was captured)`,
1091
+ onMouseEnter: onSelect,
1092
+ onClick: onOpen,
1093
+ className: `flex min-w-0 cursor-pointer border-l-2 py-2 ${indented ? "pr-4 pl-8" : "px-4"} ${selected ? "border-doom-blue bg-doom-panel" : "border-transparent hover:bg-doom-panel/60"}`,
1094
+ children: /* @__PURE__ */ jsx(FileMetadata, {
1095
+ item,
1096
+ label,
1097
+ recent
1098
+ })
1099
+ });
1100
+ }
1101
+ /** The complete session file picker, ordered exactly as the file timeline supplies it. */
1102
+ function FilesBrowser({ items, onClose, onOpen }) {
1103
+ const [query, setQuery] = useState("");
1104
+ const [selectedIndex, setSelectedIndex] = useState(0);
1105
+ const inputRef = useRef(null);
1106
+ const listRef = useRef(null);
1107
+ const shown = filterFileItems(items, query);
1108
+ const grouped = query.trim() === "" ? groupByDirectory(shown, (item) => item.relPath) : void 0;
1109
+ const ordered = grouped === void 0 ? shown : grouped.flatMap((group) => group.items);
1110
+ const positions = new Map(ordered.map((item, index) => [item.path, index]));
1111
+ const selected = ordered[selectedIndex];
1112
+ const recentPath = items[0]?.path;
1113
+ const [lastShownLength, setLastShownLength] = useState(shown.length);
1114
+ if (lastShownLength !== shown.length) {
1115
+ setLastShownLength(shown.length);
1116
+ setSelectedIndex((current) => Math.min(current, Math.max(0, shown.length - 1)));
1117
+ }
1118
+ useEffect(() => {
1119
+ listRef.current?.querySelector("[aria-selected=\"true\"]")?.scrollIntoView({ block: "nearest" });
1120
+ }, [selectedIndex]);
1121
+ const move = (delta) => {
1122
+ if (ordered.length === 0) return;
1123
+ setSelectedIndex((current) => (current + delta + ordered.length) % ordered.length);
1124
+ };
1125
+ const onKeyDown = (event) => {
1126
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1127
+ event.preventDefault();
1128
+ move(event.key === "ArrowDown" ? 1 : -1);
1129
+ return;
1130
+ }
1131
+ if (event.key === "Escape") {
1132
+ event.preventDefault();
1133
+ onClose();
1134
+ return;
1135
+ }
1136
+ if (event.key === "Enter" && selected !== void 0 && (!(event.target instanceof Element) || event.target.closest("button") === null)) {
1137
+ event.preventDefault();
1138
+ onOpen(selected);
1139
+ }
1140
+ };
1141
+ return /* @__PURE__ */ jsxs("aside", {
1142
+ "data-testid": "files-browser",
1143
+ "aria-label": "changed files",
1144
+ tabIndex: -1,
1145
+ onKeyDown,
1146
+ className: "fixed inset-y-0 right-0 z-50 flex w-[min(440px,calc(100vw-24px))] flex-col overflow-hidden border-l border-doom-border bg-doom-rail outline-none",
1147
+ children: [
1148
+ /* @__PURE__ */ jsxs("div", {
1149
+ className: "flex h-11 shrink-0 items-center gap-2.5 border-b border-doom-border px-4",
1150
+ children: [
1151
+ /* @__PURE__ */ jsx("span", {
1152
+ className: "text-lg font-semibold text-doom-hi",
1153
+ children: "# files"
1154
+ }),
1155
+ /* @__PURE__ */ jsxs("span", {
1156
+ "data-testid": "files-browser-total",
1157
+ className: "text-sm text-doom-dim",
1158
+ children: [items.length, " changed"]
1159
+ }),
1160
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
1161
+ /* @__PURE__ */ jsx(Button, {
1162
+ variant: "ghost",
1163
+ size: "icon",
1164
+ "data-testid": "files-browser-close",
1165
+ "aria-label": "close changed files",
1166
+ title: "close changed files",
1167
+ onClick: onClose,
1168
+ children: /* @__PURE__ */ jsx(CloseIcon, { className: "h-3 w-3" })
1169
+ })
1170
+ ]
1171
+ }),
1172
+ /* @__PURE__ */ jsxs("div", {
1173
+ className: "flex items-center gap-2 px-4 pt-2.5 pb-1.5",
1174
+ children: [/* @__PURE__ */ jsx(Input, {
1175
+ ref: inputRef,
1176
+ "data-testid": "files-browser-search",
1177
+ "aria-label": "search changed file paths",
1178
+ value: query,
1179
+ placeholder: "search paths…",
1180
+ autoFocus: true,
1181
+ onChange: (event) => {
1182
+ setQuery(event.target.value);
1183
+ setSelectedIndex(0);
1184
+ }
1185
+ }), query.length > 0 ? /* @__PURE__ */ jsx(Button, {
1186
+ variant: "ghost",
1187
+ size: "xs",
1188
+ "data-testid": "files-browser-clear",
1189
+ "aria-label": "clear path search",
1190
+ onClick: () => {
1191
+ setQuery("");
1192
+ setSelectedIndex(0);
1193
+ inputRef.current?.focus();
1194
+ },
1195
+ children: "clear"
1196
+ }) : null]
1197
+ }),
1198
+ /* @__PURE__ */ jsxs("p", {
1199
+ "data-testid": "files-browser-matches",
1200
+ className: "shrink-0 px-4 pb-2 text-sm text-doom-dim",
1201
+ children: [
1202
+ shown.length,
1203
+ " matches · ",
1204
+ grouped === void 0 ? "newest change first" : "by path"
1205
+ ]
1206
+ }),
1207
+ /* @__PURE__ */ jsxs("div", {
1208
+ ref: listRef,
1209
+ role: "listbox",
1210
+ "aria-label": "changed files",
1211
+ className: "flex min-h-0 flex-1 flex-col overflow-y-auto pb-2",
1212
+ children: [shown.length === 0 ? /* @__PURE__ */ jsx("p", {
1213
+ className: "px-4 py-3 text-sm text-doom-dim",
1214
+ children: "nothing matches the path"
1215
+ }) : null, grouped === void 0 ? shown.map((item) => /* @__PURE__ */ jsx(BrowserFileRow, {
1216
+ item,
1217
+ label: item.relPath,
1218
+ recent: item.path === recentPath,
1219
+ selected: positions.get(item.path) === selectedIndex,
1220
+ onSelect: () => setSelectedIndex(positions.get(item.path) ?? 0),
1221
+ onOpen: () => onOpen(item)
1222
+ }, item.path)) : grouped.map((group) => /* @__PURE__ */ jsxs(Fragment, { children: [group.prefix === "" ? null : /* @__PURE__ */ jsx(FileGroupHeader, { prefix: group.prefix }), group.items.map((item) => /* @__PURE__ */ jsx(BrowserFileRow, {
1223
+ item,
1224
+ label: groupRowLabel(group.prefix, item.relPath),
1225
+ indented: true,
1226
+ recent: item.path === recentPath,
1227
+ selected: positions.get(item.path) === selectedIndex,
1228
+ onSelect: () => setSelectedIndex(positions.get(item.path) ?? 0),
1229
+ onOpen: () => onOpen(item)
1230
+ }, item.path))] }, group.prefix))]
1231
+ }),
1232
+ /* @__PURE__ */ jsx("div", {
1233
+ className: "flex h-8 shrink-0 items-center border-t border-doom-border-soft bg-doom-deep px-4",
1234
+ children: /* @__PURE__ */ jsx("span", {
1235
+ className: "text-sm text-doom-dim",
1236
+ children: "↑↓ choose · enter open · esc close"
1237
+ })
1238
+ })
1239
+ ]
1240
+ });
1241
+ }
1242
+ //#endregion
1243
+ //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/FilesActivitySection.tsx
1244
+ /**
1245
+ * The files group's body in the activity dock: what this session has changed.
1246
+ *
1247
+ * Every recorded file is listed, not only the ones the agent named. A file
1248
+ * found by comparing the tree around a bash call is marked, because it is
1249
+ * openable and editable but has no baseline to diff against, and a row that
1250
+ * hid that distinction would promise a diff the tab cannot show.
1251
+ */
1252
+ /** The most recent changes first, which is the order the timeline already folds to. */
1253
+ const VISIBLE_ROWS = 5;
1254
+ function FilesActivitySection({ sessionId, openTransientTab }) {
1255
+ const { items } = useStore(files.store, (state) => files.select(state, sessionId));
1256
+ const [browserOpen, setBrowserOpen] = useState(false);
1257
+ const openFile = (item) => {
1258
+ if (sessionId === null) return;
1259
+ openTransientTab(fileTab(item.path, item.relPath));
1260
+ setBrowserOpen(false);
1261
+ };
1262
+ if (items.length === 0) return /* @__PURE__ */ jsx("p", {
1263
+ "data-testid": "activity-summary-files",
1264
+ className: "px-1 text-sm text-doom-dim",
1265
+ children: "nothing changed yet"
1266
+ });
1267
+ const visible = items.slice(0, VISIBLE_ROWS);
1268
+ return /* @__PURE__ */ jsxs("div", {
1269
+ "data-testid": "activity-file-edits",
1270
+ className: "flex flex-col gap-0.5",
1271
+ children: [
1272
+ visible.map((item) => /* @__PURE__ */ jsx(FileActivityRow, {
1273
+ item,
1274
+ onOpen: () => openFile(item)
1275
+ }, item.path)),
1276
+ items.length > visible.length ? /* @__PURE__ */ jsxs(Button, {
1277
+ variant: "link",
1278
+ size: "xs",
1279
+ "data-testid": "activity-files-show-all",
1280
+ "aria-label": `show all ${items.length} changed files`,
1281
+ className: "self-start px-1 pt-0.5 text-doom-faint",
1282
+ onClick: () => setBrowserOpen(true),
1283
+ children: [
1284
+ "show all ",
1285
+ items.length,
1286
+ " files"
1287
+ ]
1288
+ }) : null,
1289
+ browserOpen ? /* @__PURE__ */ jsx(FilesBrowser, {
1290
+ items,
1291
+ onClose: () => setBrowserOpen(false),
1292
+ onOpen: openFile
1293
+ }) : null
1294
+ ]
1295
+ });
1296
+ }
1297
+ //#endregion
1298
+ //#region src/extensions/workspaces/sessions/(frontend)/fill/Files.activity.files.web.tsx
1299
+ var Files_activity_files_web_default = defineFill({ component: FilesActivitySection });
1300
+ var files_web_default = defineLeaderBinding({
1301
+ path: [{
1302
+ key: "e",
1303
+ label: "extension",
1304
+ detail: "tools, skills and config"
1305
+ }, {
1306
+ key: "f",
1307
+ label: "files",
1308
+ detail: "files this session changed"
1309
+ }],
1310
+ command: "file-edits"
1311
+ });
1312
+ //#endregion
1313
+ //#region generated/web.ts
1314
+ const via = (identity, value) => value === void 0 ? void 0 : {
1315
+ ...identity,
1316
+ ...value
1317
+ };
1318
+ const webPlugin = defineWebPlugin({
1319
+ id: "files",
1320
+ session: {
1321
+ get activityGroups() {
1322
+ return [via({ name: "files" }, files_web_default$2)];
1323
+ },
1324
+ get channels() {
1325
+ return [via({ channel: "file_edits" }, file_edits_web_default)];
1326
+ },
1327
+ get fileLinks() {
1328
+ return files_web_default$1;
1329
+ },
1330
+ get fills() {
1331
+ return [via({
1332
+ slot: "activity.files",
1333
+ id: "files"
1334
+ }, Files_activity_files_web_default)];
1335
+ },
1336
+ get leaderBindings() {
1337
+ return [via({ id: "files.files" }, files_web_default)];
1338
+ }
1339
+ }
1340
+ });
1341
+ //#endregion
1342
+ export { webPlugin };
1343
+
1344
+ //# sourceMappingURL=web.mjs.map