@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.
- package/README.md +12 -2
- package/bin/boring-ui-blocks.mjs +8 -1
- package/dist/file-tree-view.d.ts +48 -3
- package/dist/file-tree-view.js +202 -12
- package/dist/file-tree.d.ts +31 -0
- package/dist/file-tree.js +122 -1
- package/dist/voice-capture.d.ts +10 -0
- package/dist/voice-capture.js +235 -39
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +9 -9
- package/r/board-viewer.json +5 -5
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +13 -13
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +8 -7
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +1 -1
- package/r/maquette-viewer.json +2 -2
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +9 -9
- package/r/pi-app.json +21 -21
- package/r/pi-artifacts.json +1 -1
- package/r/pi-chat-approval.json +1 -1
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +1 -1
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +1 -1
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +3 -3
- package/r/pi-chat.json +5 -5
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +1 -1
- package/r/registry.json +167 -166
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +6 -6
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +3 -3
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +5 -5
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
- 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.
|
|
394
|
-
|
|
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
|
|
package/bin/boring-ui-blocks.mjs
CHANGED
|
@@ -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 =
|
|
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');
|
package/dist/file-tree-view.d.ts
CHANGED
|
@@ -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
|
package/dist/file-tree-view.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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: [
|
|
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
|
-
|
|
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))
|
|
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 }) {
|
package/dist/file-tree.d.ts
CHANGED
|
@@ -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. */
|