@hachej/boring-ui-kit 0.4.13 → 0.4.14

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.
Files changed (57) hide show
  1. package/README.md +12 -2
  2. package/bin/boring-ui-blocks.mjs +8 -1
  3. package/dist/file-tree-view.d.ts +48 -3
  4. package/dist/file-tree-view.js +202 -12
  5. package/dist/file-tree.d.ts +31 -0
  6. package/dist/file-tree.js +122 -1
  7. package/dist/voice-capture.d.ts +10 -0
  8. package/dist/voice-capture.js +235 -39
  9. package/package.json +2 -2
  10. package/r/agent-target.json +4 -4
  11. package/r/app-shell.json +9 -9
  12. package/r/board-viewer.json +5 -5
  13. package/r/button.json +1 -1
  14. package/r/diagram-viewer.json +1 -1
  15. package/r/dock.json +1 -1
  16. package/r/documents.json +13 -13
  17. package/r/feedback-preview.json +1 -1
  18. package/r/feedback.json +6 -6
  19. package/r/file-tree.json +8 -7
  20. package/r/highlights.json +3 -3
  21. package/r/html-viewer.json +3 -3
  22. package/r/looks-viewer.json +1 -1
  23. package/r/maquette-viewer.json +2 -2
  24. package/r/markdown-editor.json +3 -3
  25. package/r/mockup-viewer.json +1 -1
  26. package/r/pi-ambient.json +9 -9
  27. package/r/pi-app.json +21 -21
  28. package/r/pi-artifacts.json +1 -1
  29. package/r/pi-chat-approval.json +1 -1
  30. package/r/pi-chat-attachments.json +1 -1
  31. package/r/pi-chat-markdown.json +1 -1
  32. package/r/pi-chat-mentions.json +1 -1
  33. package/r/pi-chat-pickers.json +1 -1
  34. package/r/pi-chat-plugins.json +11 -11
  35. package/r/pi-chat-question.json +1 -1
  36. package/r/pi-chat-slash.json +1 -1
  37. package/r/pi-chat-subagent.json +1 -1
  38. package/r/pi-chat-voice.json +3 -3
  39. package/r/pi-chat.json +5 -5
  40. package/r/pi-workspace.json +4 -4
  41. package/r/placed-agent.json +4 -4
  42. package/r/provider-setup.json +2 -2
  43. package/r/record-viewer.json +1 -1
  44. package/r/registry.json +167 -166
  45. package/r/requests-board.json +1 -1
  46. package/r/shared-chat.json +6 -6
  47. package/r/task-list-viewer.json +2 -2
  48. package/r/version-history.json +3 -3
  49. package/r/viewer-code.json +2 -2
  50. package/r/viewer-frame.json +5 -5
  51. package/r/viewer-html.json +4 -4
  52. package/r/viewer-image.json +2 -2
  53. package/r/viewer-markdown.json +5 -5
  54. package/r/viewer-pdf.json +3 -3
  55. package/r/viewers.json +6 -6
  56. package/r/windows.json +1 -1
  57. package/styles/file-tree.css +13 -0
package/README.md CHANGED
@@ -78,6 +78,8 @@ The default schema includes headings, lists, task lists, quotes, code and text m
78
78
 
79
79
  `createVoiceCapture` from `@hachej/boring-ui-kit/voice-capture` records browser audio without React, files or Pi. Call `start()` from a person's gesture. It feature-detects microphone access and returns a recording or a typed refusal. `stop()` returns the Blob, recorded MIME type, page-clock sample times and `truncated`; `cancel()` discards audio and releases only this capture's microphone tracks, including a permission request that finishes late.
80
80
 
81
+ Where the page has Web Audio, the recording is a 16 kHz mono 16-bit WAV (`audio/wav`, 32 kB/s), not MediaRecorder's webm: a webm carries the browser's clock, and one whose timestamps jumped was refused by every OpenAI transcription model. `start()` resolves `recording` once the first sound arrives, so a host showing "recording" never loses the first words; no sound within `firstSoundMs` (5 s) refuses as `busy`. A context the browser keeps suspended (no user activation, e.g. a hold begun on `touchstart`) records that take with MediaRecorder as before; `audioContext: null` always does. `encodeWav` and `downsample` are exported.
82
+
81
83
  `maxMs` caps capture at five minutes by default. Reaching the cap stops the microphone; callers still choose when to consume the recording through `stop()`. Transcription and persistence remain host capabilities. The existing `@hachej/boring-feedback/page` capture API re-exports this implementation unchanged. The headless root imports neither entry.
82
84
 
83
85
  The [registry voice-message guide](../../registry/README.md#voice-messages-in-pi-chat) describes PiChat's optional hold-to-send composition. Public capture behavior runs in `test/packages/ui.test.mjs`, compatibility in `test/packages/feedback-voice.test.mjs`, and dependency-free installation in `test/scaffold/isolated-ui.test.mjs`.
@@ -390,8 +392,16 @@ The default `list` variant has a Library header, visible-item count, inset searc
390
392
  visible items, including folders; it is not a workspace total. A folder shows its
391
393
  child count only after its provider listing is complete; unloaded, loading, failed
392
394
  or paginated listings show no count. The menu offers
393
- Open, Version history and Download through the same provider. Rename and Delete
394
- are absent because `RevisionProvider` exposes neither operation. No edit date or
395
+ Open, Version history and Download through the same provider. When the client has
396
+ `rename`, files also offer Rename (inline in the row, F2) and Move to… (plus dragging a
397
+ file onto a folder or the root); when it has `delete`, Delete (Delete key, after a
398
+ confirmation dialog). `capabilities={{ effective }}`, when the host passes it, only
399
+ narrows these to the listed `rename`/`delete`. Each change is a conditional write with the
400
+ revision read when the action started (`controller.prepare`); a refusal stays on the
401
+ row until dismissed and the affected folders are read again. A committed change calls
402
+ `onRenamed(from, to)` or `onDeleted(path)`, so a host's viewer follows the file or closes
403
+ (`AgentWorkspace` and `Documents` do). Folders are destinations
404
+ only. No edit date or
395
405
  author is invented or fetched per row. `variant="plain"` keeps the compact tree.
396
406
  `title` overrides the list heading; `onClose` adds a host-owned close button.
397
407
 
@@ -6,6 +6,7 @@
6
6
  // npx boring-ui-blocks update [--version <v>] [--registry <url>] [--dry-run] [--manifest <path>] [--cwd <dir>]
7
7
  // npx boring-ui-blocks add <shadcn add arguments...> [--manifest <path>] [--cwd <dir>]
8
8
  // npx boring-ui-blocks manifest [--manifest <path>] [--cwd <dir>]
9
+ // npx boring-ui-blocks help (also --help: the usage and which items to add; exit 0)
9
10
  //
10
11
  // `check` writes nothing. `add`, `update` and `manifest` write the shadcn files they install and the manifest, nothing else.
11
12
  import { createHash } from 'node:crypto';
@@ -214,7 +215,12 @@ export function check(app, design = [], manifest = MANIFEST) {
214
215
  return { version, blocks: blocks.map(block => block.name), violations };
215
216
  }
216
217
 
217
- const USAGE = 'usage: boring-ui-blocks check [--design <dir>...] [--manifest <path>] [--cwd <dir>] [--json] | update [--version <v>] [--registry <url>] [--dry-run] [--manifest <path>] [--cwd <dir>] | add <shadcn add args...> [--manifest <path>] [--cwd <dir>] | manifest [--manifest <path>] [--cwd <dir>]';
218
+ const USAGE = `usage: boring-ui-blocks check [--design <dir>...] [--manifest <path>] [--cwd <dir>] [--json] | update [--version <v>] [--registry <url>] [--dry-run] [--manifest <path>] [--cwd <dir>] | add <shadcn add args...> [--manifest <path>] [--cwd <dir>] | manifest [--manifest <path>] [--cwd <dir>] | help\n\n` +
219
+ 'Blocks: pi-app is the whole agent app with every chat feature and viewer. pi-chat is the core chat only: add pi-chat-plugins for every ' +
220
+ 'feature, or one of pi-chat-markdown, pi-chat-slash, pi-chat-mentions, pi-chat-pickers, pi-chat-voice, pi-chat-attachments, ' +
221
+ 'pi-chat-question, pi-chat-approval, pi-chat-subagent and pi-artifacts. Viewers are viewer-frame plus viewer-markdown, viewer-html, ' +
222
+ 'viewer-image, viewer-pdf and viewer-code, loaded by kind. update reinstalls only installed items: ' +
223
+ 'add the new ones an app uses once (registry/README.md, "From one item to small items").';
218
224
  const FLAGS = { check: ['design', 'manifest', 'json'], update: ['version', 'registry', 'dry-run', 'manifest'], manifest: ['manifest'] };
219
225
 
220
226
  function parse(command, rest) {
@@ -274,6 +280,7 @@ const line = v => `${v.check} ${v.rule} ${v.file}${v.line ? `:${v.line}:${v.colu
274
280
 
275
281
  export function main(argv, cwd = process.cwd(), log = console.log, error = console.error) {
276
282
  const [command, ...rest] = argv;
283
+ if (['help', '--help', '-h'].includes(command)) { log(USAGE); return 0; }
277
284
  let flags;
278
285
  try {
279
286
  if (!['check', 'update', 'add', 'manifest'].includes(command)) throw new Error(command ? `unknown command ${command}` : 'missing command');
@@ -50,6 +50,22 @@ export declare const defaultFileTreeLabels: {
50
50
  tooLarge: string;
51
51
  uploadProgress: (name: string) => string;
52
52
  cancelUpload: (name: string) => string;
53
+ rename: string;
54
+ renameOf: (path: string) => string;
55
+ move: string;
56
+ moveTitle: (name: string) => string;
57
+ moveDescription: string;
58
+ root: string;
59
+ delete: string;
60
+ deleteTitle: (name: string) => string;
61
+ deleteDescription: string;
62
+ cancel: string;
63
+ changing: string;
64
+ renamed: (to: string) => string;
65
+ deleted: string;
66
+ changedSince: string;
67
+ dismiss: (path: string) => string;
68
+ invalidName: string;
53
69
  };
54
70
  export type FileTreeLabels = typeof defaultFileTreeLabels;
55
71
  /** The tree's words in French (`locale="fr"`). */
@@ -57,10 +73,14 @@ export declare const frenchFileTreeLabels: FileTreeLabels;
57
73
  /** The languages the tree ships with; anything else is set word by word through `labels`. */
58
74
  export type FileTreeLocale = 'en' | 'fr';
59
75
  export interface FileTreeAction {
60
- readonly id: 'open' | 'history' | 'download';
76
+ readonly id: 'open' | 'history' | 'download' | 'rename' | 'move' | 'delete';
61
77
  readonly label: string;
62
78
  readonly icon: ReactNode;
63
79
  readonly onSelect: () => void;
80
+ /** Shown as a destructive item (Delete). */
81
+ readonly destructive?: boolean;
82
+ /** The action moves focus itself (an inline input or a dialog): the menu must not return focus to its trigger on close. */
83
+ readonly ownsFocus?: boolean;
64
84
  }
65
85
  export interface FileTreeActionsProps {
66
86
  readonly label: string;
@@ -68,7 +88,7 @@ export interface FileTreeActionsProps {
68
88
  readonly items: readonly FileTreeAction[];
69
89
  }
70
90
  export type FileTreeButtonProps = ComponentProps<'button'> & {
71
- readonly variant?: 'secondary' | 'ghost';
91
+ readonly variant?: 'secondary' | 'ghost' | 'outline' | 'destructive';
72
92
  readonly size?: 'default' | 'sm' | 'icon';
73
93
  };
74
94
  export interface FileTreeVersionsProps {
@@ -78,6 +98,16 @@ export interface FileTreeVersionsProps {
78
98
  readonly locale: FileTreeLocale;
79
99
  readonly earlierVersion: string;
80
100
  }
101
+ /** A modal dialog: the delete confirmation and the Move to… folder list. */
102
+ export interface FileTreeDialogProps {
103
+ readonly open: boolean;
104
+ readonly onOpenChange: (open: boolean) => void;
105
+ readonly title: ReactNode;
106
+ readonly description?: ReactNode;
107
+ readonly children?: ReactNode;
108
+ /** The dialog's buttons. */
109
+ readonly footer: ReactNode;
110
+ }
81
111
  /** Host presentation. The registry supplies real shadcn Base UI controls and shared version rows; no primitive fallback. */
82
112
  export interface FileTreeComponents {
83
113
  readonly Button: (props: FileTreeButtonProps) => ReactNode;
@@ -108,6 +138,12 @@ export interface FileTreeComponents {
108
138
  readonly value: number | null;
109
139
  readonly 'aria-label': string;
110
140
  }) => ReactNode;
141
+ /** The host's shadcn `Dialog`. Without it the tree offers neither Delete nor Move to… (dragging a file onto a folder still moves it). */
142
+ readonly Dialog?: (props: FileTreeDialogProps) => ReactNode;
143
+ }
144
+ /** The workspace's effective capabilities (`ResourceCapabilities.effective`) as the host read them. */
145
+ export interface FileTreeCapabilities {
146
+ readonly effective: readonly string[];
111
147
  }
112
148
  /** How far an upload through `uploadBlob` is. */
113
149
  export interface UploadProgress {
@@ -156,6 +192,15 @@ export interface FileTreeProps {
156
192
  readonly tooLarge?: string;
157
193
  /** The host's large-file upload (see `UploadBlob`), used above `maxBytes`. */
158
194
  readonly uploadBlob?: UploadBlob;
195
+ /**
196
+ * Narrows the file actions to what this person may do. The client's `rename` turns on Rename and Move to…, its `delete` turns on
197
+ * Delete; when given, only the listed `rename`/`delete` stay. Omitted: whatever the client has.
198
+ */
199
+ readonly capabilities?: FileTreeCapabilities;
200
+ /** A rename or move from the tree committed: `from` is now `to` (a host showing `from` follows it). */
201
+ readonly onRenamed?: (from: string, to: string) => void;
202
+ /** A delete from the tree committed (a host showing `path` closes it). */
203
+ readonly onDeleted?: (path: string) => void;
159
204
  }
160
205
  /** One borrowed binding powers browsing, search, upload and history. Historical previews never replace an editor's draft. */
161
206
  export declare function FileTree(props: FileTreeProps): import("react").JSX.Element;
@@ -163,5 +208,5 @@ export interface FileTreeViewProps extends Omit<FileTreeProps, 'revisionProvider
163
208
  readonly controller: FileTreeController;
164
209
  }
165
210
  /** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
166
- export declare function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refresh, variant, title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob }: FileTreeViewProps): import("react").JSX.Element;
211
+ export declare function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refresh, variant, title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob, capabilities, onRenamed, onDeleted }: FileTreeViewProps): import("react").JSX.Element;
167
212
  //# sourceMappingURL=file-tree-view.d.ts.map
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { ChevronRight, Download, File, FileCode, FileImage, FileSpreadsheet, FileText, Folder, FolderOpen, History, LoaderCircle, MoreHorizontal, Search, Upload, X } from 'lucide-react';
3
+ import { ChevronRight, Download, File, FileCode, FileImage, FileSpreadsheet, FileText, Folder, FolderInput, FolderOpen, History, LoaderCircle, MoreHorizontal, Pencil, Search, Trash2, Upload, X } from 'lucide-react';
4
4
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
5
5
  import { createFileTreeController } from './file-tree.js';
6
6
  /** Every word of the tree, so a host can translate it. Functions receive what they name. */
@@ -51,6 +51,22 @@ export const defaultFileTreeLabels = {
51
51
  tooLarge: 'The file is too large to upload.',
52
52
  uploadProgress: (name) => `Uploading ${name}`,
53
53
  cancelUpload: (name) => `Cancel uploading ${name}`,
54
+ rename: 'Rename',
55
+ renameOf: (path) => `Rename ${path}`,
56
+ move: 'Move to…',
57
+ moveTitle: (name) => `Move ${name}`,
58
+ moveDescription: 'Choose the folder it goes in.',
59
+ root: 'Workspace root',
60
+ delete: 'Delete',
61
+ deleteTitle: (name) => `Delete ${name}?`,
62
+ deleteDescription: 'It is removed from this workspace for everyone who uses it.',
63
+ cancel: 'Cancel',
64
+ changing: 'Saving…',
65
+ renamed: (to) => `Renamed to ${to}`,
66
+ deleted: 'Deleted',
67
+ changedSince: 'It changed since you opened it, so nothing was changed. Try again.',
68
+ dismiss: (path) => `Dismiss the message for ${path}`,
69
+ invalidName: 'A name cannot contain /. Use Move to… to change its folder.',
54
70
  };
55
71
  /** The tree's words in French (`locale="fr"`). */
56
72
  export const frenchFileTreeLabels = {
@@ -100,7 +116,27 @@ export const frenchFileTreeLabels = {
100
116
  tooLarge: 'Le fichier est trop volumineux pour être importé.',
101
117
  uploadProgress: name => `Import de ${name}`,
102
118
  cancelUpload: name => `Annuler l’import de ${name}`,
119
+ rename: 'Renommer',
120
+ renameOf: path => `Renommer ${path}`,
121
+ move: 'Déplacer vers…',
122
+ moveTitle: name => `Déplacer ${name}`,
123
+ moveDescription: 'Choisissez le dossier de destination.',
124
+ root: 'Racine de l’espace',
125
+ delete: 'Supprimer',
126
+ deleteTitle: name => `Supprimer ${name} ?`,
127
+ deleteDescription: 'Il est retiré de cet espace pour tous ceux qui l’utilisent.',
128
+ cancel: 'Annuler',
129
+ changing: 'Enregistrement…',
130
+ renamed: to => `Renommé en ${to}`,
131
+ deleted: 'Supprimé',
132
+ changedSince: 'Il a changé depuis son ouverture, rien n’a donc été modifié. Réessayez.',
133
+ dismiss: path => `Fermer le message pour ${path}`,
134
+ invalidName: 'Un nom ne peut pas contenir /. Utilisez Déplacer vers… pour changer de dossier.',
103
135
  };
136
+ /** Carries a dragged file row's path; files dragged in from the computer carry `Files` instead. */
137
+ const DRAGGED_FILE = 'application/x-boring-file-path';
138
+ const parentPath = (path) => path.slice(0, Math.max(0, path.lastIndexOf('/')));
139
+ const baseName = (path) => path.slice(path.lastIndexOf('/') + 1);
104
140
  const LOCALES = { en: defaultFileTreeLabels, fr: frenchFileTreeLabels };
105
141
  function fileKind(path, labels) {
106
142
  const extension = path.includes('.') ? path.slice(path.lastIndexOf('.') + 1).toLowerCase() : '';
@@ -135,8 +171,25 @@ function BoundFileTree(props) {
135
171
  return owned?.provider === props.revisionProvider ? _jsx(FileTreeView, { ...props, controller: owned.controller }) : _jsx("p", { role: "status", children: props.labels?.loading ?? LOCALES[props.locale ?? 'en'].loading });
136
172
  }
137
173
  /** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
138
- export function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refresh = true, variant = 'list', title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob }) {
139
- const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty, Progress } = components;
174
+ export function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refresh = true, variant = 'list', title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob, capabilities, onRenamed, onDeleted }) {
175
+ const changed = useRef({ onRenamed, onDeleted });
176
+ changed.current = { onRenamed, onDeleted };
177
+ /** Tell the host about a committed rename or delete, so a viewer showing the file follows or closes. */
178
+ const report = (mutation) => {
179
+ if (mutation.state.kind !== 'settled' || mutation.state.result.kind !== 'committed')
180
+ return mutation;
181
+ if (mutation.kind === 'rename' && mutation.to !== undefined)
182
+ changed.current.onRenamed?.(mutation.path, mutation.to);
183
+ else if (mutation.kind === 'delete')
184
+ changed.current.onDeleted?.(mutation.path);
185
+ return mutation;
186
+ };
187
+ const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty, Progress, Dialog } = components;
188
+ const can = {
189
+ // The client's methods turn the actions on; capabilities, when the host passes them, only narrow.
190
+ rename: controller.actions.rename && (capabilities === undefined || capabilities.effective.includes('rename')),
191
+ delete: controller.actions.delete && (capabilities === undefined || capabilities.effective.includes('delete')) && Dialog !== undefined,
192
+ };
140
193
  const list = variant === 'list';
141
194
  const labels = { ...LOCALES[locale], ...given };
142
195
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
@@ -159,6 +212,10 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
159
212
  const [focused, setFocused] = useState('');
160
213
  const [uploadError, setUploadError] = useState('');
161
214
  const [historyPath, setHistoryPath] = useState();
215
+ const [renaming, setRenaming] = useState();
216
+ const [asking, setAsking] = useState();
217
+ const [dragged, setDragged] = useState();
218
+ const [dropTarget, setDropTarget] = useState();
162
219
  const [localFailures, setLocalFailures] = useState(new Map());
163
220
  // Uploads through the host's `uploadBlob`, by a key of their own: the name, the progress and the abort behind the cancel button.
164
221
  const [blobUploads, setBlobUploads] = useState(new Map());
@@ -228,6 +285,80 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
228
285
  uploadRows.set(path, { kind: 'failed', size: failure.file.size, reason: failure.reason,
229
286
  retryLabel: labels.retry, retry: () => { void uploadFiles([failure.file]); } });
230
287
  const uploadButton = _jsxs(Button, { type: "button", variant: "secondary", "data-file-upload": true, onClick: () => fileInput.current?.click(), children: [_jsx(Upload, { "aria-hidden": "true" }), labels.upload] });
288
+ const mutationRows = new Map();
289
+ for (const item of state.mutations)
290
+ if (item.state.kind === 'pending' || item.state.result.kind !== 'committed')
291
+ mutationRows.set(item.path, item);
292
+ const mutationReason = (item) => item.state.kind === 'pending' ? labels.changing
293
+ : item.state.result.kind === 'committed' ? item.kind === 'delete' ? labels.deleted : labels.renamed(item.to ?? '')
294
+ : item.state.result.kind === 'conflict' ? labels.changedSince : item.state.result.kind === 'partial' ? labels.unconfirmed : item.state.result.reason;
295
+ // A refusal of the name itself (empty, `..`, the same name) keeps the input open with the reason; anything else is the row's.
296
+ const refused = (path) => (error) => {
297
+ if (error instanceof TypeError)
298
+ setRenaming(current => ({ ...current, path, error: error.message }));
299
+ else
300
+ setUploadError(error instanceof Error ? error.message : labels.changedSince);
301
+ };
302
+ const startRename = (path) => {
303
+ if (!can.rename)
304
+ return;
305
+ setRenaming({ path });
306
+ void controller.prepare(path).then(revision => { if (revision !== undefined)
307
+ setRenaming(current => current?.path === path ? { ...current, known: true } : current); });
308
+ };
309
+ const ask = (kind, path) => {
310
+ if (!Dialog || (kind === 'delete' ? !can.delete : !can.rename))
311
+ return;
312
+ void controller.prepare(path);
313
+ setAsking({ kind, path });
314
+ };
315
+ const commitRename = (path, value) => {
316
+ const name = value.trim();
317
+ if (!name || name === baseName(path)) {
318
+ setRenaming(undefined);
319
+ focus(path);
320
+ return;
321
+ }
322
+ const to = parentPath(path) ? `${parentPath(path)}/${name}` : name;
323
+ if (name.includes('/')) {
324
+ setRenaming(current => ({ ...current, path, error: labels.invalidName }));
325
+ return;
326
+ }
327
+ setRenaming(undefined);
328
+ focus(path);
329
+ void controller.rename(path, to).then(report).then(result => { if (result.state.kind === 'settled' && result.state.result.kind === 'committed')
330
+ setFocused(to); }, refused(path));
331
+ };
332
+ const moveTo = (path, folder) => {
333
+ setAsking(undefined);
334
+ if (parentPath(path) === folder)
335
+ return;
336
+ void controller.move(path, folder).then(report).catch(refused(path));
337
+ };
338
+ const closeAsking = () => { const path = asking?.path; setAsking(undefined); if (path)
339
+ requestAnimationFrame(() => focus(path)); };
340
+ const draggedPath = (event) => event.dataTransfer.types.includes(DRAGGED_FILE) ? dragged ?? event.dataTransfer.getData(DRAGGED_FILE) : undefined;
341
+ /** A folder (or `''`, the root) accepts a dragged file that is not already in it. */
342
+ const dropHandlers = (folder) => can.rename ? {
343
+ onDragEnter: (event) => { const path = draggedPath(event); if (path === undefined || parentPath(path) === folder)
344
+ return; event.preventDefault(); event.stopPropagation(); setDropTarget(folder); },
345
+ onDragOver: (event) => { const path = draggedPath(event); if (path === undefined || parentPath(path) === folder)
346
+ return; event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'move'; if (dropTarget !== folder)
347
+ setDropTarget(folder); },
348
+ onDragLeave: (event) => { if (draggedPath(event) !== undefined && dropTarget === folder && !event.currentTarget.contains(event.relatedTarget))
349
+ setDropTarget(undefined); },
350
+ onDrop: (event) => {
351
+ const path = draggedPath(event);
352
+ if (path === undefined)
353
+ return;
354
+ event.preventDefault();
355
+ event.stopPropagation();
356
+ setDropTarget(undefined);
357
+ setDragged(undefined);
358
+ if (path && parentPath(path) !== folder)
359
+ void controller.move(path, folder).then(report).catch(refused(path));
360
+ },
361
+ } : {};
231
362
  const rows = [];
232
363
  function flatten(directory, level) {
233
364
  for (const entry of state.directories.get(directory)?.entries ?? []) {
@@ -257,6 +388,10 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
257
388
  if (node.dataset.path === path)
258
389
  node.focus();
259
390
  };
391
+ /** Files only: a provider renames and deletes regular files, and a row busy uploading or changing waits. */
392
+ const mutable = (entry) => entry.kind === 'file' && !temporaryPaths.has(entry.path) && uploadRows.get(entry.path)?.kind !== 'pending' && mutationRows.get(entry.path)?.state.kind !== 'pending';
393
+ /** A blob pointer's row shows the file it stands for, not the pointer's own name: it can be moved or deleted, not renamed in place. */
394
+ const renameable = (entry) => !entry.blob;
260
395
  const keyboard = (event, entry) => {
261
396
  const index = rows.findIndex(row => row.entry.path === entry.path);
262
397
  const expanded = state.expanded.has(entry.path);
@@ -297,6 +432,16 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
297
432
  else if (!temporaryPaths.has(entry.path))
298
433
  onOpen?.(entry.path);
299
434
  break;
435
+ case 'F2':
436
+ if (!mutable(entry) || !can.rename || renameable(entry) === false)
437
+ return;
438
+ startRename(entry.path);
439
+ break;
440
+ case 'Delete':
441
+ if (!mutable(entry) || !can.delete)
442
+ return;
443
+ ask('delete', entry.path);
444
+ break;
300
445
  default: return;
301
446
  }
302
447
  event.preventDefault();
@@ -323,27 +468,61 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
323
468
  const childCount = children?.kind === 'ready' && children.cursor === undefined ? labels.childCount(children.entries.length) : undefined;
324
469
  const kind = folder ? { icon: Folder, label: labels.folder } : fileKind(blob?.name ?? entry.path, labels);
325
470
  const Icon = kind.icon;
326
- const button = _jsxs(Button, { type: "button", variant: selectedPath === entry.path ? 'secondary' : 'ghost', "data-active": selectedPath === entry.path, tabIndex: -1, title: entry.path, disabled: temporary, className: "file-tree-row-button", onClick: () => { focus(entry.path); if (!folder)
471
+ const mutation = mutationRows.get(entry.path);
472
+ const changeable = mutable(entry);
473
+ const draggable = changeable && can.rename;
474
+ const button = _jsxs(Button, { type: "button", variant: selectedPath === entry.path ? 'secondary' : 'ghost', "data-active": selectedPath === entry.path, tabIndex: -1, title: entry.path, disabled: temporary, className: "file-tree-row-button", draggable: draggable || undefined, onClick: () => { focus(entry.path); if (!folder)
327
475
  onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(blob?.size ?? entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent })] })] });
328
- const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-blob-type": blob?.type, "data-upload-state": status?.kind, "aria-busy": status?.kind === 'pending' || undefined, "aria-level": level, "aria-selected": selectedPath === entry.path, "aria-expanded": folder ? state.expanded.has(entry.path) : undefined, tabIndex: focusPath === entry.path ? 0 : -1, onFocus: event => { if (event.target === event.currentTarget)
476
+ const editing = renaming?.path === entry.path && can.rename;
477
+ const mutationFailed = mutation?.state.kind === 'settled';
478
+ const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-blob-type": blob?.type, "data-upload-state": status?.kind, "data-mutation-state": mutation ? mutationFailed ? 'failed' : 'pending' : undefined, "data-drop-target": folder && dropTarget === entry.path ? 'true' : undefined, "data-dragging": dragged === entry.path || undefined, "aria-busy": status?.kind === 'pending' || mutation?.state.kind === 'pending' || undefined, ...(folder ? dropHandlers(entry.path) : {}), ...(draggable ? {
479
+ onDragStart: (event) => {
480
+ if (event.target !== event.currentTarget && !(event.target instanceof Element && event.target.closest('.file-tree-row-button')))
481
+ return;
482
+ event.stopPropagation();
483
+ event.dataTransfer.setData(DRAGGED_FILE, entry.path);
484
+ event.dataTransfer.effectAllowed = 'move';
485
+ setDragged(entry.path);
486
+ void controller.prepare(entry.path);
487
+ },
488
+ onDragEnd: () => { setDragged(undefined); setDropTarget(undefined); },
489
+ } : {}), "aria-level": level, "aria-selected": selectedPath === entry.path, "aria-expanded": folder ? state.expanded.has(entry.path) : undefined, tabIndex: focusPath === entry.path ? 0 : -1, onFocus: event => { if (event.target === event.currentTarget)
329
490
  setFocused(entry.path); }, onKeyDown: event => { if (event.target === event.currentTarget)
330
- keyboard(event, entry); }, className: "file-tree-item", children: [_jsxs("div", { className: "flex items-center gap-1", children: [folder ? _jsx(CollapsibleTrigger, { render: button }) : button, status?.kind === 'pending' && _jsx(LoaderCircle, { "data-upload-spinner": true, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), !folder && !temporary && !status && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen ? () => onOpen(entry.path) : undefined, onHistory: history ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
491
+ keyboard(event, entry); }, className: "file-tree-item", children: [_jsxs("div", { className: "flex items-center gap-1", children: [editing ? _jsxs("span", { className: "file-tree-rename", children: [_jsx(Icon, { "aria-hidden": "true" }), _jsx(Input, { "data-file-rename": true, "data-revision-known": renaming?.known || undefined, "aria-label": labels.renameOf(entry.path), defaultValue: name, autoFocus: true, enterKeyHint: "done", spellCheck: false, "aria-invalid": renaming?.error ? true : undefined, onFocus: event => { const dot = event.currentTarget.value.lastIndexOf('.'); event.currentTarget.setSelectionRange(0, dot > 0 ? dot : event.currentTarget.value.length); }, onKeyDown: event => {
492
+ if (event.key === 'Enter') {
493
+ event.preventDefault();
494
+ event.stopPropagation();
495
+ commitRename(entry.path, event.currentTarget.value);
496
+ }
497
+ else if (event.key === 'Escape') {
498
+ event.preventDefault();
499
+ event.stopPropagation();
500
+ setRenaming(undefined);
501
+ focus(entry.path);
502
+ }
503
+ },
504
+ // Focus moved somewhere on purpose cancels; a closing menu that drops focus (no related target) does not.
505
+ onBlur: event => { if (event.relatedTarget)
506
+ setRenaming(current => current?.path === entry.path ? undefined : current); } })] }) : folder ? _jsx(CollapsibleTrigger, { render: button }) : button, (status?.kind === 'pending' || mutation?.state.kind === 'pending') && _jsx(LoaderCircle, { "data-upload-spinner": status ? '' : undefined, "data-mutation-spinner": mutation ? '' : undefined, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), mutation && mutationFailed && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-mutation-dismiss": true, "aria-label": labels.dismiss(entry.path), title: labels.dismiss(entry.path), onClick: () => controller.dismiss(mutation.operationId), children: _jsx(X, { "aria-hidden": "true" }) }), !folder && !temporary && !status && !editing && mutation?.state.kind !== 'pending' && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen ? () => onOpen(entry.path) : undefined, onHistory: history ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError, onRename: changeable && can.rename && renameable(entry) ? () => startRename(entry.path) : undefined, onMove: changeable && can.rename && Dialog ? () => ask('move', entry.path) : undefined, onDelete: changeable && can.delete ? () => ask('delete', entry.path) : undefined })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), editing && renaming?.error && _jsx("span", { "data-mutation-error": true, role: "alert", children: renaming.error }), mutation && mutationFailed && _jsx("span", { "data-mutation-error": true, children: mutationReason(mutation) }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
331
507
  return folder ? _jsx(CollapsibleRoot, { render: item, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); } }, entry.path) : item;
332
508
  };
333
509
  const visibleListing = state.query ? state.search : state.directories.get('');
334
510
  const paginated = visibleListing?.kind === 'ready' && visibleListing.cursor !== undefined;
335
- return _jsxs("section", { "data-boring": "file-tree", "data-variant": variant, "data-dropping": dropping || undefined, className: className, "aria-label": labels.region, onDragEnter: event => { if (upload && event.dataTransfer.types.includes('Files')) {
511
+ const rootDrop = dropHandlers('');
512
+ const dialogPath = asking?.path ?? '';
513
+ const folders = asking?.kind === 'move' ? [...new Set(['', ...[...state.directories.values(), state.search].flatMap(listing => listing.entries.filter(entry => entry.kind === 'directory').map(entry => entry.path))])].sort() : [];
514
+ return _jsxs("section", { "data-boring": "file-tree", "data-variant": variant, "data-dropping": dropping || undefined, "data-drop-target": dropTarget === '' ? 'true' : undefined, className: className, "aria-label": labels.region, onDragEnter: event => { rootDrop.onDragEnter?.(event); if (upload && event.dataTransfer.types.includes('Files')) {
336
515
  event.preventDefault();
337
516
  dragDepth.current++;
338
517
  setDropping(true);
339
- } }, onDragOver: event => { if (upload && event.dataTransfer.types.includes('Files')) {
518
+ } }, onDragOver: event => { rootDrop.onDragOver?.(event); if (upload && event.dataTransfer.types.includes('Files')) {
340
519
  event.preventDefault();
341
520
  event.dataTransfer.dropEffect = 'copy';
342
- } }, onDragLeave: event => { if (upload && event.dataTransfer.types.includes('Files')) {
521
+ } }, onDragLeave: event => { rootDrop.onDragLeave?.(event); if (upload && event.dataTransfer.types.includes('Files')) {
343
522
  dragDepth.current = Math.max(0, dragDepth.current - 1);
344
523
  if (!dragDepth.current)
345
524
  setDropping(false);
346
- } }, onDrop: event => { if (upload && event.dataTransfer.types.includes('Files')) {
525
+ } }, onDrop: event => { rootDrop.onDrop?.(event); if (upload && event.dataTransfer.types.includes('Files')) {
347
526
  event.preventDefault();
348
527
  dragDepth.current = 0;
349
528
  setDropping(false);
@@ -355,14 +534,19 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
355
534
  } }), dropping && _jsx("div", { className: "file-tree-drop", role: "status", children: _jsx(Empty, { icon: _jsx(Upload, { "aria-hidden": "true" }), title: labels.dropFiles }) }), uploadError && _jsx("p", { role: "alert", children: uploadError }), blobUploads.size > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "file-tree-blob-uploads", children: [...blobUploads].map(([key, item]) => {
356
535
  const percent = item.progress && item.progress.total > 0 ? Math.min(100, Math.round(item.progress.loaded / item.progress.total * 100)) : null;
357
536
  return _jsxs("li", { "data-blob-upload": item.name, "data-progress": percent ?? undefined, className: "flex items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: item.name }), Progress ? _jsx("span", { className: "w-24 shrink-0", children: _jsx(Progress, { value: percent, "aria-label": labels.uploadProgress(item.name) }) }) : _jsx("span", { role: "status", children: percent === null ? labels.uploading : `${percent}%` }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-blob-upload-cancel": true, "aria-label": labels.cancelUpload(item.name), title: labels.cancelUpload(item.name), onClick: () => item.abort.abort(), children: _jsx(X, { "aria-hidden": "true" }) })] }, key);
358
- }) }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.kind === 'partial' ? labels.unconfirmed : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path))] }), history && historyPath !== undefined && _jsx(FileHistory, { components: components, locale: locale, controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
537
+ }) }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.kind === 'partial' ? labels.unconfirmed : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path)), state.mutations.map(item => _jsxs("li", { "data-operation": item.operationId, children: [item.path, ": ", mutationReason(item)] }, item.operationId))] }), Dialog && _jsx(Dialog, { open: asking !== undefined, onOpenChange: open => { if (!open)
538
+ closeAsking(); }, title: asking?.kind === 'move' ? labels.moveTitle(baseName(dialogPath)) : labels.deleteTitle(baseName(dialogPath)), description: asking?.kind === 'move' ? labels.moveDescription : labels.deleteDescription, footer: asking?.kind === 'delete' ? _jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "outline", "data-confirm": "cancel", onClick: closeAsking, children: labels.cancel }), _jsxs(Button, { type: "button", variant: "destructive", "data-confirm": "delete", onClick: () => {
539
+ const path = dialogPath;
540
+ setAsking(undefined);
541
+ void controller.remove(path).then(report).catch(refused(path));
542
+ }, children: [_jsx(Trash2, { "aria-hidden": "true" }), labels.delete] })] }) : _jsx(Button, { type: "button", variant: "outline", "data-confirm": "cancel", onClick: closeAsking, children: labels.cancel }), children: asking?.kind === 'move' && _jsx("ul", { className: "file-tree-destinations", "aria-label": labels.moveTitle(baseName(dialogPath)), children: folders.map(folder => _jsx("li", { children: _jsxs(Button, { type: "button", variant: "ghost", "data-destination": folder, disabled: folder === parentPath(dialogPath), onClick: () => moveTo(dialogPath, folder), children: [folder ? _jsx(Folder, { "aria-hidden": "true" }) : _jsx(FolderOpen, { "aria-hidden": "true" }), _jsx("span", { className: "file-tree-name", children: folder || labels.root })] }) }, folder)) }) }), history && historyPath !== undefined && _jsx(FileHistory, { components: components, locale: locale, controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
359
543
  }
360
544
  /** The message of a host refusal `{ error: { code, message } }` (or an Error's), if any. */
361
545
  function refusalOf(error) {
362
546
  const message = typeof error === 'object' && error !== null && 'error' in error && typeof error.error === 'object' && error.error !== null && 'message' in error.error ? error.error.message : error instanceof Error ? error.message : undefined;
363
547
  return typeof message === 'string' && message ? message : undefined;
364
548
  }
365
- function FileActions({ Actions, controller, entry, labels, onOpen, onHistory, onError }) {
549
+ function FileActions({ Actions, controller, entry, labels, onOpen, onHistory, onError, onRename, onMove, onDelete }) {
366
550
  const lifetime = useRef(undefined);
367
551
  useEffect(() => () => lifetime.current?.abort(), []);
368
552
  const blob = entry.blob && 'url' in entry.blob ? entry.blob : undefined;
@@ -410,6 +594,12 @@ function FileActions({ Actions, controller, entry, labels, onOpen, onHistory, on
410
594
  if (onHistory)
411
595
  items.push({ id: 'history', label: labels.history, icon: _jsx(History, { "aria-hidden": "true" }), onSelect: onHistory });
412
596
  items.push({ id: 'download', label: labels.download, icon: _jsx(Download, { "aria-hidden": "true" }), onSelect: () => { void download(); } });
597
+ if (onRename)
598
+ items.push({ id: 'rename', label: labels.rename, icon: _jsx(Pencil, { "aria-hidden": "true" }), onSelect: onRename, ownsFocus: true });
599
+ if (onMove)
600
+ items.push({ id: 'move', label: labels.move, icon: _jsx(FolderInput, { "aria-hidden": "true" }), onSelect: onMove, ownsFocus: true });
601
+ if (onDelete)
602
+ items.push({ id: 'delete', label: labels.delete, icon: _jsx(Trash2, { "aria-hidden": "true" }), onSelect: onDelete, destructive: true, ownsFocus: true });
413
603
  return _jsx(Actions, { label: labels.actionsOf(entry.path), icon: _jsx(MoreHorizontal, { "aria-hidden": "true" }), items: items });
414
604
  }
415
605
  function FileHistory({ components: { Button, Versions, Separator }, locale, controller, path, labels, reload, onClose }) {
@@ -23,6 +23,20 @@ export interface FileUpload {
23
23
  readonly result: PublicationResult;
24
24
  };
25
25
  }
26
+ /** A rename (a move is a rename to another folder) or a delete of one file, as its row shows it. */
27
+ export interface FileMutation {
28
+ readonly operationId: string;
29
+ readonly kind: 'rename' | 'delete';
30
+ readonly path: string;
31
+ /** The new path of a rename. */
32
+ readonly to?: string;
33
+ readonly state: {
34
+ readonly kind: 'pending';
35
+ } | {
36
+ readonly kind: 'settled';
37
+ readonly result: PublicationResult;
38
+ };
39
+ }
26
40
  export interface FileTreeState {
27
41
  readonly lifecycle: 'active' | 'disposed';
28
42
  readonly directories: ReadonlyMap<string, FileListing>;
@@ -30,6 +44,8 @@ export interface FileTreeState {
30
44
  readonly query: string;
31
45
  readonly search: FileListing;
32
46
  readonly uploads: readonly FileUpload[];
47
+ /** The latest rename or delete of each path, until it is dismissed or the path changes again. */
48
+ readonly mutations: readonly FileMutation[];
33
49
  }
34
50
  export interface FileTreeController {
35
51
  readonly revisionProvider: RevisionProvider;
@@ -51,6 +67,21 @@ export interface FileTreeController {
51
67
  /** Retry a refused upload with its captured bytes. Unconfirmed uploads only reconcile the original operation. */
52
68
  readonly retry: (operationId: string) => Promise<FileUpload>;
53
69
  readonly history: (path: string, signal?: AbortSignal) => Promise<readonly SavedRevision[]>;
70
+ /** What the client can do: a client without `rename` or `delete` offers neither (the host's capabilities may narrow it further). */
71
+ readonly actions: {
72
+ readonly rename: boolean;
73
+ readonly delete: boolean;
74
+ };
75
+ /** Learn the revision of a file as its row shows it (on opening its menu, renaming, dragging): a later rename or delete is conditional on it. */
76
+ readonly prepare: (path: string, signal?: AbortSignal) => Promise<string | undefined>;
77
+ /** Rename a file through the client's conditional rename. `to` is a full path; its folder must exist. */
78
+ readonly rename: (path: string, to: string) => Promise<FileMutation>;
79
+ /** Move a file into `folder` (`''` is the workspace root): a rename that keeps its name. */
80
+ readonly move: (path: string, folder: string) => Promise<FileMutation>;
81
+ /** Delete a file through the client's conditional delete. */
82
+ readonly remove: (path: string) => Promise<FileMutation>;
83
+ /** Forget a settled rename or delete (its row's message). */
84
+ readonly dismiss: (operationId: string) => void;
54
85
  readonly dispose: () => void;
55
86
  }
56
87
  /** Browsing state belongs to one borrowed binding. Disposal fences late results; it never closes the workspace. */