@agimon-ai/po-file-edit 0.0.0-alpha.21 → 0.0.0-alpha.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,36 @@
1
- import { defineChannelFile, defineNativePlugin, defineSessionStore } from "@agimon-ai/po-core/native";
1
+ import { createApiClient, defineChannelFile, defineFileLinks, 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 { ScrollView } from "react-native-unistyles/components/native/ScrollView";
5
+ import { Button, CloseIcon, CodeBlock, CopyButton, DownloadIcon, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, KebabIcon, Label, Markdown, SegmentedControl, Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, Textarea, fontOf } from "@agimon-ai/po-native-components";
6
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
7
+ import { StyleSheet } from "react-native-unistyles";
8
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
9
  //#region src/types/webFiles.ts
3
10
  /** The frame type the hub publishes and the web plugin claims; declared in the poWeb block too. */
4
11
  const filesChannelType = "file_edits";
5
12
  //#endregion
6
13
  //#region src/extensions/workspaces/sessions/(frontend)/_lib/filesModel.ts
7
- function isRecord(value) {
14
+ function isRecord$1(value) {
8
15
  return typeof value === "object" && value !== null && !Array.isArray(value);
9
16
  }
10
17
  function isItem(value) {
11
- return isRecord(value) && typeof value.path === "string" && typeof value.relPath === "string" && typeof value.tool === "string" && typeof value.at === "number" && typeof value.count === "number";
18
+ return isRecord$1(value) && typeof value.path === "string" && typeof value.relPath === "string" && typeof value.tool === "string" && typeof value.at === "number" && typeof value.count === "number";
12
19
  }
13
20
  /** The shared wire gate for browser and native stores. */
14
21
  function parseFilesPayload(input) {
15
- if (!isRecord(input) || !Array.isArray(input.items)) return null;
22
+ if (!isRecord$1(input) || !Array.isArray(input.items)) return null;
16
23
  return { items: input.items.filter(isItem) };
17
24
  }
18
- /** The plugin's session data channel: 'file_edits' payloads into the store. */
19
- const filesChannel = defineSessionStore({
25
+ const files = defineSessionStore({
20
26
  items: [],
21
27
  detail: {},
22
28
  loading: [],
23
29
  errors: {},
24
30
  comments: []
25
- }).channel({
31
+ });
32
+ /** The plugin's session data channel: 'file_edits' payloads into the store. */
33
+ const filesChannel = files.channel({
26
34
  channel: filesChannelType,
27
35
  parse: parseFilesPayload,
28
36
  reduce: (current, { items }) => ({
@@ -30,10 +38,1206 @@ const filesChannel = defineSessionStore({
30
38
  items
31
39
  })
32
40
  });
41
+ function markLoading(sessionId, filePath) {
42
+ files.update(sessionId, (current) => current.loading.includes(filePath) ? current : {
43
+ ...current,
44
+ loading: [...current.loading, filePath],
45
+ errors: withoutKey(current.errors, filePath)
46
+ });
47
+ }
48
+ function storeDetail(sessionId, detail) {
49
+ files.update(sessionId, (current) => ({
50
+ ...current,
51
+ detail: {
52
+ ...current.detail,
53
+ [detail.path]: detail
54
+ },
55
+ loading: current.loading.filter((path) => path !== detail.path),
56
+ errors: withoutKey(current.errors, detail.path)
57
+ }));
58
+ }
59
+ function storeError(sessionId, filePath, message) {
60
+ files.update(sessionId, (current) => ({
61
+ ...current,
62
+ loading: current.loading.filter((path) => path !== filePath),
63
+ errors: {
64
+ ...current.errors,
65
+ [filePath]: message
66
+ }
67
+ }));
68
+ }
69
+ function addComment(sessionId, comment) {
70
+ files.update(sessionId, (current) => ({
71
+ ...current,
72
+ comments: [...current.comments, comment]
73
+ }));
74
+ }
75
+ function removeComment(sessionId, id) {
76
+ files.update(sessionId, (current) => ({
77
+ ...current,
78
+ comments: current.comments.filter((comment) => comment.id !== id)
79
+ }));
80
+ }
81
+ function clearComments(sessionId, filePath) {
82
+ files.update(sessionId, (current) => ({
83
+ ...current,
84
+ comments: current.comments.filter((comment) => comment.path !== filePath)
85
+ }));
86
+ }
87
+ function withoutKey(source, key) {
88
+ return Object.fromEntries(Object.entries(source).filter(([entry]) => entry !== key));
89
+ }
33
90
  //#endregion
34
91
  //#region src/extensions/workspaces/sessions/(frontend)/channel/file-edits.native.ts
35
92
  var file_edits_native_default = defineChannelFile(filesChannel);
36
93
  //#endregion
94
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/fileIdentity.ts
95
+ /** A short, stable fingerprint of a string; enough to separate two paths in an id. */
96
+ function fingerprint(value) {
97
+ let hash = 2166136261;
98
+ for (let index = 0; index < value.length; index += 1) {
99
+ hash ^= value.charCodeAt(index);
100
+ hash = Math.imul(hash, 16777619);
101
+ }
102
+ return (hash >>> 0).toString(36);
103
+ }
104
+ /** The transient tab id for one file; stable, so reopening focuses rather than duplicates. */
105
+ function fileTabId(filePath) {
106
+ const slug = filePath.replaceAll(/[^a-zA-Z0-9]+/gu, "-").replace(/^-|-$/gu, "");
107
+ return `files-file-${fingerprint(filePath)}-${slug.slice(-40)}`;
108
+ }
109
+ const api = createApiClient({
110
+ detail: {
111
+ method: "GET",
112
+ path: "/detail",
113
+ query: ["path"],
114
+ response: void 0
115
+ },
116
+ preview: {
117
+ method: "GET",
118
+ path: "/preview",
119
+ query: ["path"],
120
+ response: void 0
121
+ },
122
+ save: {
123
+ method: "PUT",
124
+ path: "/content",
125
+ response: void 0
126
+ },
127
+ /** The same path as `save`; the method is the whole of the difference. */
128
+ remove: {
129
+ method: "DELETE",
130
+ path: "/content",
131
+ query: ["path"]
132
+ },
133
+ /**
134
+ * The host's own bytes route, not this package's.
135
+ *
136
+ * `host: true` hangs it off the session root rather than under `/plugins`,
137
+ * which is how the cockpit already serves it. Declaring it here is the first
138
+ * time anything records that this package's previews depend on it.
139
+ */
140
+ file: {
141
+ method: "GET",
142
+ path: "/file",
143
+ raw: true,
144
+ query: ["path"],
145
+ host: true
146
+ }
147
+ }, {
148
+ scopes: ["session"],
149
+ basePath: "file-edits",
150
+ transport: nativeApiTransport,
151
+ sessionAddress: sessionApiAddress
152
+ });
153
+ //#endregion
154
+ //#region src/types/fileEditsApi.ts
155
+ /** Query parameter naming the file a request is about. */
156
+ const PATH_QUERY_PARAM = "path";
157
+ //#endregion
158
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/filesApi.native.ts
159
+ /**
160
+ * The page's half of this package's session API: one file's history, the
161
+ * manual save, and the deletion.
162
+ *
163
+ * These are adapters now, not a transport. The generated client owns the URL
164
+ * and the sealed transport with it, so nothing here spells a route or reaches
165
+ * for `fetch`. What stays is this package's own vocabulary: which refusals the
166
+ * reader is shown, and the stale-save contract the editor depends on.
167
+ *
168
+ * Sealing still matters and still happens, one layer down. What travels here is
169
+ * file contents in both directions, which is the worst thing in the cockpit to
170
+ * hand a tunnel's relay in the clear.
171
+ */
172
+ const UNREACHABLE = "The session is unreachable.";
173
+ function isRecord(value) {
174
+ return typeof value === "object" && value !== null && !Array.isArray(value);
175
+ }
176
+ /** The message a reader is shown: the route's own words, or why there were none. */
177
+ function messageOf(result) {
178
+ if (result.status === 0) return UNREACHABLE;
179
+ return result.error === "" ? `The session answered ${result.status}.` : result.error;
180
+ }
181
+ async function fetchFileDetail(sessionId, filePath) {
182
+ const result = await api.session(sessionId).detail({ query: { [PATH_QUERY_PARAM]: filePath } });
183
+ if (!result.ok) return {
184
+ ok: false,
185
+ error: messageOf(result)
186
+ };
187
+ if (!isRecord(result.data)) return {
188
+ ok: false,
189
+ error: "The session answered with no detail."
190
+ };
191
+ return {
192
+ ok: true,
193
+ detail: result.data
194
+ };
195
+ }
196
+ /** One file the session never changed, read only; the route bounds it to the working directory. */
197
+ async function fetchFilePreview(sessionId, filePath) {
198
+ const result = await api.session(sessionId).preview({ query: { [PATH_QUERY_PARAM]: filePath } });
199
+ if (!result.ok) return {
200
+ ok: false,
201
+ error: messageOf(result)
202
+ };
203
+ if (!isRecord(result.data)) return {
204
+ ok: false,
205
+ error: "The session answered with no file."
206
+ };
207
+ return {
208
+ ok: true,
209
+ preview: result.data
210
+ };
211
+ }
212
+ async function saveFileContent(sessionId, filePath, expectedHash, content) {
213
+ const result = await api.session(sessionId).save({ body: {
214
+ path: filePath,
215
+ expectedHash,
216
+ content
217
+ } });
218
+ if (result.status === 409) {
219
+ const data = result.ok ? void 0 : result.data;
220
+ const hash = isRecord(data) && typeof data.hash === "string" ? data.hash : void 0;
221
+ return {
222
+ ok: false,
223
+ stale: true,
224
+ error: result.ok ? "The file changed since it was opened." : messageOf(result),
225
+ ...hash === void 0 ? {} : { hash }
226
+ };
227
+ }
228
+ if (!result.ok) return {
229
+ ok: false,
230
+ stale: false,
231
+ error: messageOf(result)
232
+ };
233
+ return {
234
+ ok: true,
235
+ hash: isRecord(result.data) && typeof result.data.hash === "string" ? result.data.hash : ""
236
+ };
237
+ }
238
+ /**
239
+ * Removes the file from disk.
240
+ *
241
+ * The route takes only a path, with no hash to prove the reader saw what is
242
+ * there now: a deletion discards the file whatever it holds, so a staleness
243
+ * check would guard nothing. The confirmation in front of it is the guard.
244
+ */
245
+ async function deleteFile(sessionId, filePath) {
246
+ const result = await api.session(sessionId).remove({ query: { [PATH_QUERY_PARAM]: filePath } });
247
+ return result.ok ? { ok: true } : {
248
+ ok: false,
249
+ error: messageOf(result)
250
+ };
251
+ }
252
+ /** Binds a save to this package's guarded sealed route and one file version. */
253
+ async function prepareFileSave(sessionId, path, signal) {
254
+ const load = async (headers, signal) => {
255
+ if (signal.aborted) throw new Error("Download cancelled.");
256
+ const result = await api.session(sessionId).file({
257
+ query: { path },
258
+ headers,
259
+ signal
260
+ });
261
+ if (!result.ok && result.status !== 416) throw new Error(result.error || "File could not be read.");
262
+ if (!result.response) throw new Error("File could not be read.");
263
+ return result.response;
264
+ };
265
+ const probe = await load({ Range: "bytes=0-0" }, signal);
266
+ if (signal.aborted) throw new Error("Download cancelled.");
267
+ const range = /^bytes 0-0\/(\d+)$/u.exec(probe.headers.get("content-range") ?? "");
268
+ const empty = probe.status === 416 && probe.headers.get("content-range") === "bytes */0" || probe.status === 200 && probe.headers.get("content-length") === "0";
269
+ if (!empty && (probe.status !== 206 || !range)) throw new Error("The file does not support bounded downloads.");
270
+ const size = empty ? 0 : Number(range[1]);
271
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error("Invalid file size.");
272
+ if ((await probe.arrayBuffer()).byteLength !== (empty ? 0 : 1)) throw new Error("Invalid file response.");
273
+ const version = probe.headers.get("etag");
274
+ if (!version) throw new Error("The file has no version validator.");
275
+ return {
276
+ name: path.split(/[\\/]/u).at(-1) ?? "",
277
+ mimeType: probe.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream",
278
+ size,
279
+ signal,
280
+ read: async (offset, count, signal) => {
281
+ if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(count) || offset < 0 || count < 1 || count > 1048576 || offset + count > size) throw new Error("Invalid download range.");
282
+ const end = offset + count - 1;
283
+ const response = await load({
284
+ Range: `bytes=${offset}-${end}`,
285
+ "If-Match": version
286
+ }, signal);
287
+ if (signal.aborted) throw new Error("Download cancelled.");
288
+ 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.");
289
+ const bytes = new Uint8Array(await response.arrayBuffer());
290
+ if (signal.aborted) throw new Error("Download cancelled.");
291
+ if (bytes.byteLength !== count) throw new Error("The file response is incomplete.");
292
+ return bytes;
293
+ }
294
+ };
295
+ }
296
+ //#endregion
297
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/FilePreview.native.tsx
298
+ const MEDIA = /\.(?:png|jpe?g|gif|webp|bmp|svg|ico|mp3|wav|m4a|ogg|mp4|mov|webm|pdf)$/iu;
299
+ const MAX_MEDIA_BYTES = 5242880;
300
+ const URL_OR_ANCHOR = /^(?:[a-z][a-z\d+.-]*:|\/\/|#|\?)/iu;
301
+ function isMediaPath(path) {
302
+ return MEDIA.test(path);
303
+ }
304
+ function base64(bytes) {
305
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
306
+ const parts = [];
307
+ let chunk = "";
308
+ for (let offset = 0; offset < bytes.length; offset += 3) {
309
+ const first = bytes[offset];
310
+ const second = bytes[offset + 1];
311
+ const third = bytes[offset + 2];
312
+ 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]);
313
+ if (chunk.length >= 8192) {
314
+ parts.push(chunk);
315
+ chunk = "";
316
+ }
317
+ }
318
+ parts.push(chunk);
319
+ return parts.join("");
320
+ }
321
+ /** One media file's bytes through the package's guarded file route, as base64 with its content type. */
322
+ async function fetchMedia(sessionId, path, signal) {
323
+ const result = await api.session(sessionId).file({
324
+ query: { path },
325
+ signal
326
+ });
327
+ if (!result.ok) throw new Error(result.error || `Media could not be loaded (${result.status}).`);
328
+ if (Number(result.response.headers.get("content-length")) > MAX_MEDIA_BYTES) throw new Error("This media is too large to preview.");
329
+ const bytes = new Uint8Array(await result.response.arrayBuffer());
330
+ if (bytes.length > MAX_MEDIA_BYTES) throw new Error("This media is too large to preview.");
331
+ const type = result.response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
332
+ 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.");
333
+ return {
334
+ data: base64(bytes),
335
+ type
336
+ };
337
+ }
338
+ /** A Markdown image path, read against the document's own folder as GitHub reads it. */
339
+ function documentRelative(documentPath, source) {
340
+ if (source.startsWith("/")) return source;
341
+ const parts = documentPath.split("/").slice(0, -1);
342
+ for (const segment of source.split("/")) if (segment === "..") parts.pop();
343
+ else if (segment !== "." && segment !== "") parts.push(segment);
344
+ return parts.join("/");
345
+ }
346
+ function SessionMedia({ sessionId, path, renderMediaPreview }) {
347
+ const [loaded, setLoaded] = useState();
348
+ useEffect(() => {
349
+ if (sessionId === null) return;
350
+ const controller = new AbortController();
351
+ (async () => {
352
+ const { data, type } = await fetchMedia(sessionId, path, controller.signal);
353
+ if (!controller.signal.aborted) setLoaded({
354
+ sessionId,
355
+ path,
356
+ type,
357
+ data
358
+ });
359
+ })().catch((failure) => {
360
+ if (!controller.signal.aborted) setLoaded({
361
+ sessionId,
362
+ path,
363
+ error: failure instanceof Error ? failure.message : String(failure)
364
+ });
365
+ });
366
+ return () => controller.abort();
367
+ }, [sessionId, path]);
368
+ const current = loaded?.sessionId === sessionId && loaded.path === path ? loaded : void 0;
369
+ if (!current?.data) return /* @__PURE__ */ jsx(Text, {
370
+ accessibilityRole: current?.error ? "alert" : "text",
371
+ style: current?.error ? styles$2.error : styles$2.meta,
372
+ children: current?.error ?? "Loading media..."
373
+ });
374
+ return renderMediaPreview ? /* @__PURE__ */ jsx(Fragment, { children: renderMediaPreview({
375
+ key: `file-media-${sessionId}-${path}`,
376
+ data: current.data,
377
+ mimeType: current.type,
378
+ title: path,
379
+ path
380
+ }) }) : /* @__PURE__ */ jsx(Text, {
381
+ style: styles$2.error,
382
+ children: "This host cannot display this media preview."
383
+ });
384
+ }
385
+ /** Rendering is separate from reading: only the guarded package API supplies file content or bytes. */
386
+ function FilePreview({ path, working, ...props }) {
387
+ if (MEDIA.test(path)) return /* @__PURE__ */ jsx(SessionMedia, {
388
+ sessionId: props.sessionId,
389
+ path,
390
+ renderMediaPreview: props.renderMediaPreview
391
+ });
392
+ if (working.unavailable) return /* @__PURE__ */ jsx(Text, {
393
+ style: styles$2.meta,
394
+ children: working.reason ?? "this file cannot be shown"
395
+ });
396
+ if (/\.html?$/iu.test(path)) return props.renderFilePreview ? /* @__PURE__ */ jsx(View, {
397
+ style: styles$2.html,
398
+ children: props.renderFilePreview({
399
+ key: `file-html-${props.sessionId}-${path}`,
400
+ html: working.content,
401
+ title: path
402
+ })
403
+ }) : /* @__PURE__ */ jsx(Text, {
404
+ style: styles$2.error,
405
+ children: "This host cannot display HTML previews."
406
+ });
407
+ if (/\.(?:md|markdown)$/iu.test(path)) {
408
+ const { sessionId } = props;
409
+ return /* @__PURE__ */ jsx(Markdown, {
410
+ text: working.content,
411
+ onFileLink: (file) => {
412
+ const tab = props.fileTabFor(file);
413
+ return tab ? () => props.openTransientTab(tab) : void 0;
414
+ },
415
+ loadImage: async (source) => {
416
+ if (sessionId === null || URL_OR_ANCHOR.test(source)) throw new Error("This image cannot be loaded.");
417
+ const media = await fetchMedia(sessionId, documentRelative(path, source), new AbortController().signal);
418
+ if (!media.type.startsWith("image/")) throw new Error("This file is not an image.");
419
+ return {
420
+ url: `data:${media.type};base64,${media.data}`,
421
+ dispose: () => {}
422
+ };
423
+ }
424
+ });
425
+ }
426
+ return /* @__PURE__ */ jsx(CodeBlock, {
427
+ text: working.content,
428
+ language: path.split(".").at(-1)
429
+ });
430
+ }
431
+ const styles$2 = StyleSheet.create((theme) => ({
432
+ html: {
433
+ minHeight: theme.spacing(90),
434
+ uni__dependencies: [0]
435
+ },
436
+ meta: {
437
+ fontSize: theme.fontSize.sm,
438
+ color: theme.colors.dim,
439
+ uni__dependencies: [0]
440
+ },
441
+ error: {
442
+ fontSize: theme.fontSize.sm,
443
+ color: theme.colors.red,
444
+ uni__dependencies: [0]
445
+ }
446
+ }));
447
+ //#endregion
448
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/FilePanel.native.tsx
449
+ function Diff({ hunks, comment }) {
450
+ return /* @__PURE__ */ jsx(View, {
451
+ style: styles$1.diff,
452
+ children: hunks.map((hunk, index) => /* @__PURE__ */ jsxs(View, { children: [/* @__PURE__ */ jsxs(Text, {
453
+ style: styles$1.meta,
454
+ children: ["line ", hunk.start]
455
+ }), hunk.rows.map((row, position) => /* @__PURE__ */ jsx(Button, {
456
+ variant: "ghost",
457
+ accessibilityLabel: `comment on ${row.marker === "-" ? "removed" : "new"} line ${row.line}`,
458
+ onPress: () => comment({
459
+ snippet: row.content,
460
+ side: row.marker === "-" ? "old" : "new",
461
+ startLine: row.line,
462
+ endLine: row.line
463
+ }),
464
+ children: /* @__PURE__ */ jsxs(Text, {
465
+ selectable: true,
466
+ style: row.marker === "+" ? styles$1.added : row.marker === "-" ? styles$1.removed : styles$1.code,
467
+ children: [
468
+ row.marker,
469
+ " ",
470
+ row.line,
471
+ " ",
472
+ row.content
473
+ ]
474
+ })
475
+ }, `${row.line}-${position}`))] }, `${hunk.start}-${index}`))
476
+ });
477
+ }
478
+ function ReviewSheet({ selection, close, submit }) {
479
+ const [body, setBody] = useState("");
480
+ const [snippet, setSnippet] = useState(selection.snippet);
481
+ return /* @__PURE__ */ jsx(Sheet, {
482
+ open: true,
483
+ onOpenChange: (open) => {
484
+ if (!open) close();
485
+ },
486
+ children: /* @__PURE__ */ jsxs(SheetContent, { children: [/* @__PURE__ */ jsx(SheetHeader, { children: /* @__PURE__ */ jsx(SheetTitle, { children: "review comment" }) }), /* @__PURE__ */ jsxs(SheetBody, { children: [
487
+ /* @__PURE__ */ jsx(Label, { children: "quotation" }),
488
+ /* @__PURE__ */ jsx(Textarea, {
489
+ accessibilityLabel: "quotation",
490
+ value: snippet,
491
+ onChangeText: setSnippet,
492
+ rows: 3
493
+ }),
494
+ /* @__PURE__ */ jsx(Label, { children: "comment" }),
495
+ /* @__PURE__ */ jsx(Textarea, {
496
+ accessibilityLabel: "comment",
497
+ value: body,
498
+ onChangeText: setBody,
499
+ rows: 3
500
+ }),
501
+ /* @__PURE__ */ jsxs(View, {
502
+ style: styles$1.actions,
503
+ children: [/* @__PURE__ */ jsx(Button, {
504
+ variant: "ghost",
505
+ onPress: close,
506
+ children: "cancel"
507
+ }), /* @__PURE__ */ jsx(Button, {
508
+ variant: "primary",
509
+ disabled: !body.trim(),
510
+ onPress: () => submit(body.trim(), snippet),
511
+ children: "add comment"
512
+ })]
513
+ })
514
+ ] })] })
515
+ });
516
+ }
517
+ /** File writes retain the existing containment and stale-hash guards; deletion always asks first. */
518
+ function FilePanel({ filePath, relPath, ...props }) {
519
+ const { sessionId, sendSessionFrame, closeTransientTab } = props;
520
+ const subscribe = useCallback((listener) => {
521
+ const subscription = files.store.subscribe(listener);
522
+ return () => subscription.unsubscribe();
523
+ }, []);
524
+ const session = useSyncExternalStore(subscribe, () => files.select(files.store.state, sessionId));
525
+ const detail = session.detail[filePath];
526
+ const working = detail?.working;
527
+ const comments = session.comments.filter((comment) => comment.path === filePath);
528
+ const [view, setView] = useState("preview");
529
+ const [source, setSource] = useState();
530
+ const [expanded, setExpanded] = useState();
531
+ const [selection, setSelection] = useState();
532
+ const [draft, setDraft] = useState();
533
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
534
+ const [busy, setBusy] = useState(false);
535
+ const [note, setNote] = useState();
536
+ const [reviewError, setReviewError] = useState();
537
+ const outside = relPath === ".." || relPath.startsWith("../") || relPath.startsWith("/") || relPath.startsWith("..\\") || /^[a-z]:[\\/]/iu.test(relPath);
538
+ const writable = sessionId !== null && !outside && working !== void 0 && !working.unavailable;
539
+ const revision = session.items.find((item) => item.path === filePath)?.count ?? 0;
540
+ useEffect(() => {
541
+ if (sessionId === null) return;
542
+ let cancelled = false;
543
+ markLoading(sessionId, filePath);
544
+ fetchFileDetail(sessionId, filePath).then((result) => {
545
+ if (cancelled) return;
546
+ if (result.ok) {
547
+ storeDetail(sessionId, result.detail);
548
+ setSource(void 0);
549
+ setNote(void 0);
550
+ } else storeError(sessionId, filePath, result.error);
551
+ }).catch((failure) => {
552
+ if (!cancelled) storeError(sessionId, filePath, failure instanceof Error ? failure.message : String(failure));
553
+ });
554
+ return () => {
555
+ cancelled = true;
556
+ };
557
+ }, [
558
+ sessionId,
559
+ filePath,
560
+ revision
561
+ ]);
562
+ const downloadController = useRef(void 0);
563
+ useEffect(() => () => downloadController.current?.abort(), [sessionId, relPath]);
564
+ const download = async () => {
565
+ if (sessionId === null || outside || busy || !props.nativeSaveFile) return;
566
+ const controller = new AbortController();
567
+ downloadController.current = controller;
568
+ setBusy(true);
569
+ try {
570
+ await props.nativeSaveFile(await prepareFileSave(sessionId, relPath, controller.signal));
571
+ if (!controller.signal.aborted) setNote("saved");
572
+ } catch (error) {
573
+ if (!controller.signal.aborted) setNote(error instanceof Error ? error.message : String(error));
574
+ } finally {
575
+ setBusy(false);
576
+ }
577
+ };
578
+ const save = async () => {
579
+ if (!writable || sessionId === null || !working || source === void 0 || busy) return;
580
+ setBusy(true);
581
+ try {
582
+ const result = await saveFileContent(sessionId, filePath, working.hash, source);
583
+ if (!result.ok) {
584
+ setNote(result.error);
585
+ return;
586
+ }
587
+ setNote("saved");
588
+ const refreshed = await fetchFileDetail(sessionId, filePath);
589
+ if (refreshed.ok) {
590
+ storeDetail(sessionId, refreshed.detail);
591
+ setSource(void 0);
592
+ } else storeError(sessionId, filePath, refreshed.error);
593
+ } catch (failure) {
594
+ setNote(failure instanceof Error ? failure.message : String(failure));
595
+ } finally {
596
+ setBusy(false);
597
+ }
598
+ };
599
+ const remove = async () => {
600
+ if (!writable || sessionId === null || busy) return;
601
+ setBusy(true);
602
+ try {
603
+ const result = await deleteFile(sessionId, filePath);
604
+ if (!result.ok) {
605
+ storeError(sessionId, filePath, result.error);
606
+ setConfirmingDelete(false);
607
+ } else {
608
+ setConfirmingDelete(false);
609
+ closeTransientTab(fileTabId(filePath));
610
+ }
611
+ } catch (failure) {
612
+ storeError(sessionId, filePath, failure instanceof Error ? failure.message : String(failure));
613
+ setConfirmingDelete(false);
614
+ } finally {
615
+ setBusy(false);
616
+ }
617
+ };
618
+ const sendReview = () => {
619
+ if (sessionId === null || comments.length === 0) return;
620
+ const message = `I left ${comments.length} review ${comments.length === 1 ? "comment" : "comments"} on files you changed. Please address them.\n\n` + comments.map((comment) => {
621
+ 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()}`;
622
+ }).join("\n\n");
623
+ try {
624
+ sendSessionFrame(sessionId, {
625
+ type: "follow_up",
626
+ message
627
+ });
628
+ clearComments(sessionId, filePath);
629
+ setReviewError(void 0);
630
+ } catch (failure) {
631
+ setReviewError(failure instanceof Error ? failure.message : String(failure));
632
+ }
633
+ };
634
+ return /* @__PURE__ */ jsxs(ScrollView, {
635
+ testID: "files-file-panel",
636
+ contentContainerStyle: styles$1.panel,
637
+ children: [
638
+ /* @__PURE__ */ jsxs(View, {
639
+ style: styles$1.header,
640
+ children: [/* @__PURE__ */ jsx(Text, {
641
+ selectable: true,
642
+ style: styles$1.path,
643
+ children: relPath
644
+ }), /* @__PURE__ */ jsxs(DropdownMenu, { children: [/* @__PURE__ */ jsx(DropdownMenuTrigger, {
645
+ variant: "ghost",
646
+ size: "icon",
647
+ accessibilityLabel: "file actions",
648
+ children: /* @__PURE__ */ jsx(KebabIcon, {})
649
+ }), /* @__PURE__ */ jsxs(DropdownMenuContent, { children: [
650
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
651
+ disabled: sessionId === null || outside || busy || !props.nativeSaveFile,
652
+ onSelect: () => void download(),
653
+ children: "download"
654
+ }),
655
+ busy ? /* @__PURE__ */ jsx(DropdownMenuItem, {
656
+ onSelect: () => downloadController.current?.abort(),
657
+ children: "cancel download"
658
+ }) : null,
659
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
660
+ disabled: !writable,
661
+ onSelect: () => setView("edit"),
662
+ children: "edit"
663
+ }),
664
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
665
+ variant: "destructive",
666
+ disabled: !writable,
667
+ onSelect: () => setConfirmingDelete(true),
668
+ children: "delete"
669
+ }),
670
+ /* @__PURE__ */ jsx(DropdownMenuItem, {
671
+ onSelect: () => closeTransientTab(fileTabId(filePath)),
672
+ children: "close"
673
+ })
674
+ ] })] })]
675
+ }),
676
+ view !== "edit" && note ? /* @__PURE__ */ jsx(Text, {
677
+ style: note === "saved" ? styles$1.success : styles$1.error,
678
+ children: note
679
+ }) : null,
680
+ /* @__PURE__ */ jsx(SegmentedControl, {
681
+ accessibilityLabel: "file view",
682
+ options: [{
683
+ value: "preview",
684
+ label: "preview"
685
+ }, {
686
+ value: "diff",
687
+ label: "diff"
688
+ }],
689
+ value: view,
690
+ onValueChange: setView
691
+ }),
692
+ view === "edit" ? /* @__PURE__ */ jsx(Text, {
693
+ style: styles$1.meta,
694
+ children: "editing"
695
+ }) : null,
696
+ session.loading.includes(filePath) && !detail ? /* @__PURE__ */ jsx(Text, {
697
+ style: styles$1.meta,
698
+ children: "reading this file's history…"
699
+ }) : null,
700
+ session.errors[filePath] ? /* @__PURE__ */ jsx(Text, {
701
+ accessibilityRole: "alert",
702
+ style: styles$1.error,
703
+ children: session.errors[filePath]
704
+ }) : null,
705
+ detail ? view === "diff" ? /* @__PURE__ */ jsxs(Fragment, { children: [
706
+ /* @__PURE__ */ jsxs(Text, {
707
+ style: styles$1.meta,
708
+ children: [
709
+ "+",
710
+ detail.cumulative.additions,
711
+ " -",
712
+ detail.cumulative.removals
713
+ ]
714
+ }),
715
+ detail.cumulative.note ? /* @__PURE__ */ jsx(Text, {
716
+ style: styles$1.meta,
717
+ children: detail.cumulative.note
718
+ }) : null,
719
+ detail.cumulative.hunks ? /* @__PURE__ */ jsx(Diff, {
720
+ hunks: detail.cumulative.hunks,
721
+ comment: setDraft
722
+ }) : null,
723
+ /* @__PURE__ */ jsxs(Text, {
724
+ style: styles$1.title,
725
+ children: [detail.versions.length, " changes this session"]
726
+ }),
727
+ detail.versions.map((version) => /* @__PURE__ */ jsxs(View, { children: [/* @__PURE__ */ jsxs(Button, {
728
+ variant: "ghost",
729
+ accessibilityState: { expanded: expanded === version.index },
730
+ onPress: () => setExpanded(expanded === version.index ? void 0 : version.index),
731
+ children: [
732
+ "#",
733
+ version.index,
734
+ " ",
735
+ version.tool,
736
+ " +",
737
+ version.additions,
738
+ " -",
739
+ version.removals,
740
+ version.origin === "scan" ? " · no baseline" : ""
741
+ ]
742
+ }), expanded === version.index ? version.hunks ? /* @__PURE__ */ jsx(Diff, {
743
+ hunks: version.hunks,
744
+ comment: setDraft
745
+ }) : /* @__PURE__ */ jsx(Text, {
746
+ style: styles$1.meta,
747
+ children: version.note ?? "nothing to show"
748
+ }) : null] }, version.index))
749
+ ] }) : view === "edit" ? /* @__PURE__ */ jsxs(Fragment, { children: [writable ? /* @__PURE__ */ jsxs(Fragment, { children: [
750
+ /* @__PURE__ */ jsx(Textarea, {
751
+ accessibilityLabel: "file source",
752
+ value: source ?? working?.content ?? "",
753
+ onChangeText: setSource,
754
+ rows: 12,
755
+ onSelectionChange: (event) => {
756
+ const content = source ?? working?.content ?? "";
757
+ const { start, end } = event.nativeEvent.selection;
758
+ setSelection(end > start ? {
759
+ snippet: content.slice(start, end),
760
+ startLine: content.slice(0, start).split("\n").length,
761
+ endLine: content.slice(0, end).split("\n").length
762
+ } : void 0);
763
+ }
764
+ }),
765
+ /* @__PURE__ */ jsxs(View, {
766
+ style: styles$1.actions,
767
+ children: [/* @__PURE__ */ jsx(Button, {
768
+ variant: "primary",
769
+ disabled: source === void 0 || busy,
770
+ onPress: () => void save(),
771
+ children: "save to disk"
772
+ }), /* @__PURE__ */ jsx(Button, {
773
+ variant: "ghost",
774
+ disabled: source === void 0,
775
+ onPress: () => setSource(void 0),
776
+ children: "discard edits"
777
+ })]
778
+ }),
779
+ /* @__PURE__ */ jsx(Button, {
780
+ variant: "ghost",
781
+ disabled: !selection,
782
+ onPress: () => setDraft(selection),
783
+ children: "comment on selection"
784
+ })
785
+ ] }) : /* @__PURE__ */ jsx(Text, {
786
+ style: styles$1.meta,
787
+ children: working?.reason ?? "this file cannot be edited"
788
+ }), note ? /* @__PURE__ */ jsx(Text, {
789
+ style: note === "saved" ? styles$1.success : styles$1.error,
790
+ children: note
791
+ }) : null] }) : working ? /* @__PURE__ */ jsx(FilePreview, {
792
+ ...props,
793
+ path: relPath,
794
+ working: {
795
+ ...working,
796
+ content: source ?? working.content
797
+ }
798
+ }) : null : null,
799
+ detail && sessionId !== null ? /* @__PURE__ */ jsx(Button, {
800
+ variant: "ghost",
801
+ onPress: () => setDraft({ snippet: "" }),
802
+ children: "add review comment"
803
+ }) : null,
804
+ comments.length ? /* @__PURE__ */ jsxs(View, {
805
+ style: styles$1.diff,
806
+ children: [
807
+ /* @__PURE__ */ jsxs(Button, {
808
+ variant: "outline",
809
+ disabled: sessionId === null,
810
+ onPress: sendReview,
811
+ children: [
812
+ "send review (",
813
+ comments.length,
814
+ ")"
815
+ ]
816
+ }),
817
+ reviewError ? /* @__PURE__ */ jsxs(Text, {
818
+ accessibilityRole: "alert",
819
+ style: styles$1.error,
820
+ children: ["review not sent: ", reviewError]
821
+ }) : null,
822
+ comments.map((comment) => /* @__PURE__ */ jsxs(View, { children: [/* @__PURE__ */ jsxs(Text, {
823
+ style: styles$1.meta,
824
+ children: [
825
+ comment.relPath,
826
+ comment.startLine === void 0 ? "" : `:${comment.startLine}`,
827
+ " · ",
828
+ comment.body
829
+ ]
830
+ }), /* @__PURE__ */ jsx(Button, {
831
+ variant: "ghost",
832
+ onPress: () => {
833
+ if (sessionId !== null) removeComment(sessionId, comment.id);
834
+ },
835
+ children: "remove comment"
836
+ })] }, comment.id))
837
+ ]
838
+ }) : null,
839
+ draft ? /* @__PURE__ */ jsx(ReviewSheet, {
840
+ selection: draft,
841
+ close: () => setDraft(void 0),
842
+ submit: (body, snippet) => {
843
+ if (sessionId !== null) addComment(sessionId, {
844
+ id: `${filePath}:${draft.side ?? "new"}:${draft.startLine ?? 0}:${comments.length}:${body.length}`,
845
+ path: filePath,
846
+ relPath,
847
+ ...draft,
848
+ snippet,
849
+ body
850
+ });
851
+ setDraft(void 0);
852
+ }
853
+ }) : null,
854
+ confirmingDelete ? /* @__PURE__ */ jsx(Sheet, {
855
+ open: true,
856
+ onOpenChange: (open) => {
857
+ if (!open) setConfirmingDelete(false);
858
+ },
859
+ children: /* @__PURE__ */ jsxs(SheetContent, { children: [/* @__PURE__ */ jsx(SheetHeader, { children: /* @__PURE__ */ jsx(SheetTitle, { children: "delete file" }) }), /* @__PURE__ */ jsxs(SheetBody, { children: [/* @__PURE__ */ jsxs(Text, {
860
+ style: styles$1.meta,
861
+ children: [
862
+ "This removes ",
863
+ relPath,
864
+ " from disk. It cannot be undone here."
865
+ ]
866
+ }), /* @__PURE__ */ jsxs(View, {
867
+ style: styles$1.actions,
868
+ children: [/* @__PURE__ */ jsx(Button, {
869
+ variant: "ghost",
870
+ onPress: () => setConfirmingDelete(false),
871
+ children: "cancel"
872
+ }), /* @__PURE__ */ jsx(Button, {
873
+ variant: "danger",
874
+ disabled: !writable || busy,
875
+ onPress: () => void remove(),
876
+ children: "delete"
877
+ })]
878
+ })] })] })
879
+ }) : null
880
+ ]
881
+ });
882
+ }
883
+ function fileTab(filePath, relPath) {
884
+ return {
885
+ id: fileTabId(filePath),
886
+ label: relPath.split("/").at(-1) ?? relPath,
887
+ panel: (props) => /* @__PURE__ */ jsx(FilePanel, {
888
+ ...props,
889
+ filePath,
890
+ relPath
891
+ })
892
+ };
893
+ }
894
+ const styles$1 = StyleSheet.create((theme) => ({
895
+ panel: {
896
+ padding: theme.spacing(4),
897
+ gap: theme.spacing(3),
898
+ uni__dependencies: [0]
899
+ },
900
+ header: {
901
+ flexDirection: "row",
902
+ alignItems: "center",
903
+ gap: theme.spacing(2),
904
+ uni__dependencies: [0]
905
+ },
906
+ actions: {
907
+ flexDirection: "row",
908
+ flexWrap: "wrap",
909
+ gap: theme.spacing(2),
910
+ uni__dependencies: [0]
911
+ },
912
+ diff: {
913
+ gap: theme.spacing(2),
914
+ uni__dependencies: [0]
915
+ },
916
+ title: {
917
+ fontSize: theme.fontSize.base,
918
+ fontWeight: "600",
919
+ color: theme.colors.hi,
920
+ uni__dependencies: [0]
921
+ },
922
+ path: {
923
+ flex: 1,
924
+ fontFamily: fontOf(theme.fontFamily.mono),
925
+ fontSize: theme.fontSize.base,
926
+ color: theme.colors.hi,
927
+ uni__dependencies: [0]
928
+ },
929
+ code: {
930
+ fontFamily: fontOf(theme.fontFamily.mono),
931
+ fontSize: theme.fontSize.sm,
932
+ color: theme.colors.text,
933
+ uni__dependencies: [0]
934
+ },
935
+ added: {
936
+ fontFamily: fontOf(theme.fontFamily.mono),
937
+ fontSize: theme.fontSize.sm,
938
+ color: theme.colors.green,
939
+ uni__dependencies: [0]
940
+ },
941
+ removed: {
942
+ fontFamily: fontOf(theme.fontFamily.mono),
943
+ fontSize: theme.fontSize.sm,
944
+ color: theme.colors.red,
945
+ uni__dependencies: [0]
946
+ },
947
+ meta: {
948
+ fontSize: theme.fontSize.sm,
949
+ color: theme.colors.dim,
950
+ uni__dependencies: [0]
951
+ },
952
+ error: {
953
+ fontSize: theme.fontSize.sm,
954
+ color: theme.colors.red,
955
+ uni__dependencies: [0]
956
+ },
957
+ success: {
958
+ fontSize: theme.fontSize.sm,
959
+ color: theme.colors.green,
960
+ uni__dependencies: [0]
961
+ }
962
+ }));
963
+ //#endregion
964
+ //#region src/extensions/workspaces/sessions/(frontend)/file-links/_components/FilePreviewPanel.native.tsx
965
+ const HostCopyButton = CopyButton;
966
+ /** What the subtitle calls a file, from its extension; the same words as the artifacts preview. */
967
+ function fileKind(path) {
968
+ const extension = path.split(".").at(-1)?.toLowerCase() ?? "";
969
+ if (extension === "md" || extension === "markdown") return "Markdown";
970
+ if (extension === "html" || extension === "htm") return "HTML";
971
+ if (isMediaPath(path)) return "Media";
972
+ return extension === "" || extension === path.toLowerCase() ? "Text" : extension.toUpperCase();
973
+ }
974
+ /**
975
+ * An unchanged file, read-only, laid out as the artifacts preview: what it is and its one action in
976
+ * the top bar, the content padded below, and where it lives pinned at the bottom. Unchanged files are
977
+ * admitted only by the package's guarded preview route.
978
+ */
979
+ function FilePreviewPanel({ filePath, ...props }) {
980
+ const [loaded, setLoaded] = useState();
981
+ useEffect(() => {
982
+ if (props.sessionId === null) return;
983
+ const sessionId = props.sessionId;
984
+ let cancelled = false;
985
+ fetchFilePreview(sessionId, filePath).then((result) => {
986
+ if (!cancelled) setLoaded(result.ok ? {
987
+ sessionId,
988
+ path: filePath,
989
+ preview: result.preview
990
+ } : {
991
+ sessionId,
992
+ path: filePath,
993
+ error: result.error
994
+ });
995
+ }).catch((failure) => {
996
+ if (!cancelled) setLoaded({
997
+ sessionId,
998
+ path: filePath,
999
+ error: failure instanceof Error ? failure.message : String(failure)
1000
+ });
1001
+ });
1002
+ return () => {
1003
+ cancelled = true;
1004
+ };
1005
+ }, [props.sessionId, filePath]);
1006
+ const current = loaded?.path === filePath && loaded.sessionId === props.sessionId ? loaded : void 0;
1007
+ const controller = useRef(void 0);
1008
+ const [saving, setSaving] = useState(false);
1009
+ const [note, setNote] = useState();
1010
+ useEffect(() => () => controller.current?.abort(), [props.sessionId, filePath]);
1011
+ const save = async () => {
1012
+ if (props.sessionId === null || !props.nativeSaveFile || controller.current) return;
1013
+ const active = new AbortController();
1014
+ controller.current = active;
1015
+ setSaving(true);
1016
+ setNote(void 0);
1017
+ try {
1018
+ await props.nativeSaveFile(await prepareFileSave(props.sessionId, filePath, active.signal));
1019
+ if (!active.signal.aborted) setNote("Saved.");
1020
+ } catch (error) {
1021
+ if (!active.signal.aborted) setNote(error instanceof Error ? error.message : String(error));
1022
+ } finally {
1023
+ if (controller.current === active) controller.current = void 0;
1024
+ setSaving(false);
1025
+ }
1026
+ };
1027
+ const cancel = () => {
1028
+ controller.current?.abort();
1029
+ controller.current = void 0;
1030
+ setSaving(false);
1031
+ setNote("Download cancelled.");
1032
+ };
1033
+ const handlers = useRef({
1034
+ save,
1035
+ cancel
1036
+ });
1037
+ useEffect(() => {
1038
+ handlers.current = {
1039
+ save,
1040
+ cancel
1041
+ };
1042
+ });
1043
+ const { setTabHeader } = props;
1044
+ const canSave = props.nativeSaveFile !== void 0;
1045
+ const offline = props.sessionId === null;
1046
+ const media = isMediaPath(filePath) && props.renderMediaPreview !== void 0;
1047
+ const subtitle = `${fileKind(filePath)} · Unchanged`;
1048
+ useEffect(() => {
1049
+ if (!setTabHeader || media) return;
1050
+ setTabHeader({
1051
+ subtitle,
1052
+ action: canSave ? /* @__PURE__ */ jsx(Button, {
1053
+ variant: "ghost",
1054
+ size: "icon",
1055
+ disabled: offline,
1056
+ accessibilityLabel: saving ? "Cancel download" : "Download",
1057
+ onPress: () => saving ? handlers.current.cancel() : void handlers.current.save(),
1058
+ children: saving ? /* @__PURE__ */ jsx(CloseIcon, { color: "hi" }) : /* @__PURE__ */ jsx(DownloadIcon, { color: "hi" })
1059
+ }) : void 0
1060
+ });
1061
+ }, [
1062
+ setTabHeader,
1063
+ media,
1064
+ subtitle,
1065
+ canSave,
1066
+ saving,
1067
+ offline
1068
+ ]);
1069
+ const relPath = current?.preview?.relPath ?? filePath;
1070
+ const body = offline ? /* @__PURE__ */ jsx(Text, {
1071
+ accessibilityRole: "alert",
1072
+ style: styles.error,
1073
+ children: "The session is unavailable."
1074
+ }) : current?.error ? /* @__PURE__ */ jsx(Text, {
1075
+ accessibilityRole: "alert",
1076
+ style: styles.error,
1077
+ children: current.error
1078
+ }) : current?.preview ? /* @__PURE__ */ jsx(FilePreview, {
1079
+ ...props,
1080
+ path: current.preview.relPath,
1081
+ working: current.preview.working
1082
+ }) : /* @__PURE__ */ jsx(Text, {
1083
+ style: styles.meta,
1084
+ children: "Reading this file..."
1085
+ });
1086
+ return /* @__PURE__ */ jsxs(View, {
1087
+ style: styles.screen,
1088
+ children: [media ? /* @__PURE__ */ jsx(View, {
1089
+ style: styles.stage,
1090
+ children: body
1091
+ }) : /* @__PURE__ */ jsxs(ScrollView, {
1092
+ style: styles.stage,
1093
+ contentContainerStyle: styles.content,
1094
+ children: [
1095
+ setTabHeader ? null : /* @__PURE__ */ jsxs(View, {
1096
+ style: styles.header,
1097
+ children: [
1098
+ /* @__PURE__ */ jsx(Text, {
1099
+ style: [styles.meta, styles.headerLabel],
1100
+ children: subtitle
1101
+ }),
1102
+ canSave && saving ? /* @__PURE__ */ jsx(Button, {
1103
+ variant: "ghost",
1104
+ size: "sm",
1105
+ onPress: cancel,
1106
+ children: "Cancel"
1107
+ }) : null,
1108
+ canSave ? /* @__PURE__ */ jsx(Button, {
1109
+ variant: "outline",
1110
+ size: "sm",
1111
+ disabled: saving || offline,
1112
+ onPress: () => void save(),
1113
+ children: "Download"
1114
+ }) : null
1115
+ ]
1116
+ }),
1117
+ note ? /* @__PURE__ */ jsx(Text, {
1118
+ accessibilityLiveRegion: "polite",
1119
+ style: styles.meta,
1120
+ children: note
1121
+ }) : null,
1122
+ body
1123
+ ]
1124
+ }), /* @__PURE__ */ jsxs(View, {
1125
+ style: styles.strip,
1126
+ children: [/* @__PURE__ */ jsx(Text, {
1127
+ selectable: true,
1128
+ numberOfLines: 1,
1129
+ ellipsizeMode: "middle",
1130
+ style: [styles.path, styles.stripPath],
1131
+ children: relPath
1132
+ }), HostCopyButton ? /* @__PURE__ */ jsx(HostCopyButton, {
1133
+ text: relPath,
1134
+ label: "path"
1135
+ }) : null]
1136
+ })]
1137
+ });
1138
+ }
1139
+ function filePreviewTab(filePath) {
1140
+ return {
1141
+ id: `${fileTabId(filePath)}-preview`,
1142
+ label: filePath.split("/").at(-1) ?? filePath,
1143
+ panel: (props) => /* @__PURE__ */ jsx(FilePreviewPanel, {
1144
+ ...props,
1145
+ filePath
1146
+ })
1147
+ };
1148
+ }
1149
+ const styles = StyleSheet.create((theme, rt) => ({
1150
+ screen: { flex: 1 },
1151
+ stage: { flex: 1 },
1152
+ content: {
1153
+ padding: theme.spacing(4),
1154
+ gap: theme.spacing(3),
1155
+ uni__dependencies: [0]
1156
+ },
1157
+ header: {
1158
+ flexDirection: "row",
1159
+ alignItems: "center",
1160
+ gap: theme.spacing(2),
1161
+ paddingBottom: theme.spacing(3),
1162
+ borderBottomWidth: 1,
1163
+ borderBottomColor: theme.colors["border-soft"],
1164
+ uni__dependencies: [0]
1165
+ },
1166
+ headerLabel: { flex: 1 },
1167
+ strip: {
1168
+ flexDirection: "row",
1169
+ alignItems: "center",
1170
+ gap: theme.spacing(2),
1171
+ paddingHorizontal: theme.spacing(4),
1172
+ paddingTop: theme.spacing(2),
1173
+ paddingBottom: theme.spacing(2) + rt.insets.bottom,
1174
+ borderTopWidth: 1,
1175
+ borderTopColor: theme.colors["border-soft"],
1176
+ backgroundColor: theme.colors.bg,
1177
+ uni__dependencies: [0, 9]
1178
+ },
1179
+ stripPath: { flexShrink: 1 },
1180
+ path: {
1181
+ fontFamily: fontOf(theme.fontFamily.mono),
1182
+ fontSize: theme.fontSize.sm,
1183
+ color: theme.colors.dim,
1184
+ uni__dependencies: [0]
1185
+ },
1186
+ meta: {
1187
+ fontFamily: fontOf(theme.fontFamily.sans),
1188
+ fontSize: theme.fontSize.sm,
1189
+ color: theme.colors.dim,
1190
+ uni__dependencies: [0]
1191
+ },
1192
+ error: {
1193
+ fontFamily: fontOf(theme.fontFamily.sans),
1194
+ fontSize: theme.fontSize.sm,
1195
+ color: theme.colors.red,
1196
+ uni__dependencies: [0]
1197
+ }
1198
+ }));
1199
+ //#endregion
1200
+ //#region src/extensions/workspaces/sessions/(frontend)/file-links/_lib/fileLinks.native.ts
1201
+ /**
1202
+ * The files a message names, as links into the same tab the activity dock
1203
+ * opens.
1204
+ *
1205
+ * Only files this session recorded a change to are claimed. A message quotes a
1206
+ * great deal that is shaped like nothing in particular, class names and globs
1207
+ * and flags among it, and guessing at what is a path produces links that open
1208
+ * an error. The recorded set is the one thing the page knows for certain, and
1209
+ * it is also the set a reader is most likely to want open.
1210
+ */
1211
+ /** A reference the way a message writes one: 'src/app.ts:42' or 'src/app.ts:42:9'. */
1212
+ const LINE_SUFFIX = /:\d+(?::\d+)?$/;
1213
+ function itemsFor(sessionId) {
1214
+ return files.select(files.store.state, sessionId).items;
1215
+ }
1216
+ function match(sessionId, path) {
1217
+ const candidate = path.trim().replace(LINE_SUFFIX, "");
1218
+ if (candidate === "") return void 0;
1219
+ return itemsFor(sessionId).find((item) => item.relPath === candidate || item.path === candidate);
1220
+ }
1221
+ //#endregion
1222
+ //#region src/extensions/workspaces/sessions/(frontend)/file-links/files.native.ts
1223
+ var files_native_default = defineFileLinks({
1224
+ subscribe(listener) {
1225
+ const subscription = files.store.subscribe(listener);
1226
+ return () => subscription.unsubscribe();
1227
+ },
1228
+ fingerprint: (sessionId) => itemsFor(sessionId).map((item) => item.relPath).join("\n"),
1229
+ resolve: (sessionId, path) => {
1230
+ const item = match(sessionId, path);
1231
+ return item === void 0 ? void 0 : fileTab(item.path, item.relPath);
1232
+ },
1233
+ openPath: (sessionId, path) => {
1234
+ const item = match(sessionId, path);
1235
+ if (item !== void 0) return fileTab(item.path, item.relPath);
1236
+ const candidate = path.trim();
1237
+ return candidate === "" ? void 0 : filePreviewTab(candidate);
1238
+ }
1239
+ });
1240
+ //#endregion
37
1241
  //#region generated/native.ts
38
1242
  const via = (identity, value) => value === void 0 ? void 0 : {
39
1243
  ...identity,
@@ -41,9 +1245,14 @@ const via = (identity, value) => value === void 0 ? void 0 : {
41
1245
  };
42
1246
  const nativePlugin = defineNativePlugin({
43
1247
  id: "files",
44
- session: { get channels() {
45
- return [via({ channel: "file_edits" }, file_edits_native_default)];
46
- } }
1248
+ session: {
1249
+ get channels() {
1250
+ return [via({ channel: "file_edits" }, file_edits_native_default)];
1251
+ },
1252
+ get fileLinks() {
1253
+ return files_native_default;
1254
+ }
1255
+ }
47
1256
  });
48
1257
  //#endregion
49
1258
  export { nativePlugin };