@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.
Files changed (57) hide show
  1. package/README.md +46 -18
  2. package/THIRD_PARTY_NOTICES.md +35 -0
  3. package/bin/boring-ui-blocks.mjs +286 -0
  4. package/dist/canvas-editor-native.d.ts +4 -1
  5. package/dist/canvas-editor-native.js +8 -6
  6. package/dist/canvas-editor.js +1 -1
  7. package/dist/canvas-proposals.d.ts +3 -1
  8. package/dist/canvas-proposals.js +4 -3
  9. package/dist/experience-document-viewer.d.ts +3 -0
  10. package/dist/experience-document-viewer.js +6 -5
  11. package/dist/experience.d.ts +3 -0
  12. package/dist/experience.js +2 -1
  13. package/dist/file-tree-view.d.ts +76 -3
  14. package/dist/file-tree-view.js +172 -39
  15. package/dist/file-tree.d.ts +3 -0
  16. package/dist/file-tree.js +17 -2
  17. package/dist/html-viewer.d.ts +4 -0
  18. package/dist/html-viewer.js +5 -3
  19. package/dist/markdown-autosave.d.ts +13 -0
  20. package/dist/markdown-autosave.js +94 -0
  21. package/dist/markdown-editor.d.ts +16 -0
  22. package/dist/markdown-editor.js +81 -17
  23. package/dist/markdown-extensions.js +9 -1
  24. package/dist/markdown-proposal-diff.d.ts +2 -1
  25. package/dist/markdown-proposal-diff.js +3 -2
  26. package/dist/markdown-safety.js +3 -1
  27. package/dist/native-chat.js +1 -1
  28. package/dist/remote-chat.d.ts +18 -0
  29. package/dist/remote-chat.js +149 -7
  30. package/dist/text-buffer.js +3 -2
  31. package/dist/text-draft-controls.d.ts +3 -1
  32. package/dist/text-draft-controls.js +3 -2
  33. package/dist/viewer-components.d.ts +14 -0
  34. package/dist/viewer-components.js +1 -0
  35. package/package.json +8 -4
  36. package/r/app-shell.json +39 -20
  37. package/r/button.json +19 -11
  38. package/r/dock.json +2 -2
  39. package/r/documents.json +17 -14
  40. package/r/feedback-preview.json +8 -2
  41. package/r/feedback.json +46 -325
  42. package/r/file-tree.json +14 -6
  43. package/r/html-viewer.json +8 -42
  44. package/r/markdown-editor.json +38 -87
  45. package/r/pi-ambient.json +15 -8
  46. package/r/pi-app.json +30 -24
  47. package/r/pi-chat.json +101 -70
  48. package/r/pi-workspace.json +7 -5
  49. package/r/provider-setup.json +9 -4
  50. package/r/registry.json +471 -893
  51. package/r/task-list-viewer.json +11 -21
  52. package/r/theme.json +23 -4
  53. package/r/version-history.json +24 -0
  54. package/r/viewers.json +139 -320
  55. package/styles/file-tree.css +41 -42
  56. package/dist/file-tree-shadcn.d.ts +0 -8
  57. package/dist/file-tree-shadcn.js +0 -14
@@ -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("button", { type: "button", disabled: blocked || stale, onClick: () => {
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("button", { type: "button", disabled: busy || state.lifecycle !== 'active', onClick: () => controller.actions.reject(proposal.id), children: "Dismiss" })] }, proposal.id);
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("button", { 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("button", { type: "button", disabled: disposed || pending || unknown, onClick: () => perform(controller.actions.refresh), children: "Refresh layout" }), _jsx("button", { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.reconcile), children: "Reconcile keep" }), _jsx("button", { type: "button", disabled: disposed || pending || unknown || state.remote === null, onClick: () => perform(controller.actions.discardToRemote), children: "Discard local layout" }), state.pin && _jsx("button", { type: "button", disabled: disposed || state.readOnly || pending || unknown, onClick: () => {
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("button", { type: "button", disabled: disposed || state.readOnly || !onRegenerate, onClick: () => {
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("button", { type: "button", disabled: disposed || state.readOnly, onClick: () => {
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("button", { 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
+ }, 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 });
@@ -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
@@ -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("button", { type: "button", onClick: () => setCurrent(offered), children: "Use proposed layout" })
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
  }
@@ -1,4 +1,6 @@
1
- import type { RevisionProvider } from '@hachej/boring-files/revision';
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 History button. Default true. */
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
@@ -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 { Collapsible } from 'radix-ui';
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 here.',
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: '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 ici.',
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("button", { type: "button", onClick: () => { void (search ? controller.search(state.query) : controller.refresh(directory)); }, children: labels.retry })] });
179
- return _jsxs(_Fragment, { children: [listing.entries.length === 0 && _jsx("li", { role: "none", children: _jsx("span", { children: search ? labels.noMatches : labels.empty }) }), listing.cursor !== undefined && _jsx("li", { role: "none", children: _jsx("button", { type: "button", onClick: () => { void (search ? controller.moreSearch() : controller.more(directory)); }, children: labels.loadMore }) })] });
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
- // A search result shows its name, with its folder beside it in muted text.
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 button = _jsxs(SidebarMenuButton, { tabIndex: -1, title: entry.path, isActive: selectedPath === entry.path, className: "min-w-0 flex-1 data-[active=true]:bg-transparent", onClick: () => { focus(entry.path); if (!folder)
188
- onOpen?.(entry.path); }, children: [folder ? _jsxs(_Fragment, { children: [_jsx(ChevronRight, { className: "transition-transform", "aria-hidden": "true" }), _jsx(Folder, { "aria-hidden": "true" })] }) : _jsx(File, { "aria-hidden": "true" }), _jsx("span", { className: "min-w-0 truncate", children: name }), parent && _jsx("span", { className: "min-w-0 shrink truncate text-xs text-muted-foreground", children: parent })] });
189
- const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "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)
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: "group/collapsible rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring [&[data-state=open]>div>button>svg:first-child]:rotate-90", children: [_jsxs("div", { className: "flex items-center gap-1", children: [folder ? _jsx(Collapsible.Trigger, { asChild: true, children: button }) : button, !folder && history && _jsx("button", { type: "button", "aria-label": labels.historyOf(entry.path), className: "px-2 py-1 text-xs", onClick: () => setHistoryPath(entry.path), children: labels.history })] }), folder && !state.query && _jsx(Collapsible.Content, { asChild: true, children: _jsx(SidebarMenuSub, { role: "group", children: renderEntries(entry.path, level + 1) }) })] }, entry.path);
192
- return folder ? _jsx(Collapsible.Root, { asChild: true, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); }, children: item }, entry.path) : item;
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
- return _jsxs("section", { "data-boring": "file-tree", className: className, "aria-label": labels.region, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 p-2", children: [_jsx("input", { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, className: "min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-sm", value: state.query, onChange: event => { void controller.search(event.currentTarget.value); } }), _jsx("button", { type: "button", onClick: () => { void (state.query ? controller.search(state.query) : controller.refresh()); }, children: labels.refresh }), upload && _jsxs("label", { className: "cursor-pointer text-sm", children: [labels.upload, _jsx("input", { "aria-label": labels.uploadFiles, type: "file", multiple: true, className: "sr-only", onChange: event => {
195
- const files = Array.from(event.currentTarget.files ?? []);
196
- event.currentTarget.value = '';
197
- setUploadError('');
198
- void (async () => {
199
- // One refused or unreadable file does not stop the others; every refusal is reported.
200
- const errors = [];
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: "border-t border-border p-2 text-sm", 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", 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 }), _jsx("ul", { className: "m-0 list-none p-0", children: versions?.map(version => _jsx("li", { children: _jsxs("button", { type: "button", "aria-pressed": selected === version.revision, onClick: () => setSelected(version.revision), children: [version.savedAt ? new Date(version.savedAt).toLocaleString() : labels.earlierVersion, " \u00B7 ", version.revision.slice(0, 8)] }) }, version.revision)) }), 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 })] })] });
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
  }
@@ -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 = { operationId, path: attempt.current.path, state: { kind: 'settled', result } };
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())