@agimon-ai/po-file-edit 0.0.0-alpha.1 → 0.0.0-alpha.2

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,1004 @@
1
+ import { createApiClient, defineActivityGroup, defineChannelFile, defineFill, defineNativePlugin, defineSessionStore, nativeApiTransport, sessionApiAddress } from "@agimon-ai/po-core/native";
2
+ import { View } from "react-native-unistyles/components/native/View";
3
+ import { Text } from "react-native-unistyles/components/native/Text";
4
+ import { Button, CodeBlock, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, KebabIcon, Label, ListRow, Markdown, SegmentedControl, Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, Textarea, fontOf } from "@agimon-ai/po-native-components";
5
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
6
+ import { ScrollView } from "react-native-unistyles/components/native/ScrollView";
7
+ import { StyleSheet } from "react-native-unistyles";
8
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/types/webFiles.ts
10
+ /** The frame type the hub publishes and the web plugin claims; declared in the poWeb block too. */
11
+ const filesChannelType = "file_edits";
12
+ //#endregion
13
+ //#region src/extensions/workspaces/sessions/(frontend)/activity-group/files.native.ts
14
+ var files_native_default = defineActivityGroup({
15
+ keys: "e f",
16
+ statusKey: "doom-file-edit-files",
17
+ hideWhenEmpty: true,
18
+ marksBackgroundWork: false,
19
+ order: 15
20
+ });
21
+ //#endregion
22
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/filesModel.ts
23
+ function isRecord$1(value) {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+ function isItem(value) {
27
+ return isRecord$1(value) && typeof value.path === "string" && typeof value.relPath === "string" && typeof value.tool === "string" && typeof value.at === "number" && typeof value.count === "number";
28
+ }
29
+ /** The shared wire gate for browser and native stores. */
30
+ function parseFilesPayload(input) {
31
+ if (!isRecord$1(input) || !Array.isArray(input.items)) return null;
32
+ return { items: input.items.filter(isItem) };
33
+ }
34
+ const files = defineSessionStore({
35
+ items: [],
36
+ detail: {},
37
+ loading: [],
38
+ errors: {},
39
+ comments: []
40
+ });
41
+ /** The plugin's session data channel: 'file_edits' payloads into the store. */
42
+ const filesChannel = files.channel({
43
+ channel: filesChannelType,
44
+ parse: parseFilesPayload,
45
+ reduce: (current, { items }) => ({
46
+ ...current,
47
+ items
48
+ })
49
+ });
50
+ function markLoading(sessionId, filePath) {
51
+ files.update(sessionId, (current) => current.loading.includes(filePath) ? current : {
52
+ ...current,
53
+ loading: [...current.loading, filePath],
54
+ errors: withoutKey(current.errors, filePath)
55
+ });
56
+ }
57
+ function storeDetail(sessionId, detail) {
58
+ files.update(sessionId, (current) => ({
59
+ ...current,
60
+ detail: {
61
+ ...current.detail,
62
+ [detail.path]: detail
63
+ },
64
+ loading: current.loading.filter((path) => path !== detail.path),
65
+ errors: withoutKey(current.errors, detail.path)
66
+ }));
67
+ }
68
+ function storeError(sessionId, filePath, message) {
69
+ files.update(sessionId, (current) => ({
70
+ ...current,
71
+ loading: current.loading.filter((path) => path !== filePath),
72
+ errors: {
73
+ ...current.errors,
74
+ [filePath]: message
75
+ }
76
+ }));
77
+ }
78
+ function addComment(sessionId, comment) {
79
+ files.update(sessionId, (current) => ({
80
+ ...current,
81
+ comments: [...current.comments, comment]
82
+ }));
83
+ }
84
+ function removeComment(sessionId, id) {
85
+ files.update(sessionId, (current) => ({
86
+ ...current,
87
+ comments: current.comments.filter((comment) => comment.id !== id)
88
+ }));
89
+ }
90
+ function clearComments(sessionId, filePath) {
91
+ files.update(sessionId, (current) => ({
92
+ ...current,
93
+ comments: current.comments.filter((comment) => comment.path !== filePath)
94
+ }));
95
+ }
96
+ function withoutKey(source, key) {
97
+ return Object.fromEntries(Object.entries(source).filter(([entry]) => entry !== key));
98
+ }
99
+ //#endregion
100
+ //#region src/extensions/workspaces/sessions/(frontend)/channel/file-edits.native.ts
101
+ var file_edits_native_default = defineChannelFile(filesChannel);
102
+ //#endregion
103
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/fileIdentity.ts
104
+ /** A short, stable fingerprint of a string; enough to separate two paths in an id. */
105
+ function fingerprint(value) {
106
+ let hash = 2166136261;
107
+ for (let index = 0; index < value.length; index += 1) {
108
+ hash ^= value.charCodeAt(index);
109
+ hash = Math.imul(hash, 16777619);
110
+ }
111
+ return (hash >>> 0).toString(36);
112
+ }
113
+ /** The transient tab id for one file; stable, so reopening focuses rather than duplicates. */
114
+ function fileTabId(filePath) {
115
+ const slug = filePath.replaceAll(/[^a-zA-Z0-9]+/gu, "-").replace(/^-|-$/gu, "");
116
+ return `files-file-${fingerprint(filePath)}-${slug.slice(-40)}`;
117
+ }
118
+ const api = createApiClient({
119
+ detail: {
120
+ method: "GET",
121
+ path: "/detail",
122
+ query: ["path"],
123
+ response: void 0
124
+ },
125
+ preview: {
126
+ method: "GET",
127
+ path: "/preview",
128
+ query: ["path"],
129
+ response: void 0
130
+ },
131
+ save: {
132
+ method: "PUT",
133
+ path: "/content",
134
+ response: void 0
135
+ },
136
+ /** The same path as `save`; the method is the whole of the difference. */
137
+ remove: {
138
+ method: "DELETE",
139
+ path: "/content",
140
+ query: ["path"]
141
+ },
142
+ /**
143
+ * The host's own bytes route, not this package's.
144
+ *
145
+ * `host: true` hangs it off the session root rather than under `/plugins`,
146
+ * which is how the cockpit already serves it. Declaring it here is the first
147
+ * time anything records that this package's previews depend on it.
148
+ */
149
+ file: {
150
+ method: "GET",
151
+ path: "/file",
152
+ raw: true,
153
+ query: ["path"],
154
+ host: true
155
+ }
156
+ }, {
157
+ scopes: ["session"],
158
+ basePath: "file-edits",
159
+ transport: nativeApiTransport,
160
+ sessionAddress: sessionApiAddress
161
+ });
162
+ //#endregion
163
+ //#region src/types/fileEditsApi.ts
164
+ /** Query parameter naming the file a request is about. */
165
+ const PATH_QUERY_PARAM = "path";
166
+ //#endregion
167
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/filesApi.native.ts
168
+ /**
169
+ * The page's half of this package's session API: one file's history, the
170
+ * manual save, and the deletion.
171
+ *
172
+ * These are adapters now, not a transport. The generated client owns the URL
173
+ * and the sealed transport with it, so nothing here spells a route or reaches
174
+ * for `fetch`. What stays is this package's own vocabulary: which refusals the
175
+ * reader is shown, and the stale-save contract the editor depends on.
176
+ *
177
+ * Sealing still matters and still happens, one layer down. What travels here is
178
+ * file contents in both directions, which is the worst thing in the cockpit to
179
+ * hand a tunnel's relay in the clear.
180
+ */
181
+ const UNREACHABLE = "The session is unreachable.";
182
+ function isRecord(value) {
183
+ return typeof value === "object" && value !== null && !Array.isArray(value);
184
+ }
185
+ /** The message a reader is shown: the route's own words, or why there were none. */
186
+ function messageOf(result) {
187
+ if (result.status === 0) return UNREACHABLE;
188
+ return result.error === "" ? `The session answered ${result.status}.` : result.error;
189
+ }
190
+ async function fetchFileDetail(sessionId, filePath) {
191
+ const result = await api.session(sessionId).detail({ query: { [PATH_QUERY_PARAM]: filePath } });
192
+ if (!result.ok) return {
193
+ ok: false,
194
+ error: messageOf(result)
195
+ };
196
+ if (!isRecord(result.data)) return {
197
+ ok: false,
198
+ error: "The session answered with no detail."
199
+ };
200
+ return {
201
+ ok: true,
202
+ detail: result.data
203
+ };
204
+ }
205
+ async function saveFileContent(sessionId, filePath, expectedHash, content) {
206
+ const result = await api.session(sessionId).save({ body: {
207
+ path: filePath,
208
+ expectedHash,
209
+ content
210
+ } });
211
+ if (result.status === 409) {
212
+ const data = result.ok ? void 0 : result.data;
213
+ const hash = isRecord(data) && typeof data.hash === "string" ? data.hash : void 0;
214
+ return {
215
+ ok: false,
216
+ stale: true,
217
+ error: result.ok ? "The file changed since it was opened." : messageOf(result),
218
+ ...hash === void 0 ? {} : { hash }
219
+ };
220
+ }
221
+ if (!result.ok) return {
222
+ ok: false,
223
+ stale: false,
224
+ error: messageOf(result)
225
+ };
226
+ return {
227
+ ok: true,
228
+ hash: isRecord(result.data) && typeof result.data.hash === "string" ? result.data.hash : ""
229
+ };
230
+ }
231
+ /**
232
+ * Removes the file from disk.
233
+ *
234
+ * The route takes only a path, with no hash to prove the reader saw what is
235
+ * there now: a deletion discards the file whatever it holds, so a staleness
236
+ * check would guard nothing. The confirmation in front of it is the guard.
237
+ */
238
+ async function deleteFile(sessionId, filePath) {
239
+ const result = await api.session(sessionId).remove({ query: { [PATH_QUERY_PARAM]: filePath } });
240
+ return result.ok ? { ok: true } : {
241
+ ok: false,
242
+ error: messageOf(result)
243
+ };
244
+ }
245
+ /** Binds a save to this package's guarded sealed route and one file version. */
246
+ async function prepareFileSave(sessionId, path, signal) {
247
+ const load = async (headers, signal) => {
248
+ if (signal.aborted) throw new Error("Download cancelled.");
249
+ const result = await api.session(sessionId).file({
250
+ query: { path },
251
+ headers,
252
+ signal
253
+ });
254
+ if (!result.ok && result.status !== 416) throw new Error(result.error || "File could not be read.");
255
+ if (!result.response) throw new Error("File could not be read.");
256
+ return result.response;
257
+ };
258
+ const probe = await load({ Range: "bytes=0-0" }, signal);
259
+ if (signal.aborted) throw new Error("Download cancelled.");
260
+ const range = /^bytes 0-0\/(\d+)$/u.exec(probe.headers.get("content-range") ?? "");
261
+ const empty = probe.status === 416 && probe.headers.get("content-range") === "bytes */0" || probe.status === 200 && probe.headers.get("content-length") === "0";
262
+ if (!empty && (probe.status !== 206 || !range)) throw new Error("The file does not support bounded downloads.");
263
+ const size = empty ? 0 : Number(range[1]);
264
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error("Invalid file size.");
265
+ if ((await probe.arrayBuffer()).byteLength !== (empty ? 0 : 1)) throw new Error("Invalid file response.");
266
+ const version = probe.headers.get("etag");
267
+ if (!version) throw new Error("The file has no version validator.");
268
+ return {
269
+ name: path.split(/[\\/]/u).at(-1) ?? "",
270
+ mimeType: probe.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream",
271
+ size,
272
+ signal,
273
+ read: async (offset, count, signal) => {
274
+ if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(count) || offset < 0 || count < 1 || count > 1048576 || offset + count > size) throw new Error("Invalid download range.");
275
+ const end = offset + count - 1;
276
+ const response = await load({
277
+ Range: `bytes=${offset}-${end}`,
278
+ "If-Match": version
279
+ }, signal);
280
+ if (signal.aborted) throw new Error("Download cancelled.");
281
+ if (response.status !== 206 || response.headers.get("etag") !== version || response.headers.get("content-range") !== `bytes ${offset}-${end}/${size}`) throw new Error("The file changed or its range response is invalid.");
282
+ const bytes = new Uint8Array(await response.arrayBuffer());
283
+ if (signal.aborted) throw new Error("Download cancelled.");
284
+ if (bytes.byteLength !== count) throw new Error("The file response is incomplete.");
285
+ return bytes;
286
+ }
287
+ };
288
+ }
289
+ //#endregion
290
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/FilePreview.native.tsx
291
+ const MEDIA = /\.(?:png|jpe?g|gif|webp|bmp|svg|ico|mp3|wav|m4a|ogg|mp4|mov|webm|pdf)$/iu;
292
+ const MAX_MEDIA_BYTES = 5242880;
293
+ function base64(bytes) {
294
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
295
+ const parts = [];
296
+ let chunk = "";
297
+ for (let offset = 0; offset < bytes.length; offset += 3) {
298
+ const first = bytes[offset];
299
+ const second = bytes[offset + 1];
300
+ const third = bytes[offset + 2];
301
+ chunk += alphabet[first >> 2] + alphabet[(first & 3) << 4 | (second ?? 0) >> 4] + (second === void 0 ? "=" : alphabet[(second & 15) << 2 | (third ?? 0) >> 6]) + (third === void 0 ? "=" : alphabet[third & 63]);
302
+ if (chunk.length >= 8192) {
303
+ parts.push(chunk);
304
+ chunk = "";
305
+ }
306
+ }
307
+ parts.push(chunk);
308
+ return parts.join("");
309
+ }
310
+ function SessionMedia({ sessionId, path, renderMediaPreview }) {
311
+ const [loaded, setLoaded] = useState();
312
+ useEffect(() => {
313
+ if (sessionId === null) return;
314
+ const controller = new AbortController();
315
+ (async () => {
316
+ const result = await api.session(sessionId).file({
317
+ query: { path },
318
+ signal: controller.signal
319
+ });
320
+ if (!result.ok) throw new Error(result.error || `Media could not be loaded (${result.status}).`);
321
+ if (Number(result.response.headers.get("content-length")) > MAX_MEDIA_BYTES) throw new Error("This media is too large to preview.");
322
+ const bytes = new Uint8Array(await result.response.arrayBuffer());
323
+ if (bytes.length > MAX_MEDIA_BYTES) throw new Error("This media is too large to preview.");
324
+ const type = result.response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
325
+ if (!/^(?:image\/[a-z0-9.+-]+|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+|application\/pdf)$/u.test(type)) throw new Error("This media type cannot be previewed.");
326
+ if (!controller.signal.aborted) setLoaded({
327
+ sessionId,
328
+ path,
329
+ type,
330
+ data: base64(bytes)
331
+ });
332
+ })().catch((failure) => {
333
+ if (!controller.signal.aborted) setLoaded({
334
+ sessionId,
335
+ path,
336
+ error: failure instanceof Error ? failure.message : String(failure)
337
+ });
338
+ });
339
+ return () => controller.abort();
340
+ }, [sessionId, path]);
341
+ const current = loaded?.sessionId === sessionId && loaded.path === path ? loaded : void 0;
342
+ if (!current?.data) return /* @__PURE__ */ jsx(Text, {
343
+ accessibilityRole: current?.error ? "alert" : "text",
344
+ style: current?.error ? styles$1.error : styles$1.meta,
345
+ children: current?.error ?? "Loading media..."
346
+ });
347
+ return renderMediaPreview ? /* @__PURE__ */ jsx(Fragment, { children: renderMediaPreview({
348
+ key: `file-media-${sessionId}-${path}`,
349
+ data: current.data,
350
+ mimeType: current.type,
351
+ title: path,
352
+ path
353
+ }) }) : /* @__PURE__ */ jsx(Text, {
354
+ style: styles$1.error,
355
+ children: "This host cannot display this media preview."
356
+ });
357
+ }
358
+ /** Rendering is separate from reading: only the guarded package API supplies file content or bytes. */
359
+ function FilePreview({ path, working, ...props }) {
360
+ if (MEDIA.test(path)) return /* @__PURE__ */ jsx(SessionMedia, {
361
+ sessionId: props.sessionId,
362
+ path,
363
+ renderMediaPreview: props.renderMediaPreview
364
+ });
365
+ if (working.unavailable) return /* @__PURE__ */ jsx(Text, {
366
+ style: styles$1.meta,
367
+ children: working.reason ?? "this file cannot be shown"
368
+ });
369
+ if (/\.html?$/iu.test(path)) return props.renderFilePreview ? /* @__PURE__ */ jsx(View, {
370
+ style: styles$1.html,
371
+ children: props.renderFilePreview({
372
+ key: `file-html-${props.sessionId}-${path}`,
373
+ html: working.content,
374
+ title: path
375
+ })
376
+ }) : /* @__PURE__ */ jsx(Text, {
377
+ style: styles$1.error,
378
+ children: "This host cannot display HTML previews."
379
+ });
380
+ if (/\.(?:md|markdown)$/iu.test(path)) return /* @__PURE__ */ jsx(Markdown, {
381
+ text: working.content,
382
+ onFileLink: (file) => {
383
+ const tab = props.fileTabFor(file);
384
+ return tab ? () => props.openTransientTab(tab) : void 0;
385
+ }
386
+ });
387
+ return /* @__PURE__ */ jsx(CodeBlock, {
388
+ text: working.content,
389
+ language: path.split(".").at(-1)
390
+ });
391
+ }
392
+ const styles$1 = StyleSheet.create((theme) => ({
393
+ html: {
394
+ minHeight: theme.spacing(90),
395
+ uni__dependencies: [0]
396
+ },
397
+ meta: {
398
+ fontSize: theme.fontSize.sm,
399
+ color: theme.colors.dim,
400
+ uni__dependencies: [0]
401
+ },
402
+ error: {
403
+ fontSize: theme.fontSize.sm,
404
+ color: theme.colors.red,
405
+ uni__dependencies: [0]
406
+ }
407
+ }));
408
+ //#endregion
409
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/FilePanel.native.tsx
410
+ function Diff({ hunks, comment }) {
411
+ return /* @__PURE__ */ jsx(View, {
412
+ style: styles.diff,
413
+ children: hunks.map((hunk, index) => /* @__PURE__ */ jsxs(View, { children: [/* @__PURE__ */ jsxs(Text, {
414
+ style: styles.meta,
415
+ children: ["line ", hunk.start]
416
+ }), hunk.rows.map((row, position) => /* @__PURE__ */ jsx(Button, {
417
+ variant: "ghost",
418
+ accessibilityLabel: `comment on ${row.marker === "-" ? "removed" : "new"} line ${row.line}`,
419
+ onPress: () => comment({
420
+ snippet: row.content,
421
+ side: row.marker === "-" ? "old" : "new",
422
+ startLine: row.line,
423
+ endLine: row.line
424
+ }),
425
+ children: /* @__PURE__ */ jsxs(Text, {
426
+ selectable: true,
427
+ style: row.marker === "+" ? styles.added : row.marker === "-" ? styles.removed : styles.code,
428
+ children: [
429
+ row.marker,
430
+ " ",
431
+ row.line,
432
+ " ",
433
+ row.content
434
+ ]
435
+ })
436
+ }, `${row.line}-${position}`))] }, `${hunk.start}-${index}`))
437
+ });
438
+ }
439
+ function ReviewSheet({ selection, close, submit }) {
440
+ const [body, setBody] = useState("");
441
+ const [snippet, setSnippet] = useState(selection.snippet);
442
+ return /* @__PURE__ */ jsx(Sheet, {
443
+ open: true,
444
+ onOpenChange: (open) => {
445
+ if (!open) close();
446
+ },
447
+ children: /* @__PURE__ */ jsxs(SheetContent, { children: [/* @__PURE__ */ jsx(SheetHeader, { children: /* @__PURE__ */ jsx(SheetTitle, { children: "review comment" }) }), /* @__PURE__ */ jsxs(SheetBody, { children: [
448
+ /* @__PURE__ */ jsx(Label, { children: "quotation" }),
449
+ /* @__PURE__ */ jsx(Textarea, {
450
+ accessibilityLabel: "quotation",
451
+ value: snippet,
452
+ onChangeText: setSnippet,
453
+ rows: 3
454
+ }),
455
+ /* @__PURE__ */ jsx(Label, { children: "comment" }),
456
+ /* @__PURE__ */ jsx(Textarea, {
457
+ accessibilityLabel: "comment",
458
+ value: body,
459
+ onChangeText: setBody,
460
+ rows: 3
461
+ }),
462
+ /* @__PURE__ */ jsxs(View, {
463
+ style: styles.actions,
464
+ children: [/* @__PURE__ */ jsx(Button, {
465
+ variant: "ghost",
466
+ onPress: close,
467
+ children: "cancel"
468
+ }), /* @__PURE__ */ jsx(Button, {
469
+ variant: "primary",
470
+ disabled: !body.trim(),
471
+ onPress: () => submit(body.trim(), snippet),
472
+ children: "add comment"
473
+ })]
474
+ })
475
+ ] })] })
476
+ });
477
+ }
478
+ /** File writes retain the existing containment and stale-hash guards; deletion always asks first. */
479
+ function FilePanel({ filePath, relPath, ...props }) {
480
+ const { sessionId, sendSessionFrame, closeTransientTab } = props;
481
+ const subscribe = useCallback((listener) => {
482
+ const subscription = files.store.subscribe(listener);
483
+ return () => subscription.unsubscribe();
484
+ }, []);
485
+ const session = useSyncExternalStore(subscribe, () => files.select(files.store.state, sessionId));
486
+ const detail = session.detail[filePath];
487
+ const working = detail?.working;
488
+ const comments = session.comments.filter((comment) => comment.path === filePath);
489
+ const [view, setView] = useState("preview");
490
+ const [source, setSource] = useState();
491
+ const [expanded, setExpanded] = useState();
492
+ const [selection, setSelection] = useState();
493
+ const [draft, setDraft] = useState();
494
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
495
+ const [busy, setBusy] = useState(false);
496
+ const [note, setNote] = useState();
497
+ const [reviewError, setReviewError] = useState();
498
+ const outside = relPath === ".." || relPath.startsWith("../") || relPath.startsWith("/") || relPath.startsWith("..\\") || /^[a-z]:[\\/]/iu.test(relPath);
499
+ const writable = sessionId !== null && !outside && working !== void 0 && !working.unavailable;
500
+ const revision = session.items.find((item) => item.path === filePath)?.count ?? 0;
501
+ useEffect(() => {
502
+ if (sessionId === null) return;
503
+ let cancelled = false;
504
+ markLoading(sessionId, filePath);
505
+ fetchFileDetail(sessionId, filePath).then((result) => {
506
+ if (cancelled) return;
507
+ if (result.ok) {
508
+ storeDetail(sessionId, result.detail);
509
+ setSource(void 0);
510
+ setNote(void 0);
511
+ } else storeError(sessionId, filePath, result.error);
512
+ }).catch((failure) => {
513
+ if (!cancelled) storeError(sessionId, filePath, failure instanceof Error ? failure.message : String(failure));
514
+ });
515
+ return () => {
516
+ cancelled = true;
517
+ };
518
+ }, [
519
+ sessionId,
520
+ filePath,
521
+ revision
522
+ ]);
523
+ const downloadController = useRef(void 0);
524
+ useEffect(() => () => downloadController.current?.abort(), [sessionId, relPath]);
525
+ const download = async () => {
526
+ if (sessionId === null || outside || busy || !props.nativeSaveFile) return;
527
+ const controller = new AbortController();
528
+ downloadController.current = controller;
529
+ setBusy(true);
530
+ try {
531
+ await props.nativeSaveFile(await prepareFileSave(sessionId, relPath, controller.signal));
532
+ if (!controller.signal.aborted) setNote("saved");
533
+ } catch (error) {
534
+ if (!controller.signal.aborted) setNote(error instanceof Error ? error.message : String(error));
535
+ } finally {
536
+ setBusy(false);
537
+ }
538
+ };
539
+ const save = async () => {
540
+ if (!writable || sessionId === null || !working || source === void 0 || busy) return;
541
+ setBusy(true);
542
+ try {
543
+ const result = await saveFileContent(sessionId, filePath, working.hash, source);
544
+ if (!result.ok) {
545
+ setNote(result.error);
546
+ return;
547
+ }
548
+ setNote("saved");
549
+ const refreshed = await fetchFileDetail(sessionId, filePath);
550
+ if (refreshed.ok) {
551
+ storeDetail(sessionId, refreshed.detail);
552
+ setSource(void 0);
553
+ } else storeError(sessionId, filePath, refreshed.error);
554
+ } catch (failure) {
555
+ setNote(failure instanceof Error ? failure.message : String(failure));
556
+ } finally {
557
+ setBusy(false);
558
+ }
559
+ };
560
+ const remove = async () => {
561
+ if (!writable || sessionId === null || busy) return;
562
+ setBusy(true);
563
+ try {
564
+ const result = await deleteFile(sessionId, filePath);
565
+ if (!result.ok) {
566
+ storeError(sessionId, filePath, result.error);
567
+ setConfirmingDelete(false);
568
+ } else {
569
+ setConfirmingDelete(false);
570
+ closeTransientTab(fileTabId(filePath));
571
+ }
572
+ } catch (failure) {
573
+ storeError(sessionId, filePath, failure instanceof Error ? failure.message : String(failure));
574
+ setConfirmingDelete(false);
575
+ } finally {
576
+ setBusy(false);
577
+ }
578
+ };
579
+ const sendReview = () => {
580
+ if (sessionId === null || comments.length === 0) return;
581
+ const message = `I left ${comments.length} review ${comments.length === 1 ? "comment" : "comments"} on files you changed. Please address them.\n\n` + comments.map((comment) => {
582
+ return `### ${comment.startLine === void 0 ? comment.relPath : comment.side === "old" ? `${comment.relPath} (removed line ${comment.startLine})` : `${comment.relPath}:${comment.startLine}`}\n\n${comment.snippet.split("\n").slice(0, 20).join("\n").slice(0, 2e3).split("\n").map((line) => `> ${line}`).join("\n")}\n\n${comment.body.trim()}`;
583
+ }).join("\n\n");
584
+ try {
585
+ sendSessionFrame(sessionId, {
586
+ type: "follow_up",
587
+ message
588
+ });
589
+ clearComments(sessionId, filePath);
590
+ setReviewError(void 0);
591
+ } catch (failure) {
592
+ setReviewError(failure instanceof Error ? failure.message : String(failure));
593
+ }
594
+ };
595
+ return /* @__PURE__ */ jsxs(ScrollView, {
596
+ testID: "files-file-panel",
597
+ contentContainerStyle: styles.panel,
598
+ children: [
599
+ /* @__PURE__ */ jsxs(View, {
600
+ style: styles.header,
601
+ children: [/* @__PURE__ */ jsx(Text, {
602
+ selectable: true,
603
+ style: styles.path,
604
+ children: relPath
605
+ }), /* @__PURE__ */ jsxs(DropdownMenu, { children: [/* @__PURE__ */ jsx(DropdownMenuTrigger, {
606
+ variant: "ghost",
607
+ size: "icon",
608
+ accessibilityLabel: "file actions",
609
+ children: /* @__PURE__ */ jsx(KebabIcon, {})
610
+ }), /* @__PURE__ */ jsxs(DropdownMenuContent, { children: [
611
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
612
+ disabled: sessionId === null || outside || busy || !props.nativeSaveFile,
613
+ onSelect: () => void download(),
614
+ children: "download"
615
+ }),
616
+ busy ? /* @__PURE__ */ jsx(DropdownMenuItem, {
617
+ onSelect: () => downloadController.current?.abort(),
618
+ children: "cancel download"
619
+ }) : null,
620
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
621
+ disabled: !writable,
622
+ onSelect: () => setView("edit"),
623
+ children: "edit"
624
+ }),
625
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
626
+ variant: "destructive",
627
+ disabled: !writable,
628
+ onSelect: () => setConfirmingDelete(true),
629
+ children: "delete"
630
+ }),
631
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
632
+ onSelect: () => closeTransientTab(fileTabId(filePath)),
633
+ children: "close"
634
+ })
635
+ ] })] })]
636
+ }),
637
+ view !== "edit" && note ? /* @__PURE__ */ jsx(Text, {
638
+ style: note === "saved" ? styles.success : styles.error,
639
+ children: note
640
+ }) : null,
641
+ /* @__PURE__ */ jsx(SegmentedControl, {
642
+ accessibilityLabel: "file view",
643
+ options: [{
644
+ value: "preview",
645
+ label: "preview"
646
+ }, {
647
+ value: "diff",
648
+ label: "diff"
649
+ }],
650
+ value: view,
651
+ onValueChange: setView
652
+ }),
653
+ view === "edit" ? /* @__PURE__ */ jsx(Text, {
654
+ style: styles.meta,
655
+ children: "editing"
656
+ }) : null,
657
+ session.loading.includes(filePath) && !detail ? /* @__PURE__ */ jsx(Text, {
658
+ style: styles.meta,
659
+ children: "reading this file's history…"
660
+ }) : null,
661
+ session.errors[filePath] ? /* @__PURE__ */ jsx(Text, {
662
+ accessibilityRole: "alert",
663
+ style: styles.error,
664
+ children: session.errors[filePath]
665
+ }) : null,
666
+ detail ? view === "diff" ? /* @__PURE__ */ jsxs(Fragment, { children: [
667
+ /* @__PURE__ */ jsxs(Text, {
668
+ style: styles.meta,
669
+ children: [
670
+ "+",
671
+ detail.cumulative.additions,
672
+ " -",
673
+ detail.cumulative.removals
674
+ ]
675
+ }),
676
+ detail.cumulative.note ? /* @__PURE__ */ jsx(Text, {
677
+ style: styles.meta,
678
+ children: detail.cumulative.note
679
+ }) : null,
680
+ detail.cumulative.hunks ? /* @__PURE__ */ jsx(Diff, {
681
+ hunks: detail.cumulative.hunks,
682
+ comment: setDraft
683
+ }) : null,
684
+ /* @__PURE__ */ jsxs(Text, {
685
+ style: styles.title,
686
+ children: [detail.versions.length, " changes this session"]
687
+ }),
688
+ detail.versions.map((version) => /* @__PURE__ */ jsxs(View, { children: [/* @__PURE__ */ jsxs(Button, {
689
+ variant: "ghost",
690
+ accessibilityState: { expanded: expanded === version.index },
691
+ onPress: () => setExpanded(expanded === version.index ? void 0 : version.index),
692
+ children: [
693
+ "#",
694
+ version.index,
695
+ " ",
696
+ version.tool,
697
+ " +",
698
+ version.additions,
699
+ " -",
700
+ version.removals,
701
+ version.origin === "scan" ? " · no baseline" : ""
702
+ ]
703
+ }), expanded === version.index ? version.hunks ? /* @__PURE__ */ jsx(Diff, {
704
+ hunks: version.hunks,
705
+ comment: setDraft
706
+ }) : /* @__PURE__ */ jsx(Text, {
707
+ style: styles.meta,
708
+ children: version.note ?? "nothing to show"
709
+ }) : null] }, version.index))
710
+ ] }) : view === "edit" ? /* @__PURE__ */ jsxs(Fragment, { children: [writable ? /* @__PURE__ */ jsxs(Fragment, { children: [
711
+ /* @__PURE__ */ jsx(Textarea, {
712
+ accessibilityLabel: "file source",
713
+ value: source ?? working?.content ?? "",
714
+ onChangeText: setSource,
715
+ rows: 12,
716
+ onSelectionChange: (event) => {
717
+ const content = source ?? working?.content ?? "";
718
+ const { start, end } = event.nativeEvent.selection;
719
+ setSelection(end > start ? {
720
+ snippet: content.slice(start, end),
721
+ startLine: content.slice(0, start).split("\n").length,
722
+ endLine: content.slice(0, end).split("\n").length
723
+ } : void 0);
724
+ }
725
+ }),
726
+ /* @__PURE__ */ jsxs(View, {
727
+ style: styles.actions,
728
+ children: [/* @__PURE__ */ jsx(Button, {
729
+ variant: "primary",
730
+ disabled: source === void 0 || busy,
731
+ onPress: () => void save(),
732
+ children: "save to disk"
733
+ }), /* @__PURE__ */ jsx(Button, {
734
+ variant: "ghost",
735
+ disabled: source === void 0,
736
+ onPress: () => setSource(void 0),
737
+ children: "discard edits"
738
+ })]
739
+ }),
740
+ /* @__PURE__ */ jsx(Button, {
741
+ variant: "ghost",
742
+ disabled: !selection,
743
+ onPress: () => setDraft(selection),
744
+ children: "comment on selection"
745
+ })
746
+ ] }) : /* @__PURE__ */ jsx(Text, {
747
+ style: styles.meta,
748
+ children: working?.reason ?? "this file cannot be edited"
749
+ }), note ? /* @__PURE__ */ jsx(Text, {
750
+ style: note === "saved" ? styles.success : styles.error,
751
+ children: note
752
+ }) : null] }) : working ? /* @__PURE__ */ jsx(FilePreview, {
753
+ ...props,
754
+ path: relPath,
755
+ working: {
756
+ ...working,
757
+ content: source ?? working.content
758
+ }
759
+ }) : null : null,
760
+ detail && sessionId !== null ? /* @__PURE__ */ jsx(Button, {
761
+ variant: "ghost",
762
+ onPress: () => setDraft({ snippet: "" }),
763
+ children: "add review comment"
764
+ }) : null,
765
+ comments.length ? /* @__PURE__ */ jsxs(View, {
766
+ style: styles.diff,
767
+ children: [
768
+ /* @__PURE__ */ jsxs(Button, {
769
+ variant: "outline",
770
+ disabled: sessionId === null,
771
+ onPress: sendReview,
772
+ children: [
773
+ "send review (",
774
+ comments.length,
775
+ ")"
776
+ ]
777
+ }),
778
+ reviewError ? /* @__PURE__ */ jsxs(Text, {
779
+ accessibilityRole: "alert",
780
+ style: styles.error,
781
+ children: ["review not sent: ", reviewError]
782
+ }) : null,
783
+ comments.map((comment) => /* @__PURE__ */ jsxs(View, { children: [/* @__PURE__ */ jsxs(Text, {
784
+ style: styles.meta,
785
+ children: [
786
+ comment.relPath,
787
+ comment.startLine === void 0 ? "" : `:${comment.startLine}`,
788
+ " · ",
789
+ comment.body
790
+ ]
791
+ }), /* @__PURE__ */ jsx(Button, {
792
+ variant: "ghost",
793
+ onPress: () => {
794
+ if (sessionId !== null) removeComment(sessionId, comment.id);
795
+ },
796
+ children: "remove comment"
797
+ })] }, comment.id))
798
+ ]
799
+ }) : null,
800
+ draft ? /* @__PURE__ */ jsx(ReviewSheet, {
801
+ selection: draft,
802
+ close: () => setDraft(void 0),
803
+ submit: (body, snippet) => {
804
+ if (sessionId !== null) addComment(sessionId, {
805
+ id: `${filePath}:${draft.side ?? "new"}:${draft.startLine ?? 0}:${comments.length}:${body.length}`,
806
+ path: filePath,
807
+ relPath,
808
+ ...draft,
809
+ snippet,
810
+ body
811
+ });
812
+ setDraft(void 0);
813
+ }
814
+ }) : null,
815
+ confirmingDelete ? /* @__PURE__ */ jsx(Sheet, {
816
+ open: true,
817
+ onOpenChange: (open) => {
818
+ if (!open) setConfirmingDelete(false);
819
+ },
820
+ children: /* @__PURE__ */ jsxs(SheetContent, { children: [/* @__PURE__ */ jsx(SheetHeader, { children: /* @__PURE__ */ jsx(SheetTitle, { children: "delete file" }) }), /* @__PURE__ */ jsxs(SheetBody, { children: [/* @__PURE__ */ jsxs(Text, {
821
+ style: styles.meta,
822
+ children: [
823
+ "This removes ",
824
+ relPath,
825
+ " from disk. It cannot be undone here."
826
+ ]
827
+ }), /* @__PURE__ */ jsxs(View, {
828
+ style: styles.actions,
829
+ children: [/* @__PURE__ */ jsx(Button, {
830
+ variant: "ghost",
831
+ onPress: () => setConfirmingDelete(false),
832
+ children: "cancel"
833
+ }), /* @__PURE__ */ jsx(Button, {
834
+ variant: "danger",
835
+ disabled: !writable || busy,
836
+ onPress: () => void remove(),
837
+ children: "delete"
838
+ })]
839
+ })] })] })
840
+ }) : null
841
+ ]
842
+ });
843
+ }
844
+ function fileTab(filePath, relPath) {
845
+ return {
846
+ id: fileTabId(filePath),
847
+ label: relPath.split("/").at(-1) ?? relPath,
848
+ panel: (props) => /* @__PURE__ */ jsx(FilePanel, {
849
+ ...props,
850
+ filePath,
851
+ relPath
852
+ })
853
+ };
854
+ }
855
+ const styles = StyleSheet.create((theme) => ({
856
+ panel: {
857
+ padding: theme.spacing(4),
858
+ gap: theme.spacing(3),
859
+ uni__dependencies: [0]
860
+ },
861
+ header: {
862
+ flexDirection: "row",
863
+ alignItems: "center",
864
+ gap: theme.spacing(2),
865
+ uni__dependencies: [0]
866
+ },
867
+ actions: {
868
+ flexDirection: "row",
869
+ flexWrap: "wrap",
870
+ gap: theme.spacing(2),
871
+ uni__dependencies: [0]
872
+ },
873
+ diff: {
874
+ gap: theme.spacing(2),
875
+ uni__dependencies: [0]
876
+ },
877
+ title: {
878
+ fontSize: theme.fontSize.base,
879
+ fontWeight: "600",
880
+ color: theme.colors.hi,
881
+ uni__dependencies: [0]
882
+ },
883
+ path: {
884
+ flex: 1,
885
+ fontFamily: fontOf(theme.fontFamily.mono),
886
+ fontSize: theme.fontSize.base,
887
+ color: theme.colors.hi,
888
+ uni__dependencies: [0]
889
+ },
890
+ code: {
891
+ fontFamily: fontOf(theme.fontFamily.mono),
892
+ fontSize: theme.fontSize.sm,
893
+ color: theme.colors.text,
894
+ uni__dependencies: [0]
895
+ },
896
+ added: {
897
+ fontFamily: fontOf(theme.fontFamily.mono),
898
+ fontSize: theme.fontSize.sm,
899
+ color: theme.colors.green,
900
+ uni__dependencies: [0]
901
+ },
902
+ removed: {
903
+ fontFamily: fontOf(theme.fontFamily.mono),
904
+ fontSize: theme.fontSize.sm,
905
+ color: theme.colors.red,
906
+ uni__dependencies: [0]
907
+ },
908
+ meta: {
909
+ fontSize: theme.fontSize.sm,
910
+ color: theme.colors.dim,
911
+ uni__dependencies: [0]
912
+ },
913
+ error: {
914
+ fontSize: theme.fontSize.sm,
915
+ color: theme.colors.red,
916
+ uni__dependencies: [0]
917
+ },
918
+ success: {
919
+ fontSize: theme.fontSize.sm,
920
+ color: theme.colors.green,
921
+ uni__dependencies: [0]
922
+ }
923
+ }));
924
+ //#endregion
925
+ //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/FilesActivitySection.native.tsx
926
+ /** The latest changes and a searchable complete picker, backed by the native channel store. */
927
+ function FilesActivitySection({ sessionId, openTransientTab }) {
928
+ const subscribe = useCallback((listener) => {
929
+ const subscription = files.store.subscribe(listener);
930
+ return () => subscription.unsubscribe();
931
+ }, []);
932
+ const { items } = useSyncExternalStore(subscribe, () => files.select(files.store.state, sessionId));
933
+ const [open, setOpen] = useState(false);
934
+ const [query, setQuery] = useState("");
935
+ const choose = (item) => {
936
+ if (sessionId !== null) {
937
+ openTransientTab(fileTab(item.path, item.relPath));
938
+ setOpen(false);
939
+ }
940
+ };
941
+ const row = (item) => /* @__PURE__ */ jsx(ListRow, {
942
+ title: item.relPath,
943
+ description: `${item.tool === "bash" ? "command" : item.tool === "user" ? "you" : item.tool}${item.count > 1 ? ` · ${item.count}×` : ""}${item.diffable ? "" : " · no baseline"}`,
944
+ disabled: sessionId === null,
945
+ onPress: () => choose(item)
946
+ }, item.path);
947
+ if (!items.length) return /* @__PURE__ */ jsx(Text, { children: "nothing changed yet" });
948
+ return /* @__PURE__ */ jsxs(View, { children: [
949
+ items.slice(0, 5).map(row),
950
+ items.length > 5 ? /* @__PURE__ */ jsxs(Button, {
951
+ variant: "ghost",
952
+ onPress: () => setOpen(true),
953
+ children: [
954
+ "show all ",
955
+ items.length,
956
+ " files"
957
+ ]
958
+ }) : null,
959
+ open ? /* @__PURE__ */ jsx(Sheet, {
960
+ open: true,
961
+ onOpenChange: setOpen,
962
+ children: /* @__PURE__ */ jsxs(SheetContent, { children: [/* @__PURE__ */ jsx(SheetHeader, { children: /* @__PURE__ */ jsx(SheetTitle, { children: "changed files" }) }), /* @__PURE__ */ jsxs(SheetBody, { children: [/* @__PURE__ */ jsx(Input, {
963
+ accessibilityLabel: "search changed file paths",
964
+ value: query,
965
+ onChangeText: setQuery,
966
+ placeholder: "search paths…"
967
+ }), items.filter((item) => item.relPath.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())).map(row)] })] })
968
+ }) : null
969
+ ] });
970
+ }
971
+ //#endregion
972
+ //#region src/extensions/workspaces/sessions/(frontend)/fill/Files.activity.files.native.tsx
973
+ var Files_activity_files_native_default = defineFill({
974
+ slot: "activity.files",
975
+ id: "files",
976
+ component: FilesActivitySection
977
+ });
978
+ //#endregion
979
+ //#region generated/native.ts
980
+ const via = (identity, value) => value === void 0 ? void 0 : {
981
+ ...identity,
982
+ ...value
983
+ };
984
+ const nativePlugin = defineNativePlugin({
985
+ id: "files",
986
+ session: {
987
+ get activityGroups() {
988
+ return [via({ name: "files" }, files_native_default)];
989
+ },
990
+ get channels() {
991
+ return [via({ channel: "file_edits" }, file_edits_native_default)];
992
+ },
993
+ get fills() {
994
+ return [via({
995
+ slot: "activity.files",
996
+ id: "files"
997
+ }, Files_activity_files_native_default)];
998
+ }
999
+ }
1000
+ });
1001
+ //#endregion
1002
+ export { nativePlugin };
1003
+
1004
+ //# sourceMappingURL=native.mjs.map