@pipelex/mthds-form 0.10.1 → 0.12.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.
@@ -1,5 +1,6 @@
1
- import { collectStuffFiles } from './chunk-4JQAJT7C.js';
2
- import { viewableUrl, isNativeDateNode, isNativeHtmlNode, readHtmlContent, isNativeCompositeNode, readCompositeContent, isNativeValueNode, readDateContent, formatDateContent, readImageContent, readDataUrl, readDocumentContent } from './chunk-TID7ZN63.js';
1
+ import { planStuffSave, planFileSave } from './chunk-V7T4DM63.js';
2
+ import { readStuffFile } from './chunk-VS5JMFUV.js';
3
+ import { viewableUrl, isNativeDateNode, isNativeHtmlNode, readHtmlContent, isNativeCompositeNode, readCompositeContent, isNativeValueNode, readDateContent, formatDateContent, readImageContent, readDataUrl, readDocumentContent } from './chunk-CM4KVG53.js';
3
4
  import { conceptCategory } from './chunk-FABY3DEY.js';
4
5
  import { acceptLabel, acceptMap, isAcceptedFile, formatOfFilename, DOCUMENT_FORMATS, IMAGE_FORMATS } from './chunk-XCO6GTEC.js';
5
6
  import { isFilled } from './chunk-PM55AHRO.js';
@@ -7,7 +8,7 @@ import { toStoredDateValue, toDateInputValue } from './chunk-GKV77RAS.js';
7
8
  import { ownProp } from './chunk-V3OV2XQT.js';
8
9
  import { clsx } from 'clsx';
9
10
  import { twMerge } from 'tailwind-merge';
10
- import { ChevronDown, ChevronUp, Check, List, Braces, ListChecks, ToggleLeft, Hash, Image, FileText, CalendarDays, Type, Loader2, ImageOff, Upload, Link2, EyeOff, Eye, X, Minus, Plus, Trash2, Copy, Download, ChevronRight } from 'lucide-react';
11
+ import { ChevronDown, ChevronUp, Check, List, Braces, ListChecks, ToggleLeft, Hash, Image, FileText, CalendarDays, Type, Loader2, ImageOff, Upload, Link2, EyeOff, Eye, X, Minus, Plus, Trash2, Copy, Download, ChevronRight, TriangleAlert } from 'lucide-react';
11
12
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
12
13
  import * as React2 from 'react';
13
14
  import { createContext, use, useId, useMemo, useContext, useState, useRef, useCallback, useEffect, Fragment as Fragment$1, useLayoutEffect } from 'react';
@@ -203,6 +204,7 @@ var DEFAULT_FIELD_STRINGS = {
203
204
  dropToUpload: "Drop to upload",
204
205
  dropOrBrowse: "Drop a file or click to browse",
205
206
  pasteUrlInstead: "paste a URL instead",
207
+ uploadUnavailable: "Files cannot be uploaded here. Paste a link to the file instead.",
206
208
  urlPlaceholder: "https://\u2026",
207
209
  fileUrlAria: (label) => label ? `Link to the file for ${label}` : "Link to the file",
208
210
  uploadedFile: "Attached file",
@@ -220,6 +222,9 @@ var DEFAULT_FIELD_STRINGS = {
220
222
  copyText: "Copy the text",
221
223
  download: "Download",
222
224
  downloading: "Downloading\u2026",
225
+ downloadIncomplete: (names) => `${names.length === 1 ? "One file" : `${names.length} files`} could not be saved: ${names.join(", ")}`,
226
+ downloadFile: (name) => `Download ${name}`,
227
+ downloadFileFailed: "The file could not be saved",
223
228
  viewRendered: "Result",
224
229
  viewJson: "JSON",
225
230
  copyJson: "Copy the JSON",
@@ -278,7 +283,16 @@ function FieldShell({
278
283
  !required && /* @__PURE__ */ jsx("span", { className: "ml-auto font-mono text-[10px] uppercase tracking-wider text-muted-foreground", children: s.optionalBadge })
279
284
  ] }),
280
285
  description && /* @__PURE__ */ jsx("p", { className: "text-[12px] leading-relaxed text-muted-foreground", children: description }),
281
- isApp && error ? /* @__PURE__ */ jsx("div", { className: "rounded-lg ring-1 ring-destructive/70 ring-offset-4 ring-offset-background", children }) : children,
286
+ /* @__PURE__ */ jsx(
287
+ "div",
288
+ {
289
+ className: cn(
290
+ "space-y-2",
291
+ isApp && error && "rounded-lg ring-1 ring-destructive/70 ring-offset-4 ring-offset-background"
292
+ ),
293
+ children
294
+ }
295
+ ),
282
296
  error && /* @__PURE__ */ jsx("p", { className: "flex items-center gap-1 text-[11px] text-destructive", role: "alert", children: error })
283
297
  ] });
284
298
  }
@@ -348,9 +362,11 @@ function FileField({
348
362
  field,
349
363
  value,
350
364
  onDropFile,
365
+ allowUrl = true,
351
366
  onChange,
352
367
  id,
353
368
  uploading,
369
+ uploadError,
354
370
  resolveUrl,
355
371
  error,
356
372
  disabled,
@@ -372,16 +388,28 @@ function FileField({
372
388
  }, []);
373
389
  useEffect(() => () => setLocal(null), [setLocal]);
374
390
  const [rejected, setRejected] = useState(null);
391
+ const [dismissedUploadError, setDismissedUploadError] = useState(null);
392
+ useEffect(() => {
393
+ if (uploadError === void 0) setDismissedUploadError(null);
394
+ else setRejected(null);
395
+ }, [uploadError]);
396
+ const dismissUploadError = useCallback(() => {
397
+ setDismissedUploadError(uploadError ?? null);
398
+ }, [uploadError]);
399
+ const failure = uploadError !== void 0 && uploadError !== dismissedUploadError ? uploadError : null;
375
400
  const formats = field.formats;
376
401
  const hint = acceptLabel(formats);
377
402
  const accept = useMemo(() => acceptMap(formats), [formats]);
403
+ const canUpload = onDropFile !== void 0;
378
404
  const handleFile = useCallback(
379
405
  (file) => {
406
+ if (!onDropFile) return;
380
407
  if (!isAcceptedFile(formats, file)) {
381
408
  setRejected(file.name);
382
409
  return;
383
410
  }
384
411
  setRejected(null);
412
+ setDismissedUploadError(null);
385
413
  setLocal({ objectUrl: URL.createObjectURL(file), type: file.type });
386
414
  onDropFile(file);
387
415
  },
@@ -391,7 +419,9 @@ function FileField({
391
419
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
392
420
  maxFiles: 1,
393
421
  multiple: false,
394
- disabled: busy,
422
+ // Disabled, not merely unrendered, when there is no upload path: the hook
423
+ // cannot be skipped, and nothing may open a picker through it.
424
+ disabled: busy || !canUpload,
395
425
  // Filters the OS picker so a wrong file is hard to choose in the first
396
426
  // place. It is NOT the enforcement: a drag-and-drop bypasses the picker
397
427
  // entirely, and a browser reporting an empty `File.type` slips through
@@ -421,8 +451,9 @@ function FileField({
421
451
  setResolved(null);
422
452
  setPreviewOpen(false);
423
453
  setRejected(null);
454
+ dismissUploadError();
424
455
  onChange(void 0);
425
- }, [setLocal, onChange]);
456
+ }, [setLocal, onChange, dismissUploadError]);
426
457
  const localIsCurrent = !!localPreview && (localPreview.boundUrl === void 0 || localPreview.boundUrl === value?.url);
427
458
  useEffect(() => {
428
459
  if (!localPreview) return;
@@ -464,6 +495,14 @@ function FileField({
464
495
  };
465
496
  const urlValue = value?.url ?? "";
466
497
  const urlText = presentation === "studio" || isReadableLink(urlValue) || urlValue === typedUrl ? urlValue : "";
498
+ if (!canUpload && !allowUrl) {
499
+ throw new Error(
500
+ `The file field at "${id}" has no way in: the form's environment supplies no onDropFile and sets allowUrl to false. Supply onDropFile so the file can be uploaded, or leave allowUrl unset so a link can be pasted.`
501
+ );
502
+ }
503
+ const linkOpen = allowUrl && (!canUpload || showUrl);
504
+ const noUploadLineShown = !canUpload && !hasFile && !uploading;
505
+ const noUploadLineId = `${domId}-no-upload`;
467
506
  return /* @__PURE__ */ jsxs(
468
507
  FieldShell,
469
508
  {
@@ -497,7 +536,19 @@ function FileField({
497
536
  // load, which is the one thing a refusal must not look like.
498
537
  /* @__PURE__ */ jsx("div", { className: "flex h-24 items-center justify-center rounded-md border border-border bg-muted", children: /* @__PURE__ */ jsx(ImageOff, { className: "h-5 w-5 text-muted-foreground" }) })
499
538
  ))
500
- ] }) : (
539
+ ] }) : !canUpload ? (
540
+ // No upload path, so no dropzone and no picker: a control that takes a
541
+ // file with nowhere to send it drops the file without a word. The line
542
+ // says why the link input below is the way in, and the hint says what
543
+ // that link has to point at.
544
+ /* @__PURE__ */ jsx("div", { className: "space-y-0.5", children: uploading ? /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-1.5 text-[12px] text-muted-foreground", children: [
545
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }),
546
+ s.uploading
547
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
548
+ /* @__PURE__ */ jsx("p", { id: noUploadLineId, className: "text-[12px] text-muted-foreground", children: s.uploadUnavailable }),
549
+ hint && /* @__PURE__ */ jsx("p", { className: "font-mono text-[10.5px] text-muted-foreground", children: hint })
550
+ ] }) })
551
+ ) : (
501
552
  // `role="presentation"` and no tab stop: this div is the drop TARGET and
502
553
  // the visual affordance, and the control inside it is a real
503
554
  // `<input type="file">`, which already has the right role and now has a
@@ -539,39 +590,43 @@ function FileField({
539
590
  }
540
591
  )
541
592
  ),
542
- rejected && !busy && /* @__PURE__ */ jsxs("p", { role: "alert", className: "text-[12px] text-destructive", children: [
593
+ !busy && (rejected ? /* @__PURE__ */ jsxs("p", { role: "alert", className: "text-[12px] text-destructive", children: [
543
594
  /* @__PURE__ */ jsx("span", { className: "font-mono", children: rejected }),
544
595
  " \u2014 ",
545
596
  s.unsupportedFileType(hint)
546
- ] }),
547
- !showUrl ? /* @__PURE__ */ jsxs(
548
- "button",
549
- {
550
- type: "button",
551
- disabled: busy,
552
- onClick: () => setShowUrl(true),
553
- className: "flex w-fit items-center gap-1 font-mono text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50",
554
- children: [
555
- /* @__PURE__ */ jsx(Link2, { className: "h-3 w-3" }),
556
- s.pasteUrlInstead
557
- ]
558
- }
559
- ) : /* @__PURE__ */ jsx(
597
+ ] }) : failure && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-[12px] text-destructive", children: failure })),
598
+ linkOpen ? /* @__PURE__ */ jsx(
560
599
  "input",
561
600
  {
562
601
  type: "text",
563
- autoFocus: true,
602
+ id: canUpload ? void 0 : domId,
603
+ autoFocus: showUrl,
564
604
  value: urlText,
565
605
  disabled: busy,
566
606
  "aria-label": s.fileUrlAria(fieldLabel(field.title, field.name, presentation)),
607
+ "aria-describedby": noUploadLineShown ? noUploadLineId : void 0,
608
+ "aria-invalid": canUpload ? void 0 : !!error,
567
609
  placeholder: s.urlPlaceholder,
568
610
  onChange: (e) => {
569
611
  setLocal(null);
612
+ dismissUploadError();
570
613
  setTypedUrl(e.target.value);
571
614
  onChange(e.target.value ? { url: e.target.value } : void 0);
572
615
  },
573
616
  className: cn(fieldControlClass, "h-9 px-3 font-mono text-[12px]")
574
617
  }
618
+ ) : allowUrl && /* @__PURE__ */ jsxs(
619
+ "button",
620
+ {
621
+ type: "button",
622
+ disabled: busy,
623
+ onClick: () => setShowUrl(true),
624
+ className: "flex w-fit items-center gap-1 font-mono text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50",
625
+ children: [
626
+ /* @__PURE__ */ jsx(Link2, { className: "h-3 w-3" }),
627
+ s.pasteUrlInstead
628
+ ]
629
+ }
575
630
  )
576
631
  ]
577
632
  }
@@ -1111,6 +1166,7 @@ function ListField({ field, value, onChange, id, error, env }) {
1111
1166
  const isApp = presentation === "app";
1112
1167
  const items = value ?? [];
1113
1168
  const [rowKeys, setRowKeys] = useRowKeys(items.length);
1169
+ const [staleErrors, setStaleErrors] = useState(NO_ERRORS);
1114
1170
  const setItem = (index, itemValue) => {
1115
1171
  const next = items.slice();
1116
1172
  next[index] = itemValue;
@@ -1118,10 +1174,40 @@ function ListField({ field, value, onChange, id, error, env }) {
1118
1174
  };
1119
1175
  const removeItem = (index) => {
1120
1176
  setRowKeys((prev) => prev.filter((_, i) => i !== index));
1177
+ setStaleErrors((prev) => withRenumberedErrors(prev, id, index, env?.uploadErrors));
1121
1178
  onChange(items.filter((_, i) => i !== index));
1122
1179
  };
1123
1180
  const addItem = () => onChange([...items, emptyValue(field.item)]);
1124
1181
  const busy = listIsBusy(id, env?.uploadingIds);
1182
+ const hostErrors = env?.uploadErrors;
1183
+ useEffect(() => {
1184
+ setStaleErrors((prev) => {
1185
+ if (prev.size === 0) return prev;
1186
+ const next = new Map([...prev].filter(([key, message]) => hostErrors?.get(key) === message));
1187
+ return next.size === prev.size ? prev : next;
1188
+ });
1189
+ }, [hostErrors]);
1190
+ const rowEnv = useMemo(() => {
1191
+ if (!env || staleErrors.size === 0) return env;
1192
+ const drop = env.onDropFile;
1193
+ return {
1194
+ ...env,
1195
+ uploadErrors: new Map(
1196
+ [...env.uploadErrors ?? []].filter(([key, message]) => staleErrors.get(key) !== message)
1197
+ ),
1198
+ // A new file at a position is a new attempt there, so what the host says
1199
+ // about that position from now on is about the file that is there.
1200
+ onDropFile: drop ? (dropId, file) => {
1201
+ setStaleErrors((prev) => {
1202
+ if (!prev.has(dropId)) return prev;
1203
+ const next = new Map(prev);
1204
+ next.delete(dropId);
1205
+ return next;
1206
+ });
1207
+ drop(dropId, file);
1208
+ } : void 0
1209
+ };
1210
+ }, [env, staleErrors]);
1125
1211
  const isFull = field.maxItemCount !== void 0 && items.length >= field.maxItemCount;
1126
1212
  return /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
1127
1213
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-x-2 gap-y-1", children: [
@@ -1155,7 +1241,7 @@ function ListField({ field, value, onChange, id, error, env }) {
1155
1241
  value: item,
1156
1242
  onChange: (v) => setItem(index, v),
1157
1243
  id: `${id}.${index}`,
1158
- env
1244
+ env: rowEnv
1159
1245
  }
1160
1246
  ) }),
1161
1247
  /* @__PURE__ */ jsx(
@@ -1199,6 +1285,18 @@ function listIsBusy(id, uploadingIds) {
1199
1285
  for (const uploading of uploadingIds) if (uploading.startsWith(prefix)) return true;
1200
1286
  return false;
1201
1287
  }
1288
+ var NO_ERRORS = /* @__PURE__ */ new Map();
1289
+ function withRenumberedErrors(stale, listId, removed, hostErrors) {
1290
+ if (!hostErrors?.size) return stale;
1291
+ const prefix = `${listId}.`;
1292
+ const next = new Map(stale);
1293
+ for (const [key, message] of hostErrors) {
1294
+ if (!key.startsWith(prefix)) continue;
1295
+ const row = key.slice(prefix.length).split(".")[0] ?? "";
1296
+ if (/^\d+$/.test(row) && Number(row) >= removed) next.set(key, message);
1297
+ }
1298
+ return next.size === stale.size && [...next].every(([k, m]) => stale.get(k) === m) ? stale : next;
1299
+ }
1202
1300
  function useRowKeys(length) {
1203
1301
  const [state, setState] = useState(() => ({
1204
1302
  keys: Array.from({ length }, (_, i) => i),
@@ -1265,6 +1363,7 @@ function UnknownField({ field, value, onChange, id, error, disabled }) {
1265
1363
  }
1266
1364
  function FieldRenderer({ field, value, onChange, id, error, env }) {
1267
1365
  const disabled = env?.disabled;
1366
+ const drop = env?.onDropFile;
1268
1367
  switch (field.kind) {
1269
1368
  case "text":
1270
1369
  return /* @__PURE__ */ jsx(
@@ -1344,8 +1443,10 @@ function FieldRenderer({ field, value, onChange, id, error, env }) {
1344
1443
  field,
1345
1444
  value,
1346
1445
  onChange,
1347
- onDropFile: (file) => env?.onDropFile?.(id, file),
1446
+ onDropFile: drop ? (file) => drop(id, file) : void 0,
1447
+ allowUrl: env?.allowUrl,
1348
1448
  uploading: env?.uploadingIds?.has(id),
1449
+ uploadError: env?.uploadErrors?.get(id),
1349
1450
  resolveUrl: env?.resolveUrl,
1350
1451
  id,
1351
1452
  error,
@@ -1359,8 +1460,10 @@ function FieldRenderer({ field, value, onChange, id, error, env }) {
1359
1460
  field,
1360
1461
  value,
1361
1462
  onChange,
1362
- onDropFile: (file) => env?.onDropFile?.(id, file),
1463
+ onDropFile: drop ? (file) => drop(id, file) : void 0,
1464
+ allowUrl: env?.allowUrl,
1363
1465
  uploading: env?.uploadingIds?.has(id),
1466
+ uploadError: env?.uploadErrors?.get(id),
1364
1467
  resolveUrl: env?.resolveUrl,
1365
1468
  id,
1366
1469
  error,
@@ -1405,20 +1508,84 @@ function FieldRenderer({ field, value, onChange, id, error, env }) {
1405
1508
  );
1406
1509
  }
1407
1510
  }
1511
+
1512
+ // src/react/absolute-url.ts
1513
+ function absoluteUrl(url) {
1514
+ const gated = viewableUrl(url);
1515
+ if (!gated?.startsWith("/") || typeof document === "undefined") return url;
1516
+ try {
1517
+ return new URL(gated, document.baseURI).href;
1518
+ } catch {
1519
+ return url;
1520
+ }
1521
+ }
1522
+
1523
+ // src/react/save-in-browser.ts
1524
+ function saveBlob(blob, filename) {
1525
+ const href = URL.createObjectURL(blob);
1526
+ const anchor = document.createElement("a");
1527
+ anchor.href = href;
1528
+ anchor.download = filename;
1529
+ document.body.appendChild(anchor);
1530
+ anchor.click();
1531
+ anchor.remove();
1532
+ setTimeout(() => URL.revokeObjectURL(href), 0);
1533
+ }
1534
+ function blobType(mimeType) {
1535
+ return /^(text\/|application\/json)/.test(mimeType) && !/charset=/i.test(mimeType) ? `${mimeType};charset=utf-8` : mimeType;
1536
+ }
1537
+ async function saveInBrowser(files) {
1538
+ const failed = [];
1539
+ for (const file of files) {
1540
+ try {
1541
+ if (file.text !== void 0) {
1542
+ saveBlob(new Blob([file.text], { type: blobType(file.mimeType) }), file.name);
1543
+ continue;
1544
+ }
1545
+ const response = await fetch(file.url);
1546
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1547
+ saveBlob(await response.blob(), file.name);
1548
+ } catch (error) {
1549
+ failed.push({ file, reason: error instanceof Error ? error.message : String(error) });
1550
+ }
1551
+ }
1552
+ return { failed };
1553
+ }
1408
1554
  var ResultEnvContext = createContext({});
1409
1555
  function ResultEnvProvider({
1410
1556
  resolveUrl,
1411
1557
  resolveShareUrl,
1412
1558
  proseImages,
1413
1559
  tableColumns,
1560
+ saveFiles,
1561
+ downloads,
1414
1562
  children
1415
1563
  }) {
1416
1564
  const env = useMemo(
1417
- () => ({ resolveUrl, resolveShareUrl, proseImages, tableColumns }),
1418
- [resolveUrl, resolveShareUrl, proseImages, tableColumns]
1565
+ () => ({ resolveUrl, resolveShareUrl, proseImages, tableColumns, saveFiles, downloads }),
1566
+ [resolveUrl, resolveShareUrl, proseImages, tableColumns, saveFiles, downloads]
1419
1567
  );
1420
1568
  return /* @__PURE__ */ jsx(ResultEnvContext, { value: env, children });
1421
1569
  }
1570
+ function DownloadDisplayOverride({
1571
+ downloads,
1572
+ children
1573
+ }) {
1574
+ const env = use(ResultEnvContext);
1575
+ const result = downloads?.result;
1576
+ const files = downloads?.files;
1577
+ const merged = useMemo(
1578
+ () => result === void 0 && files === void 0 ? env : {
1579
+ ...env,
1580
+ downloads: {
1581
+ result: result ?? env.downloads?.result,
1582
+ files: files ?? env.downloads?.files
1583
+ }
1584
+ },
1585
+ [env, result, files]
1586
+ );
1587
+ return /* @__PURE__ */ jsx(ResultEnvContext, { value: merged, children });
1588
+ }
1422
1589
  function useResolvedUrl(url) {
1423
1590
  return use(ResultEnvContext).resolveUrl?.(url) ?? url;
1424
1591
  }
@@ -1428,6 +1595,32 @@ function useResolveUrl() {
1428
1595
  function useResolveShareUrl() {
1429
1596
  return use(ResultEnvContext).resolveShareUrl;
1430
1597
  }
1598
+ function useSaveFiles() {
1599
+ const save = use(ResultEnvContext).saveFiles ?? saveInBrowser;
1600
+ return useCallback((files) => deliver(save, files), [save]);
1601
+ }
1602
+ async function deliver(save, files) {
1603
+ const handed = files.map(
1604
+ (file) => file.url === void 0 ? file : { ...file, url: absoluteUrl(file.url) }
1605
+ );
1606
+ try {
1607
+ const result = await save(handed);
1608
+ if (Array.isArray(result?.failed)) return { failed: result.failed };
1609
+ return {
1610
+ failed: handed.map((file) => ({ file, reason: "The save function reported no result" }))
1611
+ };
1612
+ } catch (error) {
1613
+ const reason = error instanceof Error ? error.message : String(error);
1614
+ return { failed: handed.map((file) => ({ file, reason })) };
1615
+ }
1616
+ }
1617
+ function useResultDownloadShown() {
1618
+ return use(ResultEnvContext).downloads?.result ?? true;
1619
+ }
1620
+ function useFileDownloadShown(kind) {
1621
+ const files = use(ResultEnvContext).downloads?.files ?? true;
1622
+ return typeof files === "boolean" ? files : files.includes(kind);
1623
+ }
1431
1624
  function useProseImages() {
1432
1625
  return use(ResultEnvContext).proseImages ?? "link";
1433
1626
  }
@@ -1437,6 +1630,27 @@ function useTableColumns() {
1437
1630
  if (budget === void 0 || Number.isNaN(budget)) return DEFAULT_TABLE_COLUMNS;
1438
1631
  return Math.max(1, Math.floor(budget));
1439
1632
  }
1633
+ var ResultLocationContext = createContext(void 0);
1634
+ function useResultLocation() {
1635
+ return use(ResultLocationContext);
1636
+ }
1637
+ function ResultRoot({
1638
+ baseName,
1639
+ path,
1640
+ children
1641
+ }) {
1642
+ const key = path.join("\0");
1643
+ const location = useMemo(() => ({ baseName, path: key.split("\0") }), [baseName, key]);
1644
+ return /* @__PURE__ */ jsx(ResultLocationContext, { value: location, children });
1645
+ }
1646
+ function ResultAt({ segment, children }) {
1647
+ const parent = use(ResultLocationContext);
1648
+ const location = useMemo(
1649
+ () => parent ? { baseName: parent.baseName, path: [...parent.path, segment] } : void 0,
1650
+ [parent, segment]
1651
+ );
1652
+ return /* @__PURE__ */ jsx(ResultLocationContext, { value: location, children });
1653
+ }
1440
1654
  var TooltipProvider = TooltipPrimitive.Provider;
1441
1655
  var TooltipRoot = TooltipPrimitive.Root;
1442
1656
  var TooltipTrigger = TooltipPrimitive.Trigger;
@@ -1959,15 +2173,12 @@ function CopyUrlButton({ url, storageUrl }) {
1959
2173
  };
1960
2174
  return /* @__PURE__ */ jsx(CopyButton, { provide, label: s.copyUrl, title: url });
1961
2175
  }
1962
- function absoluteUrl(url) {
1963
- if (!/^\/(?!\/)/.test(url) || typeof window === "undefined") return url;
1964
- return `${window.location.origin}${url}`;
1965
- }
1966
2176
  function FileRef({
1967
2177
  url,
1968
2178
  storageUrl,
1969
2179
  mimeType,
1970
- filename
2180
+ filename,
2181
+ file
1971
2182
  }) {
1972
2183
  const s = useFieldStrings();
1973
2184
  const encoded = encodedFileSummary(url, s);
@@ -1994,7 +2205,80 @@ function FileRef({
1994
2205
  children: label
1995
2206
  }
1996
2207
  ),
1997
- /* @__PURE__ */ jsx(CopyUrlButton, { url, ...storageUrl ? { storageUrl } : {} })
2208
+ /* @__PURE__ */ jsx(FileActions, { copy: { url, ...storageUrl ? { storageUrl } : {} }, file })
2209
+ ] });
2210
+ }
2211
+ function FileActions({
2212
+ copy,
2213
+ file
2214
+ }) {
2215
+ return /* @__PURE__ */ jsxs("span", { className: "flex shrink-0 items-center gap-0.5", children: [
2216
+ copy && /* @__PURE__ */ jsx(
2217
+ CopyUrlButton,
2218
+ {
2219
+ url: copy.url,
2220
+ ...copy.storageUrl ? { storageUrl: copy.storageUrl } : {}
2221
+ }
2222
+ ),
2223
+ file && /* @__PURE__ */ jsx(FileDownloadButton, { file })
2224
+ ] });
2225
+ }
2226
+ function useStuffFile(kind, value) {
2227
+ const location = useResultLocation();
2228
+ return location ? readStuffFile(kind, value, location.path.join(".")) : void 0;
2229
+ }
2230
+ function FileDownloadButton({ file }) {
2231
+ const s = useFieldStrings();
2232
+ const location = useResultLocation();
2233
+ const resolve = useResolveUrl();
2234
+ const save = useSaveFiles();
2235
+ const shown = useFileDownloadShown(file.kind);
2236
+ const statusId = useId();
2237
+ const [attempt, setAttempt] = useState(null);
2238
+ useEffect(() => {
2239
+ if (attempt?.state !== "saved") return;
2240
+ const timer = window.setTimeout(
2241
+ () => setAttempt((current) => current === attempt ? null : current),
2242
+ 1500
2243
+ );
2244
+ return () => window.clearTimeout(timer);
2245
+ }, [attempt]);
2246
+ if (!shown) return null;
2247
+ const planned = planFileSave(file, {
2248
+ baseName: location?.baseName ?? file.path,
2249
+ ...resolve ? { resolveUrl: resolve } : {}
2250
+ });
2251
+ if (!planned) return null;
2252
+ const identity = `${planned.name}
2253
+ ${planned.url ?? planned.text}`;
2254
+ const state = attempt?.identity === identity ? attempt.state : "idle";
2255
+ const failed = state === "failed";
2256
+ const label = s.downloadFile(planned.name);
2257
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2258
+ /* @__PURE__ */ jsx(
2259
+ "button",
2260
+ {
2261
+ type: "button",
2262
+ onClick: () => {
2263
+ const started = { identity, state: "saving" };
2264
+ setAttempt(started);
2265
+ void save([planned]).then((result) => {
2266
+ const settled = result.failed.length > 0 ? "failed" : "saved";
2267
+ setAttempt((current) => current === started ? { identity, state: settled } : current);
2268
+ });
2269
+ },
2270
+ disabled: state === "saving",
2271
+ "aria-label": label,
2272
+ title: failed ? s.downloadFileFailed : label,
2273
+ ...failed ? { "aria-describedby": statusId } : {},
2274
+ className: cn(
2275
+ "shrink-0 rounded p-0.5 hover:bg-card focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-1 disabled:opacity-60",
2276
+ failed ? "text-destructive" : "text-muted-foreground hover:text-foreground"
2277
+ ),
2278
+ children: state === "saving" ? /* @__PURE__ */ jsx(Loader2, { "aria-hidden": true, className: "size-3.5 animate-spin" }) : state === "saved" ? /* @__PURE__ */ jsx(Check, { "aria-hidden": true, className: "size-3.5" }) : failed ? /* @__PURE__ */ jsx(TriangleAlert, { "aria-hidden": true, className: "size-3.5" }) : /* @__PURE__ */ jsx(Download, { "aria-hidden": true, className: "size-3.5" })
2279
+ }
2280
+ ),
2281
+ /* @__PURE__ */ jsx("span", { id: statusId, role: "status", className: "sr-only", children: failed ? s.downloadFileFailed : "" })
1998
2282
  ] });
1999
2283
  }
2000
2284
  function paintable(content, resolve) {
@@ -2036,6 +2320,7 @@ function DocumentValue({ value }) {
2036
2320
  const s = useFieldStrings();
2037
2321
  const resolve = useResolveUrl();
2038
2322
  const [open, setOpen] = useState(false);
2323
+ const file = useStuffFile("document", value);
2039
2324
  const content = readDocumentContent(value);
2040
2325
  if (!content) return /* @__PURE__ */ jsx(Absent, {});
2041
2326
  const name = content.title ?? content.filename ?? fileLabel(content.url, content.filename, s);
@@ -2051,7 +2336,8 @@ function DocumentValue({ value }) {
2051
2336
  url: paintableUrl(content, resolve) ?? content.url,
2052
2337
  storageUrl: content.url,
2053
2338
  mimeType: content.mimeType,
2054
- ...content.filename ? { filename: content.filename } : {}
2339
+ ...content.filename ? { filename: content.filename } : {},
2340
+ file
2055
2341
  }
2056
2342
  ),
2057
2343
  content.snippet && /* @__PURE__ */ jsx("p", { className: "line-clamp-2 text-[12px] leading-relaxed text-muted-foreground", children: content.snippet })
@@ -2081,6 +2367,7 @@ function ImageValue({
2081
2367
  }) {
2082
2368
  const s = useFieldStrings();
2083
2369
  const resolve = useResolveUrl();
2370
+ const file = useStuffFile("image", value);
2084
2371
  const content = readImageContent(value);
2085
2372
  if (!content) return /* @__PURE__ */ jsx(Absent, {});
2086
2373
  const src = paintableUrl(content, resolve);
@@ -2131,7 +2418,8 @@ function ImageValue({
2131
2418
  {
2132
2419
  url: content.url,
2133
2420
  mimeType: content.mimeType,
2134
- ...content.filename ? { filename: content.filename } : {}
2421
+ ...content.filename ? { filename: content.filename } : {},
2422
+ file
2135
2423
  }
2136
2424
  ) })
2137
2425
  ] })
@@ -2156,7 +2444,8 @@ function ImageValue({
2156
2444
  {
2157
2445
  url: content.url,
2158
2446
  mimeType: content.mimeType,
2159
- ...content.filename ? { filename: content.filename } : {}
2447
+ ...content.filename ? { filename: content.filename } : {},
2448
+ file
2160
2449
  }
2161
2450
  ),
2162
2451
  src && !compact && /* @__PURE__ */ jsx("div", { className: inGallery ? "px-2.5" : void 0, children: /* @__PURE__ */ jsx(
@@ -2165,7 +2454,8 @@ function ImageValue({
2165
2454
  url: src,
2166
2455
  storageUrl: content.url,
2167
2456
  mimeType: content.mimeType,
2168
- ...content.filename ? { filename: content.filename } : {}
2457
+ ...content.filename ? { filename: content.filename } : {},
2458
+ file
2169
2459
  }
2170
2460
  ) }),
2171
2461
  content.caption && /* @__PURE__ */ jsx(
@@ -2368,7 +2658,7 @@ function ObjectTable({
2368
2658
  ] }) }),
2369
2659
  /* @__PURE__ */ jsx("tbody", { children: items.map((item, index) => {
2370
2660
  const isOpen = canExpand && expanded.has(index);
2371
- return /* @__PURE__ */ jsx(Fragment$1, { children: /* @__PURE__ */ jsxs("tr", { className: "border-b border-border/60 last:border-b-0", children: [
2661
+ return /* @__PURE__ */ jsx(ResultAt, { segment: String(index), children: /* @__PURE__ */ jsxs("tr", { className: "border-b border-border/60 last:border-b-0", children: [
2372
2662
  canExpand && /* @__PURE__ */ jsx("td", { className: "px-1 pt-px align-top", children: /* @__PURE__ */ jsx(
2373
2663
  "button",
2374
2664
  {
@@ -2405,7 +2695,7 @@ function ObjectTable({
2405
2695
  column === name ? "max-w-none min-w-[16ch] wrap-anywhere [&>span]:whitespace-normal" : column.kind === "list" && isInlineColumn(column) ? "min-w-[16ch]" : "truncate"
2406
2696
  ),
2407
2697
  ...typeof cell === "string" || typeof cell === "number" ? { title: cellTitle(column, cell, presentation) } : {},
2408
- children: /* @__PURE__ */ jsx(LeafValue, { field: column, value: cell, compact: true })
2698
+ children: /* @__PURE__ */ jsx(ResultAt, { segment: column.name, children: /* @__PURE__ */ jsx(LeafValue, { field: column, value: cell, compact: true }) })
2409
2699
  }
2410
2700
  ) }, column.name);
2411
2701
  })
@@ -2466,10 +2756,10 @@ function ImageGallery({ items }) {
2466
2756
  return content ? !!(viewableUrl(content.publicUrl) ?? viewableUrl(content.url)) : false;
2467
2757
  });
2468
2758
  if (!anyViewable) return /* @__PURE__ */ jsx(FileRows, { items, kind: "image" });
2469
- return /* @__PURE__ */ jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2", children: items.map((item, index) => /* @__PURE__ */ jsx("div", { className: "overflow-hidden rounded-lg border border-border bg-card/40", children: /* @__PURE__ */ jsx(ImageValue, { value: item, inGallery: true }) }, index)) });
2759
+ return /* @__PURE__ */ jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2", children: items.map((item, index) => /* @__PURE__ */ jsx("div", { className: "overflow-hidden rounded-lg border border-border bg-card/40", children: /* @__PURE__ */ jsx(ResultAt, { segment: String(index), children: /* @__PURE__ */ jsx(ImageValue, { value: item, inGallery: true }) }) }, index)) });
2470
2760
  }
2471
2761
  function FileRows({ items, kind }) {
2472
- return /* @__PURE__ */ jsx("div", { className: "divide-y divide-border overflow-hidden rounded-lg border border-border", children: items.map((item, index) => /* @__PURE__ */ jsx("div", { className: "bg-card/40 px-3.5 py-2", children: kind === "document" ? /* @__PURE__ */ jsx(DocumentValue, { value: item }) : /* @__PURE__ */ jsx(ImageValue, { value: item, inRow: true }) }, index)) });
2762
+ return /* @__PURE__ */ jsx("div", { className: "divide-y divide-border overflow-hidden rounded-lg border border-border", children: items.map((item, index) => /* @__PURE__ */ jsx("div", { className: "bg-card/40 px-3.5 py-2", children: /* @__PURE__ */ jsx(ResultAt, { segment: String(index), children: kind === "document" ? /* @__PURE__ */ jsx(DocumentValue, { value: item }) : /* @__PURE__ */ jsx(ImageValue, { value: item, inRow: true }) }) }, index)) });
2473
2763
  }
2474
2764
  function recordColumns(item) {
2475
2765
  if (item.kind !== "object" || item.fields.length === 0) return void 0;
@@ -2496,9 +2786,16 @@ function chooseColumns(fields, budget) {
2496
2786
  ...name ? { name } : {}
2497
2787
  };
2498
2788
  }
2499
- function ResultField({ field, value, depth = 0, hideLabel = false }) {
2789
+ function ResultField(props) {
2790
+ const location = useResultLocation();
2791
+ if (location) return /* @__PURE__ */ jsx(ResultNode, { ...props });
2792
+ return /* @__PURE__ */ jsx(ResultRoot, { baseName: props.field.name, path: [props.field.name], children: /* @__PURE__ */ jsx(ResultNode, { ...props }) });
2793
+ }
2794
+ function ResultNode({ field, value, depth = 0, hideLabel = false }) {
2500
2795
  const s = useFieldStrings();
2501
2796
  const unwrapped = unwrap(field, value);
2797
+ const pageDownloadShown = useFileDownloadShown("markup");
2798
+ const location = useResultLocation();
2502
2799
  const header = hideLabel ? null : /* @__PURE__ */ jsx(Label2, { field, describe: true });
2503
2800
  const copyText = (field.kind === "text" || field.kind === "prose") && typeof unwrapped === "string" && unwrapped !== "" ? unwrapped : void 0;
2504
2801
  if (isNativeDateNode(field)) {
@@ -2509,8 +2806,12 @@ function ResultField({ field, value, depth = 0, hideLabel = false }) {
2509
2806
  }
2510
2807
  if (isNativeHtmlNode(field)) {
2511
2808
  const content = readHtmlContent(unwrapped);
2809
+ const page = pageDownloadShown && location ? readStuffFile("markup", unwrapped, location.path.join(".")) : void 0;
2512
2810
  return /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
2513
- header,
2811
+ page ? /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-2", children: [
2812
+ /* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1", children: header }),
2813
+ /* @__PURE__ */ jsx(FileActions, { file: page })
2814
+ ] }) : header,
2514
2815
  content ? /* @__PURE__ */ jsx(HtmlPreview, { content }) : /* @__PURE__ */ jsx(Absent, {})
2515
2816
  ] });
2516
2817
  }
@@ -2558,7 +2859,7 @@ function ResultField({ field, value, depth = 0, hideLabel = false }) {
2558
2859
  isInlineColumn(child) ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
2559
2860
  /* @__PURE__ */ jsx("div", { className: "min-w-0", children: /* @__PURE__ */ jsx(Label2, { field: child, describe: true }) }),
2560
2861
  /* @__PURE__ */ jsx("div", { className: "flex min-w-0 justify-end **:data-chips:justify-end", children: /* @__PURE__ */ jsx("div", { className: "min-w-0 text-left wrap-break-word", children: /* @__PURE__ */ jsx(LeafValue, { field: child, value: memberValue(child) }) }) })
2561
- ] }, child.name) : /* @__PURE__ */ jsx("div", { className: "col-span-2 min-w-0", children: /* @__PURE__ */ jsx(ResultField, { field: child, value: memberValue(child), depth: depth + 1 }) }, child.name)
2862
+ ] }, child.name) : /* @__PURE__ */ jsx("div", { className: "col-span-2 min-w-0", children: /* @__PURE__ */ jsx(ResultAt, { segment: child.name, children: /* @__PURE__ */ jsx(ResultField, { field: child, value: memberValue(child), depth: depth + 1 }) }) }, child.name)
2562
2863
  )
2563
2864
  )
2564
2865
  }
@@ -2580,7 +2881,7 @@ function ResultField({ field, value, depth = 0, hideLabel = false }) {
2580
2881
  // date's `date` and `time`, print a page's markup source down a
2581
2882
  // column named by it, and show a composite as JSON. The arm keyed by
2582
2883
  // concept is the one that knows what each entry is.
2583
- /* @__PURE__ */ jsx("div", { className: "divide-y divide-border/60 overflow-hidden rounded-lg border border-border", children: items.map((item, index) => /* @__PURE__ */ jsx("div", { className: "bg-card/40 px-3 py-1.5", children: /* @__PURE__ */ jsx(ResultField, { field: field.item, value: item, depth: depth + 1, hideLabel: true }) }, index)) })
2884
+ /* @__PURE__ */ jsx("div", { className: "divide-y divide-border/60 overflow-hidden rounded-lg border border-border", children: items.map((item, index) => /* @__PURE__ */ jsx("div", { className: "bg-card/40 px-3 py-1.5", children: /* @__PURE__ */ jsx(ResultAt, { segment: String(index), children: /* @__PURE__ */ jsx(ResultField, { field: field.item, value: item, depth: depth + 1, hideLabel: true }) }) }, index)) })
2584
2885
  ) : columns ? (
2585
2886
  // Same shape every row: the labels are column headers, not per-row
2586
2887
  // labels. See `ObjectTable`.
@@ -2615,7 +2916,7 @@ function ResultField({ field, value, depth = 0, hideLabel = false }) {
2615
2916
  className: "space-y-2 rounded-lg border border-border bg-card/40 px-3.5 py-3",
2616
2917
  children: [
2617
2918
  /* @__PURE__ */ jsx("span", { className: "font-mono text-[10px] text-muted-foreground", children: index + 1 }),
2618
- /* @__PURE__ */ jsx(ResultField, { field: field.item, value: item, depth: depth + 1, hideLabel: true })
2919
+ /* @__PURE__ */ jsx(ResultAt, { segment: String(index), children: /* @__PURE__ */ jsx(ResultField, { field: field.item, value: item, depth: depth + 1, hideLabel: true }) })
2619
2920
  ]
2620
2921
  },
2621
2922
  index
@@ -2642,67 +2943,6 @@ function ResultField({ field, value, depth = 0, hideLabel = false }) {
2642
2943
  }
2643
2944
  return null;
2644
2945
  }
2645
-
2646
- // src/react/download-stuff.ts
2647
- function saveBlob(blob, filename) {
2648
- const href = URL.createObjectURL(blob);
2649
- const anchor = document.createElement("a");
2650
- anchor.href = href;
2651
- anchor.download = filename;
2652
- document.body.appendChild(anchor);
2653
- anchor.click();
2654
- anchor.remove();
2655
- setTimeout(() => URL.revokeObjectURL(href), 0);
2656
- }
2657
- function mimeForExtension(extension) {
2658
- return extension === "html" ? "text/html;charset=utf-8" : "text/plain;charset=utf-8";
2659
- }
2660
- function extensionOf(url, filename) {
2661
- const named = filename?.match(/\.([A-Za-z0-9]{1,8})$/)?.[1];
2662
- if (named) return named.toLowerCase();
2663
- const path = url.split(/[?#]/)[0] ?? url;
2664
- const match = path.match(/\.([A-Za-z0-9]{1,8})$/)?.[1];
2665
- return match ? match.toLowerCase() : "";
2666
- }
2667
- function fileNameFor(file, base) {
2668
- if (file.filename) return file.filename;
2669
- const extension = file.extension ?? extensionOf(file.url ?? "", file.filename);
2670
- const stem = `${base}-${file.path.replace(/\./g, "-")}`;
2671
- return extension ? `${stem}.${extension}` : stem;
2672
- }
2673
- async function downloadStuff({
2674
- field,
2675
- value,
2676
- baseName,
2677
- resolveUrl
2678
- }) {
2679
- const files = collectStuffFiles(field, value);
2680
- const isBareFile = files.length === 1 && files[0]?.path === field.name;
2681
- let deliveredAFile = false;
2682
- for (const file of files) {
2683
- const name = fileNameFor(file, baseName);
2684
- if (file.text !== void 0) {
2685
- saveBlob(new Blob([file.text], { type: mimeForExtension(file.extension) }), name);
2686
- deliveredAFile = true;
2687
- continue;
2688
- }
2689
- const href = viewableUrl(resolveUrl?.(file.url ?? "")) ?? viewableUrl(file.publicUrl) ?? viewableUrl(file.url);
2690
- if (!href) continue;
2691
- try {
2692
- const response = await fetch(href);
2693
- if (!response.ok) throw new Error(String(response.status));
2694
- saveBlob(await response.blob(), name);
2695
- } catch {
2696
- window.open(href, "_blank", "noopener");
2697
- }
2698
- deliveredAFile = true;
2699
- }
2700
- if (isBareFile && deliveredAFile) return;
2701
- saveBlob(
2702
- new Blob([JSON.stringify(value, null, 2)], { type: "application/json;charset=utf-8" }),
2703
- `${baseName}.json`
2704
- );
2705
- }
2706
2946
  var JSON_TOKEN_RE = /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|(\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
2707
2947
  function highlight(json) {
2708
2948
  const out = [];
@@ -2762,33 +3002,73 @@ function JsonView({ value }) {
2762
3002
  /* @__PURE__ */ jsx("pre", { className: "overflow-x-auto rounded-lg border border-border bg-card/40 px-3.5 py-3 pr-12 font-mono text-[12px] leading-relaxed", children: highlight(text) })
2763
3003
  ] });
2764
3004
  }
2765
- function StuffViewer({
3005
+ function isShownResult(attempt, field, baseName, value) {
3006
+ if (attempt.field === field && attempt.baseName === baseName && attempt.value === value) {
3007
+ return true;
3008
+ }
3009
+ return attempt.key !== void 0 && attempt.key === resultKey(field, baseName, value);
3010
+ }
3011
+ function resultKey(field, baseName, value) {
3012
+ try {
3013
+ return JSON.stringify(
3014
+ [baseName, field, value],
3015
+ (_key, member) => typeof member === "bigint" ? `${member}n` : member
3016
+ );
3017
+ } catch {
3018
+ return void 0;
3019
+ }
3020
+ }
3021
+ function StuffViewer({ downloads, ...props }) {
3022
+ return /* @__PURE__ */ jsx(DownloadDisplayOverride, { downloads, children: /* @__PURE__ */ jsx(StuffPanel, { ...props }) });
3023
+ }
3024
+ function StuffPanel({
2766
3025
  field,
2767
3026
  value,
2768
3027
  name,
2769
3028
  defaultView = "rendered",
2770
3029
  downloadBaseName,
2771
- hideDownload = false,
2772
3030
  className
2773
3031
  }) {
2774
3032
  const s = useFieldStrings();
2775
3033
  const named = name ? { ...field, name } : field;
3034
+ const baseName = downloadBaseName ?? named.name ?? "result";
2776
3035
  const [view, setView] = useState(defaultView);
2777
- const [saving, setSaving] = useState(false);
3036
+ const [saving, setSaving] = useState(null);
3037
+ const [missed, setMissed] = useState(null);
3038
+ const latest = useRef(null);
3039
+ const savingShown = saving !== null && isShownResult(saving, field, baseName, value);
3040
+ const missedShown = missed && isShownResult(missed.attempt, field, baseName, value) ? missed.names : [];
2778
3041
  const resolveUrl = useResolveUrl();
3042
+ const save = useSaveFiles();
3043
+ const downloadShown = useResultDownloadShown();
2779
3044
  const handleDownload = useCallback(async () => {
2780
- setSaving(true);
3045
+ const attempt = {
3046
+ field,
3047
+ baseName,
3048
+ value,
3049
+ key: resultKey(field, baseName, value)
3050
+ };
3051
+ latest.current = attempt;
3052
+ setSaving(attempt);
3053
+ setMissed(null);
2781
3054
  try {
2782
- await downloadStuff({
2783
- field,
2784
- value,
2785
- baseName: downloadBaseName ?? named.name ?? "result",
2786
- resolveUrl
3055
+ const plan = planStuffSave(field, value, {
3056
+ baseName,
3057
+ ...resolveUrl ? { resolveUrl } : {}
3058
+ });
3059
+ const { failed } = await save(plan.files);
3060
+ if (latest.current !== attempt) return;
3061
+ setMissed({
3062
+ attempt,
3063
+ names: [
3064
+ ...plan.unavailable.map((file) => file.name),
3065
+ ...failed.map((failure) => failure.file.name)
3066
+ ]
2787
3067
  });
2788
3068
  } finally {
2789
- setSaving(false);
3069
+ setSaving((current) => current === attempt ? null : current);
2790
3070
  }
2791
- }, [field, named.name, value, downloadBaseName, resolveUrl]);
3071
+ }, [field, value, baseName, resolveUrl, save]);
2792
3072
  const views = [
2793
3073
  { id: "rendered", label: s.viewRendered },
2794
3074
  { id: "json", label: s.viewJson }
@@ -2797,17 +3077,17 @@ function StuffViewer({
2797
3077
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-start justify-between gap-x-4 gap-y-1", children: [
2798
3078
  /* @__PURE__ */ jsx("div", { className: "min-w-0 space-y-1", children: /* @__PURE__ */ jsx(ResultHeader, { field: named }) }),
2799
3079
  /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [
2800
- !hideDownload && /* @__PURE__ */ jsxs(
3080
+ downloadShown && /* @__PURE__ */ jsxs(
2801
3081
  "button",
2802
3082
  {
2803
3083
  type: "button",
2804
3084
  onClick: () => void handleDownload(),
2805
- disabled: saving,
3085
+ disabled: savingShown,
2806
3086
  "aria-label": s.download,
2807
3087
  className: "inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-1 disabled:opacity-60",
2808
3088
  children: [
2809
- saving ? /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ jsx(Download, { className: "h-3.5 w-3.5" }),
2810
- saving ? s.downloading : s.download
3089
+ savingShown ? /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ jsx(Download, { className: "h-3.5 w-3.5" }),
3090
+ savingShown ? s.downloading : s.download
2811
3091
  ]
2812
3092
  }
2813
3093
  ),
@@ -2835,10 +3115,18 @@ function StuffViewer({
2835
3115
  )
2836
3116
  ] })
2837
3117
  ] }),
2838
- view === "json" ? /* @__PURE__ */ jsx(JsonView, { value }) : /* @__PURE__ */ jsx(ResultField, { field, value, hideLabel: true })
3118
+ downloadShown && /* @__PURE__ */ jsx(
3119
+ "p",
3120
+ {
3121
+ role: "status",
3122
+ className: cn("text-[12px] text-destructive", missedShown.length === 0 && "sr-only"),
3123
+ children: missedShown.length > 0 ? s.downloadIncomplete(missedShown) : ""
3124
+ }
3125
+ ),
3126
+ view === "json" ? /* @__PURE__ */ jsx(JsonView, { value }) : /* @__PURE__ */ jsx(ResultRoot, { baseName, path: [field.name], children: /* @__PURE__ */ jsx(ResultField, { field, value, hideLabel: true }) })
2839
3127
  ] });
2840
3128
  }
2841
3129
 
2842
- export { BooleanField, ConceptPill, DEFAULT_FIELD_STRINGS, DateField, DocumentField, EnumField, FieldDomIdProvider, FieldPresentationProvider, FieldRenderer, FieldShell, FieldStringsProvider, HtmlPreview, ImageField, JsonView, ListField, Markdown, NumberField, ObjectField, OptionalToggle, ProseField, ResultEnvProvider, ResultField, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, StuffViewer, Switch, TextField, ToggleGroup, ToggleGroupItem, UnknownField, cn, downloadStuff, fieldControlClass, humanizeFieldName, useFieldDomId, useFieldPresentation, useFieldStrings, useProseImages, useResolveShareUrl, useResolveUrl, useResolvedUrl };
2843
- //# sourceMappingURL=chunk-W56WIIVC.js.map
2844
- //# sourceMappingURL=chunk-W56WIIVC.js.map
3130
+ export { BooleanField, ConceptPill, DEFAULT_FIELD_STRINGS, DateField, DocumentField, EnumField, FieldDomIdProvider, FieldPresentationProvider, FieldRenderer, FieldShell, FieldStringsProvider, HtmlPreview, ImageField, JsonView, ListField, Markdown, NumberField, ObjectField, OptionalToggle, ProseField, ResultEnvProvider, ResultField, ResultRoot, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, StuffViewer, Switch, TextField, ToggleGroup, ToggleGroupItem, UnknownField, cn, fieldControlClass, humanizeFieldName, saveInBrowser, useFieldDomId, useFieldPresentation, useFieldStrings, useProseImages, useResolveShareUrl, useResolveUrl, useResolvedUrl };
3131
+ //# sourceMappingURL=chunk-4KX6YTLW.js.map
3132
+ //# sourceMappingURL=chunk-4KX6YTLW.js.map