@hachej/boring-ui-kit 0.3.2 → 0.4.1
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 +46 -18
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/bin/boring-ui-blocks.mjs +286 -0
- package/dist/canvas-editor-native.d.ts +4 -1
- package/dist/canvas-editor-native.js +8 -6
- package/dist/canvas-editor.js +1 -1
- package/dist/canvas-proposals.d.ts +3 -1
- package/dist/canvas-proposals.js +4 -3
- package/dist/experience-document-viewer.d.ts +3 -0
- package/dist/experience-document-viewer.js +6 -5
- package/dist/experience.d.ts +3 -0
- package/dist/experience.js +2 -1
- package/dist/file-tree-view.d.ts +76 -3
- package/dist/file-tree-view.js +172 -39
- package/dist/file-tree.d.ts +3 -0
- package/dist/file-tree.js +17 -2
- package/dist/html-viewer.d.ts +4 -0
- package/dist/html-viewer.js +5 -3
- package/dist/markdown-autosave.d.ts +13 -0
- package/dist/markdown-autosave.js +94 -0
- package/dist/markdown-editor.d.ts +16 -0
- package/dist/markdown-editor.js +81 -17
- package/dist/markdown-extensions.js +9 -1
- package/dist/markdown-proposal-diff.d.ts +2 -1
- package/dist/markdown-proposal-diff.js +3 -2
- package/dist/markdown-safety.js +3 -1
- package/dist/native-chat.js +1 -1
- package/dist/remote-chat.d.ts +18 -0
- package/dist/remote-chat.js +149 -7
- package/dist/text-buffer.js +3 -2
- package/dist/text-draft-controls.d.ts +3 -1
- package/dist/text-draft-controls.js +3 -2
- package/dist/viewer-components.d.ts +14 -0
- package/dist/viewer-components.js +1 -0
- package/package.json +8 -4
- package/r/app-shell.json +39 -20
- package/r/button.json +19 -11
- package/r/dock.json +2 -2
- package/r/documents.json +17 -14
- package/r/feedback-preview.json +8 -2
- package/r/feedback.json +46 -325
- package/r/file-tree.json +14 -6
- package/r/html-viewer.json +8 -42
- package/r/markdown-editor.json +38 -87
- package/r/pi-ambient.json +15 -8
- package/r/pi-app.json +30 -24
- package/r/pi-chat.json +101 -70
- package/r/pi-workspace.json +7 -5
- package/r/provider-setup.json +9 -4
- package/r/registry.json +471 -893
- package/r/task-list-viewer.json +11 -21
- package/r/theme.json +23 -4
- package/r/version-history.json +24 -0
- package/r/viewers.json +139 -320
- package/styles/file-tree.css +41 -42
- package/dist/file-tree-shadcn.d.ts +0 -8
- package/dist/file-tree-shadcn.js +0 -14
package/dist/canvas-proposals.js
CHANGED
|
@@ -28,18 +28,19 @@ function ProposalChanges({ proposal }) {
|
|
|
28
28
|
}, [proposal]);
|
|
29
29
|
return _jsxs("details", { children: [_jsxs("summary", { children: ["Review ", changes.length, " changed ", changes.length === 1 ? 'record' : 'records'] }), changes.map(change => _jsxs("div", { children: [_jsxs("h4", { children: [change.kind, ": ", change.id] }), _jsxs("table", { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Field" }), _jsx("th", { scope: "col", children: "Before" }), _jsx("th", { scope: "col", children: "After" })] }) }), _jsx("tbody", { children: change.fields.map(row => _jsxs("tr", { children: [_jsx("th", { scope: "row", children: row.field }), _jsx("td", { children: _jsx("pre", { style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: row.before }) }), _jsx("td", { children: _jsx("pre", { style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: row.after }) })] }, row.field)) })] })] }, change.id))] });
|
|
30
30
|
}
|
|
31
|
-
export function CanvasProposals({ controller, state, editor, busy, run }) {
|
|
31
|
+
export function CanvasProposals({ components, controller, state, editor, busy, run }) {
|
|
32
32
|
const nativeReadOnly = useValue('canvas proposal readonly', () => !editor || editor.isDisposed || editor.getIsReadonly(), [editor]);
|
|
33
33
|
const blocked = busy || nativeReadOnly || state.readOnly || state.lifecycle !== 'active' || state.problem !== null
|
|
34
34
|
|| state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown');
|
|
35
|
+
const Control = components?.button ?? 'button';
|
|
35
36
|
if (!state.proposals.length)
|
|
36
37
|
return null;
|
|
37
38
|
return _jsx("aside", { "aria-label": "Canvas proposals", children: state.proposals.map(proposal => {
|
|
38
39
|
const stale = state.bufferVersion !== proposal.base.target.subject.bufferVersion || !sameBase(state.base, proposal.base.target.subject.base);
|
|
39
|
-
return _jsxs("article", { "data-boring": "canvas-proposal", children: [_jsx("h3", { children: proposal.summary || 'Canvas change proposal' }), _jsx(ProposalChanges, { proposal: proposal }), proposal.adopted ? _jsx("p", { children: "Applied locally. Publication status is shown above." }) : stale ? _jsx("p", { children: "Canvas changed since this proposal." }) : null, !proposal.adopted && _jsx(
|
|
40
|
+
return _jsxs("article", { "data-boring": "canvas-proposal", children: [_jsx("h3", { children: proposal.summary || 'Canvas change proposal' }), _jsx(ProposalChanges, { proposal: proposal }), proposal.adopted ? _jsx("p", { children: "Applied locally. Publication status is shown above." }) : stale ? _jsx("p", { children: "Canvas changed since this proposal." }) : null, !proposal.adopted && _jsx(Control, { type: "button", disabled: blocked || stale, onClick: () => {
|
|
40
41
|
if (!editor || editor.isDisposed || editor.getIsReadonly())
|
|
41
42
|
return;
|
|
42
43
|
void run(() => controller.actions.accept(proposal.id));
|
|
43
|
-
}, children: "Accept and save" }), _jsx(
|
|
44
|
+
}, children: "Accept and save" }), _jsx(Control, { type: "button", disabled: busy || state.lifecycle !== 'active', onClick: () => controller.actions.reject(proposal.id), children: "Dismiss" })] }, proposal.id);
|
|
44
45
|
}) });
|
|
45
46
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RenderedExperienceCell } from './experience-renderer.js';
|
|
2
2
|
import type { ExperienceAccess } from './experience-compose.js';
|
|
3
3
|
import type { ExperienceDocumentController, ExperienceRegionRequest } from './experience-document.js';
|
|
4
|
+
import type { ViewerComponents } from './viewer-components.js';
|
|
4
5
|
export interface ExperienceDocumentProps {
|
|
5
6
|
readonly controller: ExperienceDocumentController;
|
|
6
7
|
readonly cells: readonly RenderedExperienceCell[];
|
|
@@ -8,6 +9,8 @@ export interface ExperienceDocumentProps {
|
|
|
8
9
|
readonly onRegenerate?: (request: ExperienceRegionRequest) => void | Promise<void>;
|
|
9
10
|
readonly title?: string;
|
|
10
11
|
readonly className?: string;
|
|
12
|
+
/** Host presentation for the layout controls; without it the package renders unstyled native buttons. */
|
|
13
|
+
readonly components?: ViewerComponents;
|
|
11
14
|
}
|
|
12
15
|
export declare function ExperienceDocument(props: ExperienceDocumentProps): import("react").JSX.Element;
|
|
13
16
|
//# sourceMappingURL=experience-document-viewer.d.ts.map
|
|
@@ -4,7 +4,8 @@ import { TextDraftControls } from './text-draft-controls.js';
|
|
|
4
4
|
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
5
5
|
import { ExperienceRenderer } from './experience-renderer.js';
|
|
6
6
|
import { layoutProps } from './experience-catalog.js';
|
|
7
|
-
function MountedDocument({ controller, cells, canView, onRegenerate, title = 'Experience layout', className }) {
|
|
7
|
+
function MountedDocument({ controller, cells, canView, onRegenerate, title = 'Experience layout', className, components }) {
|
|
8
|
+
const Control = components?.button ?? 'button';
|
|
8
9
|
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
9
10
|
const [error, setError] = useState();
|
|
10
11
|
const active = useRef(true);
|
|
@@ -26,11 +27,11 @@ function MountedDocument({ controller, cells, canView, onRegenerate, title = 'Ex
|
|
|
26
27
|
const status = disposed ? 'Viewer disposed' : state.readOnly ? 'Read only' : pending ? 'Keeping selected layout' : unknown ? 'Keep unconfirmed. Reconcile before keeping again.'
|
|
27
28
|
: state.save.kind === 'settled' && state.save.result.kind !== 'saved' ? state.save.result.kind === 'partial' ? 'Keep unconfirmed' : state.save.result.reason
|
|
28
29
|
: state.dirty ? 'Layout not kept' : 'Layout kept';
|
|
29
|
-
return _jsxs("section", { "data-boring": "experience-document", className: className, "aria-label": title, children: [_jsxs("header", { children: [_jsx("strong", { children: title }), _jsx("span", { role: "status", children: status })] }), _jsxs("div", { role: "toolbar", "aria-label": "Layout document controls", children: [_jsx(
|
|
30
|
+
return _jsxs("section", { "data-boring": "experience-document", className: className, "aria-label": title, children: [_jsxs("header", { children: [_jsx("strong", { children: title }), _jsx("span", { role: "status", children: status })] }), _jsxs("div", { role: "toolbar", "aria-label": "Layout document controls", children: [_jsx(Control, { type: "button", disabled: disposed || state.readOnly || !state.dirty || !state.descriptor || pending || unknown, onClick: () => { const selection = controller.actions.selection(); perform(() => controller.flush(selection)); }, children: "Keep this layout" }), _jsx(Control, { type: "button", disabled: disposed || pending || unknown, onClick: () => perform(controller.actions.refresh), children: "Refresh layout" }), _jsx(Control, { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.reconcile), children: "Reconcile keep" }), _jsx(Control, { type: "button", disabled: disposed || pending || unknown || state.remote === null, onClick: () => perform(controller.actions.discardToRemote), children: "Discard local layout" }), state.pin && _jsx(Control, { type: "button", disabled: disposed || state.readOnly || pending || unknown, onClick: () => {
|
|
30
31
|
const pin = state.pin;
|
|
31
32
|
if (pin)
|
|
32
33
|
perform(() => controller.actions.pin(pin.selection));
|
|
33
|
-
}, children: "Pin this region" }), regions.filter(region => region.regenerate.includes('request')).map(region => _jsxs(
|
|
34
|
+
}, children: "Pin this region" }), regions.filter(region => region.regenerate.includes('request')).map(region => _jsxs(Control, { type: "button", disabled: disposed || state.readOnly || !onRegenerate, onClick: () => {
|
|
34
35
|
const result = controller.actions.beginRegion(controller.actions.selection(), region.region, 'request');
|
|
35
36
|
if (result.kind !== 'applied') {
|
|
36
37
|
setError('reason' in result ? result.reason : 'Region request was not started');
|
|
@@ -41,10 +42,10 @@ function MountedDocument({ controller, cells, canView, onRegenerate, title = 'Ex
|
|
|
41
42
|
if (active.current)
|
|
42
43
|
setError('The region could not be regenerated. The current layout has been retained.');
|
|
43
44
|
});
|
|
44
|
-
}, children: ["Regenerate ", region.region] }, region.region))] }), _jsx(TextDraftControls, { recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), proposal && _jsxs("div", { role: "group", "aria-label": proposal.kind === 'region' ? 'Proposed region' : 'Proposed layout', children: [_jsx(
|
|
45
|
+
}, children: ["Regenerate ", region.region] }, region.region))] }), _jsx(TextDraftControls, { components: components, recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), proposal && _jsxs("div", { role: "group", "aria-label": proposal.kind === 'region' ? 'Proposed region' : 'Proposed layout', children: [_jsx(Control, { type: "button", disabled: disposed || state.readOnly, onClick: () => {
|
|
45
46
|
const result = controller.actions.adopt(proposal.id);
|
|
46
47
|
setError(result.kind === 'applied' ? undefined : 'reason' in result ? result.reason : 'Layout was not adopted');
|
|
47
|
-
}, children: proposal.kind === 'region' ? 'Use proposed region' : 'Use proposed layout' }), _jsx(
|
|
48
|
+
}, children: proposal.kind === 'region' ? 'Use proposed region' : 'Use proposed layout' }), _jsx(Control, { type: "button", disabled: disposed, onClick: () => { controller.actions.reject(proposal.id); setError(undefined); }, children: proposal.kind === 'region' ? 'Dismiss proposed region' : 'Dismiss proposed layout' })] }), (error || state.problem) && _jsx("p", { role: "alert", children: error ?? state.problem }), state.descriptor ? _jsx("section", { "data-boring": "experience", "aria-label": state.descriptor.title ?? state.descriptor.name, children: _jsx(ExperienceRenderer, { descriptor: state.descriptor, cells: cells, canView: canView }) }) : _jsx("p", { children: "No layout selected" })] });
|
|
48
49
|
}
|
|
49
50
|
export function ExperienceDocument(props) {
|
|
50
51
|
const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });
|
package/dist/experience.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RenderedExperienceCell } from './experience-renderer.js';
|
|
2
2
|
import type { ExperienceAccess } from './experience-compose.js';
|
|
3
|
+
import type { ViewerComponents } from './viewer-components.js';
|
|
3
4
|
export { ExperienceRenderer } from './experience-renderer.js';
|
|
4
5
|
export type { RenderedExperienceCell } from './experience-renderer.js';
|
|
5
6
|
export interface ExperienceProps {
|
|
@@ -7,6 +8,8 @@ export interface ExperienceProps {
|
|
|
7
8
|
readonly cells: readonly RenderedExperienceCell[];
|
|
8
9
|
readonly canView: ExperienceAccess['canView'];
|
|
9
10
|
readonly className?: string;
|
|
11
|
+
/** Host presentation for the proposal action; without it the package renders an unstyled native button. */
|
|
12
|
+
readonly components?: ViewerComponents;
|
|
10
13
|
}
|
|
11
14
|
export declare function Experience(props: ExperienceProps): import("react").JSX.Element;
|
|
12
15
|
//# sourceMappingURL=experience.d.ts.map
|
package/dist/experience.js
CHANGED
|
@@ -13,11 +13,12 @@ function select(props) {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
export function Experience(props) {
|
|
16
|
+
const Control = props.components?.button ?? 'button';
|
|
16
17
|
const [current, setCurrent] = useState(() => select(props));
|
|
17
18
|
const candidate = current.input === props.descriptor && current.kind === 'ready' ? null : select(props);
|
|
18
19
|
const offered = candidate?.kind === 'invalid' && current.kind === 'invalid' ? null : candidate;
|
|
19
20
|
return _jsxs("section", { "data-boring": "experience", className: props.className, "aria-label": current.kind === 'ready' ? current.descriptor.title ?? current.descriptor.name : 'Experience unavailable', children: [offered && (offered.kind === 'ready'
|
|
20
|
-
? _jsx(
|
|
21
|
+
? _jsx(Control, { type: "button", onClick: () => setCurrent(offered), children: "Use proposed layout" })
|
|
21
22
|
: _jsx("p", { role: "alert", children: "Proposed layout unavailable. The current layout has been retained." })), current.kind === 'invalid' ? _jsx("p", { role: "alert", children: "Experience unavailable" })
|
|
22
23
|
: _jsx(ExperienceRenderer, { descriptor: current.descriptor, cells: props.cells, canView: props.canView })] });
|
|
23
24
|
}
|
package/dist/file-tree-view.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Collapsible } from '@base-ui/react/collapsible';
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
import type { RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';
|
|
2
4
|
import type { FileTreeController } from './file-tree.js';
|
|
3
5
|
/** Every word of the tree, so a host can translate it. Functions receive what they name. */
|
|
4
6
|
export declare const defaultFileTreeLabels: {
|
|
@@ -14,6 +16,20 @@ export declare const defaultFileTreeLabels: {
|
|
|
14
16
|
retry: string;
|
|
15
17
|
noMatches: string;
|
|
16
18
|
empty: string;
|
|
19
|
+
emptyHint: string;
|
|
20
|
+
library: string;
|
|
21
|
+
close: string;
|
|
22
|
+
open: string;
|
|
23
|
+
download: string;
|
|
24
|
+
downloadingFailed: string;
|
|
25
|
+
actionsOf: (path: string) => string;
|
|
26
|
+
shownItems: (count: number) => string;
|
|
27
|
+
folder: string;
|
|
28
|
+
childCount: (count: number) => string;
|
|
29
|
+
file: string;
|
|
30
|
+
image: string;
|
|
31
|
+
document: string;
|
|
32
|
+
dropFiles: string;
|
|
17
33
|
loadMore: string;
|
|
18
34
|
history: string;
|
|
19
35
|
historyOf: (path: string) => string;
|
|
@@ -37,7 +53,56 @@ export type FileTreeLabels = typeof defaultFileTreeLabels;
|
|
|
37
53
|
export declare const frenchFileTreeLabels: FileTreeLabels;
|
|
38
54
|
/** The languages the tree ships with; anything else is set word by word through `labels`. */
|
|
39
55
|
export type FileTreeLocale = 'en' | 'fr';
|
|
56
|
+
export interface FileTreeAction {
|
|
57
|
+
readonly id: 'open' | 'history' | 'download';
|
|
58
|
+
readonly label: string;
|
|
59
|
+
readonly icon: ReactNode;
|
|
60
|
+
readonly onSelect: () => void;
|
|
61
|
+
}
|
|
62
|
+
export interface FileTreeActionsProps {
|
|
63
|
+
readonly label: string;
|
|
64
|
+
readonly icon: ReactNode;
|
|
65
|
+
readonly items: readonly FileTreeAction[];
|
|
66
|
+
}
|
|
67
|
+
export type FileTreeButtonProps = ComponentProps<'button'> & {
|
|
68
|
+
readonly variant?: 'secondary' | 'ghost';
|
|
69
|
+
readonly size?: 'default' | 'sm' | 'icon';
|
|
70
|
+
};
|
|
71
|
+
export interface FileTreeVersionsProps {
|
|
72
|
+
readonly versions: readonly SavedRevision[];
|
|
73
|
+
readonly current: string;
|
|
74
|
+
readonly onSelect: (revision: string) => void;
|
|
75
|
+
readonly locale: FileTreeLocale;
|
|
76
|
+
readonly earlierVersion: string;
|
|
77
|
+
}
|
|
78
|
+
/** Host presentation. The registry supplies real shadcn Base UI controls and shared version rows; no primitive fallback. */
|
|
79
|
+
export interface FileTreeComponents {
|
|
80
|
+
readonly Button: (props: FileTreeButtonProps) => ReactNode;
|
|
81
|
+
readonly Actions: (props: FileTreeActionsProps) => ReactNode;
|
|
82
|
+
readonly Versions: (props: FileTreeVersionsProps) => ReactNode;
|
|
83
|
+
/** The host's shadcn sidebar menu (`SidebarMenu`, `SidebarMenuItem`, `SidebarMenuSub`). */
|
|
84
|
+
readonly Menu: (props: ComponentProps<'ul'>) => ReactNode;
|
|
85
|
+
readonly MenuItem: (props: ComponentProps<'li'>) => ReactNode;
|
|
86
|
+
readonly MenuSub: (props: ComponentProps<'ul'>) => ReactNode;
|
|
87
|
+
/** The host's shadcn `Input`. */
|
|
88
|
+
readonly Input: (props: ComponentProps<'input'>) => ReactNode;
|
|
89
|
+
/** The host's shadcn `Separator`. */
|
|
90
|
+
readonly Separator: () => ReactNode;
|
|
91
|
+
/** The host's shadcn `Collapsible`, `CollapsibleTrigger` and `CollapsibleContent`. */
|
|
92
|
+
readonly Collapsible: (props: Collapsible.Root.Props) => ReactNode;
|
|
93
|
+
readonly CollapsibleTrigger: (props: Collapsible.Trigger.Props) => ReactNode;
|
|
94
|
+
readonly CollapsibleContent: (props: Collapsible.Panel.Props) => ReactNode;
|
|
95
|
+
/** The host's shadcn `Empty`, composed from an icon, a title, an optional description and optional actions. */
|
|
96
|
+
readonly Empty: (props: {
|
|
97
|
+
readonly icon?: ReactNode;
|
|
98
|
+
readonly title: ReactNode;
|
|
99
|
+
readonly description?: ReactNode;
|
|
100
|
+
readonly className?: string;
|
|
101
|
+
readonly children?: ReactNode;
|
|
102
|
+
}) => ReactNode;
|
|
103
|
+
}
|
|
40
104
|
export interface FileTreeProps {
|
|
105
|
+
readonly components: FileTreeComponents;
|
|
41
106
|
readonly revisionProvider: RevisionProvider;
|
|
42
107
|
readonly onOpen?: (path: string) => void;
|
|
43
108
|
readonly selectedPath?: string;
|
|
@@ -49,8 +114,16 @@ export interface FileTreeProps {
|
|
|
49
114
|
readonly labels?: Partial<FileTreeLabels>;
|
|
50
115
|
/** Offer the Upload button. Default true. */
|
|
51
116
|
readonly upload?: boolean;
|
|
52
|
-
/** Offer each file's
|
|
117
|
+
/** Offer version history in each file's actions menu. Default true. */
|
|
53
118
|
readonly history?: boolean;
|
|
119
|
+
/** Offer the Refresh button. Default true; a host whose tree follows the workspace's change feed (`refreshKey`) can hide it. */
|
|
120
|
+
readonly refresh?: boolean;
|
|
121
|
+
/** `list` (default) includes a Library header, inset search, metadata and file actions. `plain` keeps the compact tree. */
|
|
122
|
+
readonly variant?: 'plain' | 'list';
|
|
123
|
+
/** The list header's title, over the locale's Library label. */
|
|
124
|
+
readonly title?: string;
|
|
125
|
+
/** A host-owned dismissal action, shown at the right of the list header. */
|
|
126
|
+
readonly onClose?: () => void;
|
|
54
127
|
/** Reload the open folders and the current search whenever this value changes (for example after the agent saves a file). */
|
|
55
128
|
readonly refreshKey?: unknown;
|
|
56
129
|
/** Reload the open history panel whenever this value changes (for example the seq of the workspace's latest change event). */
|
|
@@ -62,5 +135,5 @@ export interface FileTreeViewProps extends Omit<FileTreeProps, 'revisionProvider
|
|
|
62
135
|
readonly controller: FileTreeController;
|
|
63
136
|
}
|
|
64
137
|
/** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
|
|
65
|
-
export declare function FileTreeView({ controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refreshKey, historyKey }: FileTreeViewProps): import("react").JSX.Element;
|
|
138
|
+
export declare function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refresh, variant, title, onClose, refreshKey, historyKey }: FileTreeViewProps): import("react").JSX.Element;
|
|
66
139
|
//# sourceMappingURL=file-tree-view.d.ts.map
|
package/dist/file-tree-view.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
4
|
-
import { ChevronRight, File, Folder } from 'lucide-react';
|
|
5
|
-
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub } from './file-tree-shadcn.js';
|
|
3
|
+
import { ChevronRight, Download, File, FileCode, FileImage, FileSpreadsheet, FileText, Folder, FolderOpen, History, LoaderCircle, MoreHorizontal, Search, Upload, X } from 'lucide-react';
|
|
6
4
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
7
5
|
import { createFileTreeController } from './file-tree.js';
|
|
8
6
|
/** Every word of the tree, so a host can translate it. Functions receive what they name. */
|
|
@@ -18,9 +16,23 @@ export const defaultFileTreeLabels = {
|
|
|
18
16
|
loading: 'Loading files…',
|
|
19
17
|
retry: 'Retry',
|
|
20
18
|
noMatches: 'No matching files.',
|
|
21
|
-
empty: 'No files
|
|
19
|
+
empty: 'No files yet',
|
|
20
|
+
emptyHint: 'Upload or ask the agent to create one',
|
|
21
|
+
library: 'Library',
|
|
22
|
+
close: 'Close Library',
|
|
23
|
+
open: 'Open',
|
|
24
|
+
download: 'Download',
|
|
25
|
+
downloadingFailed: 'The file could not be downloaded.',
|
|
26
|
+
actionsOf: (path) => `Actions for ${path}`,
|
|
27
|
+
shownItems: (count) => `${count} items shown`,
|
|
28
|
+
folder: 'Folder',
|
|
29
|
+
childCount: (count) => `${count} item${count === 1 ? '' : 's'}`,
|
|
30
|
+
file: 'File',
|
|
31
|
+
image: 'Image',
|
|
32
|
+
document: 'Document',
|
|
33
|
+
dropFiles: 'Drop files to upload',
|
|
22
34
|
loadMore: 'Load more',
|
|
23
|
-
history: '
|
|
35
|
+
history: 'Version history',
|
|
24
36
|
historyOf: (path) => `History of ${path}`,
|
|
25
37
|
closeHistory: 'Close history',
|
|
26
38
|
loadingHistory: 'Loading history…',
|
|
@@ -50,7 +62,21 @@ export const frenchFileTreeLabels = {
|
|
|
50
62
|
loading: 'Chargement des fichiers…',
|
|
51
63
|
retry: 'Réessayer',
|
|
52
64
|
noMatches: 'Aucun fichier ne correspond.',
|
|
53
|
-
empty: 'Aucun fichier
|
|
65
|
+
empty: 'Aucun fichier pour le moment',
|
|
66
|
+
emptyHint: 'Importez un fichier ou demandez à l’agent d’en créer un',
|
|
67
|
+
library: 'Bibliothèque',
|
|
68
|
+
close: 'Fermer la bibliothèque',
|
|
69
|
+
open: 'Ouvrir',
|
|
70
|
+
download: 'Télécharger',
|
|
71
|
+
downloadingFailed: 'Impossible de télécharger le fichier.',
|
|
72
|
+
actionsOf: path => `Actions pour ${path}`,
|
|
73
|
+
shownItems: count => `${count} éléments affichés`,
|
|
74
|
+
folder: 'Dossier',
|
|
75
|
+
childCount: count => `${count} élément${count === 1 ? '' : 's'}`,
|
|
76
|
+
file: 'Fichier',
|
|
77
|
+
image: 'Image',
|
|
78
|
+
document: 'Document',
|
|
79
|
+
dropFiles: 'Déposez les fichiers à importer',
|
|
54
80
|
loadMore: 'Afficher plus',
|
|
55
81
|
history: 'Historique',
|
|
56
82
|
historyOf: path => `Historique de ${path}`,
|
|
@@ -70,6 +96,24 @@ export const frenchFileTreeLabels = {
|
|
|
70
96
|
uploadFailed: name => `${name} : échec de l’import`,
|
|
71
97
|
};
|
|
72
98
|
const LOCALES = { en: defaultFileTreeLabels, fr: frenchFileTreeLabels };
|
|
99
|
+
function fileKind(path, labels) {
|
|
100
|
+
const extension = path.includes('.') ? path.slice(path.lastIndexOf('.') + 1).toLowerCase() : '';
|
|
101
|
+
if (['md', 'markdown'].includes(extension))
|
|
102
|
+
return { icon: FileText, label: 'Markdown' };
|
|
103
|
+
if (extension === 'pdf')
|
|
104
|
+
return { icon: FileText, label: 'PDF' };
|
|
105
|
+
if (['csv', 'xls', 'xlsx', 'tsv'].includes(extension))
|
|
106
|
+
return { icon: FileSpreadsheet, label: extension.toUpperCase() };
|
|
107
|
+
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(extension))
|
|
108
|
+
return { icon: FileImage, label: labels.image };
|
|
109
|
+
if (['json', 'js', 'mjs', 'ts', 'tsx', 'html', 'css', 'xml', 'yml', 'yaml'].includes(extension))
|
|
110
|
+
return { icon: FileCode, label: extension.toUpperCase() };
|
|
111
|
+
return { icon: extension ? FileText : File, label: extension === 'txt' ? labels.document : extension.toUpperCase() || labels.file };
|
|
112
|
+
}
|
|
113
|
+
function fileSize(bytes, locale) {
|
|
114
|
+
const unit = bytes < 1024 ? 0 : bytes < 1024 ** 2 ? 1 : bytes < 1024 ** 3 ? 2 : 3;
|
|
115
|
+
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: unit ? 1 : 0 }).format(bytes / 1024 ** unit)} ${['B', 'KB', 'MB', 'GB'][unit]}`;
|
|
116
|
+
}
|
|
73
117
|
/** One borrowed binding powers browsing, search, upload and history. Historical previews never replace an editor's draft. */
|
|
74
118
|
export function FileTree(props) {
|
|
75
119
|
return _jsx(BoundFileTree, { ...props }, JSON.stringify([props.revisionProvider.workspace, props.revisionProvider.identity]));
|
|
@@ -85,7 +129,9 @@ function BoundFileTree(props) {
|
|
|
85
129
|
return owned?.provider === props.revisionProvider ? _jsx(FileTreeView, { ...props, controller: owned.controller }) : _jsx("p", { role: "status", children: props.labels?.loading ?? LOCALES[props.locale ?? 'en'].loading });
|
|
86
130
|
}
|
|
87
131
|
/** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
|
|
88
|
-
export function FileTreeView({ controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refreshKey, historyKey }) {
|
|
132
|
+
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 }) {
|
|
133
|
+
const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty } = components;
|
|
134
|
+
const list = variant === 'list';
|
|
89
135
|
const labels = { ...LOCALES[locale], ...given };
|
|
90
136
|
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
91
137
|
const shownKey = useRef(refreshKey);
|
|
@@ -101,9 +147,41 @@ export function FileTreeView({ controller, onOpen, selectedPath, className, uplo
|
|
|
101
147
|
void controller.refresh(directory);
|
|
102
148
|
}, [controller, refreshKey]);
|
|
103
149
|
const tree = useRef(null);
|
|
150
|
+
const fileInput = useRef(null);
|
|
151
|
+
const dragDepth = useRef(0);
|
|
152
|
+
const [dropping, setDropping] = useState(false);
|
|
104
153
|
const [focused, setFocused] = useState('');
|
|
105
154
|
const [uploadError, setUploadError] = useState('');
|
|
106
155
|
const [historyPath, setHistoryPath] = useState();
|
|
156
|
+
const [localFailures, setLocalFailures] = useState(new Map());
|
|
157
|
+
const uploadFiles = async (files) => {
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
const path = uploadDirectory ? `${uploadDirectory}/${file.name}` : file.name;
|
|
160
|
+
setLocalFailures(previous => { const next = new Map(previous); next.delete(path); return next; });
|
|
161
|
+
try {
|
|
162
|
+
await controller.upload({ path, bytes: new Uint8Array(await file.arrayBuffer()), mediaType: file.type || 'application/octet-stream' });
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
setLocalFailures(previous => new Map(previous).set(path, { file, reason: error instanceof Error ? error.message : labels.uploadFailed(file.name) }));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const uploadRows = new Map();
|
|
170
|
+
for (const item of state.uploads) {
|
|
171
|
+
if (item.state.kind === 'pending')
|
|
172
|
+
uploadRows.set(item.path, { kind: 'pending', size: item.size });
|
|
173
|
+
else if (item.state.result.kind === 'committed')
|
|
174
|
+
uploadRows.delete(item.path);
|
|
175
|
+
else
|
|
176
|
+
uploadRows.set(item.path, { kind: 'failed', size: item.size,
|
|
177
|
+
reason: item.state.result.kind === 'partial' ? labels.unconfirmed : item.state.result.reason,
|
|
178
|
+
retryLabel: item.state.result.kind === 'unknown' ? labels.checkUpload : labels.retry,
|
|
179
|
+
retry: () => { void controller.retry(item.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); } });
|
|
180
|
+
}
|
|
181
|
+
for (const [path, failure] of localFailures)
|
|
182
|
+
uploadRows.set(path, { kind: 'failed', size: failure.file.size, reason: failure.reason,
|
|
183
|
+
retryLabel: labels.retry, retry: () => { void uploadFiles([failure.file]); } });
|
|
184
|
+
const uploadButton = _jsxs(Button, { type: "button", variant: "secondary", "data-file-upload": true, onClick: () => fileInput.current?.click(), children: [_jsx(Upload, { "aria-hidden": "true" }), labels.upload] });
|
|
107
185
|
const rows = [];
|
|
108
186
|
function flatten(directory, level) {
|
|
109
187
|
for (const entry of state.directories.get(directory)?.entries ?? []) {
|
|
@@ -117,6 +195,12 @@ export function FileTreeView({ controller, onOpen, selectedPath, className, uplo
|
|
|
117
195
|
rows.push({ entry, level: 1 });
|
|
118
196
|
else
|
|
119
197
|
flatten('', 1);
|
|
198
|
+
const visiblePaths = new Set(rows.map(row => row.entry.path));
|
|
199
|
+
const temporaryEntries = [...uploadRows].filter(([path]) => !visiblePaths.has(path) && path.toLowerCase().includes(state.query.toLowerCase()))
|
|
200
|
+
.map(([path, status]) => ({ path, kind: 'file', size: status.size }));
|
|
201
|
+
const temporaryPaths = new Set(temporaryEntries.map(entry => entry.path));
|
|
202
|
+
for (const entry of temporaryEntries)
|
|
203
|
+
rows.push({ entry, level: 1 });
|
|
120
204
|
const focusPath = rows.some(row => row.entry.path === focused) ? focused : rows[0]?.entry.path;
|
|
121
205
|
const focus = (path) => {
|
|
122
206
|
if (path === undefined)
|
|
@@ -164,7 +248,7 @@ export function FileTreeView({ controller, onOpen, selectedPath, className, uplo
|
|
|
164
248
|
case ' ':
|
|
165
249
|
if (entry.kind === 'directory')
|
|
166
250
|
void controller.toggle(entry.path);
|
|
167
|
-
else
|
|
251
|
+
else if (!temporaryPaths.has(entry.path))
|
|
168
252
|
onOpen?.(entry.path);
|
|
169
253
|
break;
|
|
170
254
|
default: return;
|
|
@@ -175,44 +259,93 @@ export function FileTreeView({ controller, onOpen, selectedPath, className, uplo
|
|
|
175
259
|
if (!listing || listing.kind === 'loading')
|
|
176
260
|
return _jsx("li", { role: "none", children: _jsx("span", { role: "status", children: labels.loading }) });
|
|
177
261
|
if (listing.kind === 'error')
|
|
178
|
-
return _jsxs("li", { role: "none", children: [_jsx("span", { role: "alert", children: listing.reason }), " ", _jsx(
|
|
179
|
-
return _jsxs(_Fragment, { children: [listing.entries.length === 0 &&
|
|
262
|
+
return _jsxs("li", { role: "none", children: [_jsx("span", { role: "alert", children: listing.reason }), " ", _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.search(state.query) : controller.refresh(directory)); }, children: labels.retry })] });
|
|
263
|
+
return _jsxs(_Fragment, { children: [listing.entries.length === 0 && (directory || temporaryEntries.length === 0) && _jsx("li", { role: "none", className: list && !directory ? undefined : 'file-tree-status', children: list && !directory
|
|
264
|
+
? _jsx(Empty, { className: "file-tree-empty", icon: _jsx(FolderOpen, { "aria-hidden": "true" }), title: search ? labels.noMatches : labels.empty, description: search ? undefined : labels.emptyHint, children: !search && upload && uploadButton })
|
|
265
|
+
: _jsx("span", { children: search ? labels.noMatches : labels.empty }) }), listing.cursor !== undefined && _jsx("li", { role: "none", children: _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.moreSearch() : controller.more(directory)); }, children: labels.loadMore }) })] });
|
|
180
266
|
};
|
|
181
267
|
const renderEntries = (directory, level) => _jsxs(_Fragment, { children: [(state.directories.get(directory)?.entries ?? []).map(entry => renderEntry(entry, level)), listingStatus(state.directories.get(directory), directory)] });
|
|
182
268
|
const renderEntry = (entry, level) => {
|
|
183
269
|
const folder = entry.kind === 'directory';
|
|
184
|
-
|
|
270
|
+
const temporary = temporaryPaths.has(entry.path);
|
|
271
|
+
const status = uploadRows.get(entry.path);
|
|
185
272
|
const name = entry.path.split('/').at(-1);
|
|
186
|
-
const parent = state.query && entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : '';
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
const
|
|
273
|
+
const parent = (state.query || temporary) && entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : '';
|
|
274
|
+
const children = folder ? state.directories.get(entry.path) : undefined;
|
|
275
|
+
const childCount = children?.kind === 'ready' && children.cursor === undefined ? labels.childCount(children.entries.length) : undefined;
|
|
276
|
+
const kind = folder ? { icon: Folder, label: labels.folder } : fileKind(entry.path, labels);
|
|
277
|
+
const Icon = kind.icon;
|
|
278
|
+
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)
|
|
279
|
+
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(entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent })] })] });
|
|
280
|
+
const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "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)
|
|
190
281
|
setFocused(entry.path); }, onKeyDown: event => { if (event.target === event.currentTarget)
|
|
191
|
-
keyboard(event, entry); }, className: "
|
|
192
|
-
return folder ? _jsx(
|
|
282
|
+
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);
|
|
283
|
+
return folder ? _jsx(CollapsibleRoot, { render: item, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); } }, entry.path) : item;
|
|
284
|
+
};
|
|
285
|
+
const visibleListing = state.query ? state.search : state.directories.get('');
|
|
286
|
+
const paginated = visibleListing?.kind === 'ready' && visibleListing.cursor !== undefined;
|
|
287
|
+
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')) {
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
dragDepth.current++;
|
|
290
|
+
setDropping(true);
|
|
291
|
+
} }, onDragOver: event => { if (upload && event.dataTransfer.types.includes('Files')) {
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
294
|
+
} }, onDragLeave: event => { if (upload && event.dataTransfer.types.includes('Files')) {
|
|
295
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
296
|
+
if (!dragDepth.current)
|
|
297
|
+
setDropping(false);
|
|
298
|
+
} }, onDrop: event => { if (upload && event.dataTransfer.types.includes('Files')) {
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
dragDepth.current = 0;
|
|
301
|
+
setDropping(false);
|
|
302
|
+
void uploadFiles(Array.from(event.dataTransfer.files));
|
|
303
|
+
} }, children: [list && _jsxs("header", { className: "file-tree-header", children: [_jsx("h2", { children: title ?? labels.library }), _jsxs("span", { className: "file-tree-count", "aria-label": labels.shownItems(rows.length), children: [rows.length, paginated ? '+' : ''] }), upload && uploadButton, onClose && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-file-close": true, "aria-label": labels.close, onClick: onClose, children: _jsx(X, { "aria-hidden": "true" }) })] }), _jsxs("div", { className: "file-tree-toolbar", children: [_jsxs("div", { className: "file-tree-search", children: [list && _jsx(Search, { "aria-hidden": "true" }), _jsx(Input, { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, value: state.query, onChange: event => { void controller.search(event.currentTarget.value); } })] }), refresh && _jsx(Button, { type: "button", variant: "ghost", "aria-label": labels.refresh, onClick: () => { void (state.query ? controller.search(state.query) : controller.refresh()); }, children: labels.refresh }), !list && upload && uploadButton] }), upload && _jsx(Input, { ref: fileInput, "aria-label": labels.uploadFiles, type: "file", multiple: true, onChange: event => {
|
|
304
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
305
|
+
event.currentTarget.value = '';
|
|
306
|
+
void uploadFiles(files);
|
|
307
|
+
} }), 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 }), _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)] });
|
|
308
|
+
}
|
|
309
|
+
function FileActions({ Actions, controller, entry, labels, onOpen, onHistory, onError }) {
|
|
310
|
+
const lifetime = useRef(undefined);
|
|
311
|
+
useEffect(() => () => lifetime.current?.abort(), []);
|
|
312
|
+
const download = async () => {
|
|
313
|
+
onError('');
|
|
314
|
+
lifetime.current?.abort();
|
|
315
|
+
const abort = new AbortController();
|
|
316
|
+
lifetime.current = abort;
|
|
317
|
+
try {
|
|
318
|
+
const result = await controller.revisionProvider.read({ target: controller.revisionProvider.locate(entry.path), revision: { kind: 'latest' } }, abort.signal);
|
|
319
|
+
if (abort.signal.aborted)
|
|
320
|
+
return;
|
|
321
|
+
if (result.kind !== 'available') {
|
|
322
|
+
onError(labels.downloadingFailed);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const url = URL.createObjectURL(new Blob([new Uint8Array(result.snapshot.bytes)], { type: result.snapshot.mediaType }));
|
|
326
|
+
const link = document.createElement('a');
|
|
327
|
+
link.href = url;
|
|
328
|
+
link.download = entry.path.split('/').at(-1) ?? entry.path;
|
|
329
|
+
document.body.append(link);
|
|
330
|
+
link.click();
|
|
331
|
+
link.remove();
|
|
332
|
+
// Give the browser time to consume the URL before releasing its bytes.
|
|
333
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
if (!abort.signal.aborted)
|
|
337
|
+
onError(labels.downloadingFailed);
|
|
338
|
+
}
|
|
193
339
|
};
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
for (const file of files) {
|
|
202
|
-
const path = uploadDirectory ? `${uploadDirectory}/${file.name}` : file.name;
|
|
203
|
-
try {
|
|
204
|
-
await controller.upload({ path, bytes: new Uint8Array(await file.arrayBuffer()), mediaType: file.type || 'application/octet-stream' });
|
|
205
|
-
}
|
|
206
|
-
catch (error) {
|
|
207
|
-
errors.push(error instanceof Error ? error.message : labels.uploadFailed(file.name));
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
if (errors.length)
|
|
211
|
-
setUploadError(errors.join(' '));
|
|
212
|
-
})();
|
|
213
|
-
} })] })] }), uploadError && _jsx("p", { role: "alert", children: uploadError }), _jsx(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "m-0 list-none p-2", children: state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1) }), state.uploads.length > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "list-none p-2 text-sm", children: state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [_jsxs("span", { 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.state.kind === 'settled' && upload.state.result.kind === 'unknown' && _jsx("button", { type: "button", onClick: () => { void controller.reconcile(upload.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); }, children: labels.checkUpload })] }, upload.operationId)) }), history && historyPath !== undefined && _jsx(FileHistory, { controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
|
|
340
|
+
const items = [];
|
|
341
|
+
if (onOpen)
|
|
342
|
+
items.push({ id: 'open', label: labels.open, icon: _jsx(FolderOpen, { "aria-hidden": "true" }), onSelect: onOpen });
|
|
343
|
+
if (onHistory)
|
|
344
|
+
items.push({ id: 'history', label: labels.history, icon: _jsx(History, { "aria-hidden": "true" }), onSelect: onHistory });
|
|
345
|
+
items.push({ id: 'download', label: labels.download, icon: _jsx(Download, { "aria-hidden": "true" }), onSelect: () => { void download(); } });
|
|
346
|
+
return _jsx(Actions, { label: labels.actionsOf(entry.path), icon: _jsx(MoreHorizontal, { "aria-hidden": "true" }), items: items });
|
|
214
347
|
}
|
|
215
|
-
function FileHistory({ controller, path, labels, reload, onClose }) {
|
|
348
|
+
function FileHistory({ components: { Button, Versions, Separator }, locale, controller, path, labels, reload, onClose }) {
|
|
216
349
|
const [versions, setVersions] = useState();
|
|
217
350
|
const [failure, setFailure] = useState('');
|
|
218
351
|
const [selected, setSelected] = useState();
|
|
@@ -252,5 +385,5 @@ function FileHistory({ controller, path, labels, reload, onClose }) {
|
|
|
252
385
|
}
|
|
253
386
|
catch { /* Binary history remains a byte-qualified read-only preview. */ }
|
|
254
387
|
}
|
|
255
|
-
return _jsxs("section", { "aria-label": labels.historyOf(path), className: "
|
|
388
|
+
return _jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsxs("section", { "aria-label": labels.historyOf(path), className: "file-tree-history", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("h3", { className: "font-medium", children: labels.historyOf(path) }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: labels.closeHistory })] }), failure && _jsx("p", { role: "alert", children: failure }), !versions && !failure && _jsx("p", { role: "status", children: labels.loadingHistory }), versions?.length === 0 && _jsx("p", { children: labels.noHistory }), versions && _jsx(Versions, { versions: versions, current: selected ?? versions[0]?.revision ?? '', onSelect: setSelected, locale: locale, earlierVersion: labels.earlierVersion }), preview && _jsxs("div", { "data-testid": "file-history-preview", "data-revision": preview.ref.revision, children: [_jsx("p", { children: labels.readOnlyRevision(preview.bytes.byteLength) }), text === undefined ? _jsx("p", { children: labels.binaryFile(preview.mediaType) }) : _jsx("pre", { className: "max-h-80 overflow-auto whitespace-pre-wrap", children: text })] })] })] });
|
|
256
389
|
}
|
package/dist/file-tree.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type FileListing = {
|
|
|
15
15
|
export interface FileUpload {
|
|
16
16
|
readonly operationId: string;
|
|
17
17
|
readonly path: string;
|
|
18
|
+
readonly size: number;
|
|
18
19
|
readonly state: {
|
|
19
20
|
readonly kind: 'pending';
|
|
20
21
|
} | {
|
|
@@ -47,6 +48,8 @@ export interface FileTreeController {
|
|
|
47
48
|
readonly signal?: AbortSignal;
|
|
48
49
|
}) => Promise<FileUpload>;
|
|
49
50
|
readonly reconcile: (operationId: string) => Promise<FileUpload>;
|
|
51
|
+
/** Retry a refused upload with its captured bytes. Unconfirmed uploads only reconcile the original operation. */
|
|
52
|
+
readonly retry: (operationId: string) => Promise<FileUpload>;
|
|
50
53
|
readonly history: (path: string, signal?: AbortSignal) => Promise<readonly SavedRevision[]>;
|
|
51
54
|
readonly dispose: () => void;
|
|
52
55
|
}
|
package/dist/file-tree.js
CHANGED
|
@@ -61,7 +61,7 @@ export function createFileTreeController({ revisionProvider }) {
|
|
|
61
61
|
const attempt = attempts.get(operationId);
|
|
62
62
|
if (!attempt)
|
|
63
63
|
throw new Error('Unknown upload operation');
|
|
64
|
-
const current = {
|
|
64
|
+
const current = { ...attempt.current, state: { kind: 'settled', result } };
|
|
65
65
|
attempt.current = current;
|
|
66
66
|
update({ ...state, uploads: state.uploads.map(item => item.operationId === operationId ? current : item) });
|
|
67
67
|
if (result.kind === 'committed' && state.lifecycle === 'active') {
|
|
@@ -150,7 +150,7 @@ export function createFileTreeController({ revisionProvider }) {
|
|
|
150
150
|
const request = publicationSnapshot({ operationId, atomicity: 'all-or-nothing', changes: [input.replace
|
|
151
151
|
? { kind: 'replace', target: input.replace, bytes, mediaType: input.mediaType ?? 'application/octet-stream' }
|
|
152
152
|
: { kind: 'create', target, expected: { kind: 'absent' }, bytes, mediaType: input.mediaType ?? 'application/octet-stream' }] });
|
|
153
|
-
const current = { operationId, path: input.path, state: { kind: 'pending' } };
|
|
153
|
+
const current = { operationId, path: input.path, size: bytes.byteLength, state: { kind: 'pending' } };
|
|
154
154
|
const digest = await publicationDigest(request);
|
|
155
155
|
active();
|
|
156
156
|
const concurrent = unresolved();
|
|
@@ -195,6 +195,21 @@ export function createFileTreeController({ revisionProvider }) {
|
|
|
195
195
|
};
|
|
196
196
|
return attempt.reconciling = run();
|
|
197
197
|
},
|
|
198
|
+
retry: operationId => {
|
|
199
|
+
active();
|
|
200
|
+
const attempt = attempts.get(operationId);
|
|
201
|
+
if (!attempt)
|
|
202
|
+
return Promise.reject(new Error('Unknown upload operation'));
|
|
203
|
+
if (attempt.current.state.kind === 'pending' || attempt.current.state.result.kind === 'committed')
|
|
204
|
+
return Promise.resolve(attempt.current);
|
|
205
|
+
if (attempt.current.state.result.kind === 'unknown')
|
|
206
|
+
return controller.reconcile(operationId);
|
|
207
|
+
const change = attempt.request.changes[0];
|
|
208
|
+
if (change.kind === 'delete')
|
|
209
|
+
return Promise.reject(new Error('An upload cannot delete a file'));
|
|
210
|
+
return controller.upload({ path: attempt.current.path, bytes: attempt.bytes, mediaType: change.mediaType,
|
|
211
|
+
...(change.kind === 'replace' ? { replace: change.target } : {}) });
|
|
212
|
+
},
|
|
198
213
|
history: (path, signal) => { active(); return revisionProvider.history(path, signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal); },
|
|
199
214
|
dispose: () => { if (state.lifecycle === 'disposed')
|
|
200
215
|
return; lifetime.abort(); for (const load of loads.values())
|