@calliopelabs/ui 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -1
- package/dist/agent/AgentChat.d.ts +8 -1
- package/dist/agent/AgentChat.js +43 -2
- package/dist/agent/WorkflowRenderers.d.ts +61 -0
- package/dist/agent/WorkflowRenderers.js +102 -0
- package/dist/agent/controller.d.ts +19 -0
- package/dist/agent/controller.js +33 -0
- package/dist/agent/copilotkit/CopilotKitAgentProvider.js +2 -2
- package/dist/agent/index.d.ts +4 -2
- package/dist/agent/index.js +3 -1
- package/dist/agent/mock.d.ts +26 -4
- package/dist/agent/mock.js +68 -37
- package/dist/agent/renderers.d.ts +49 -0
- package/dist/agent/renderers.js +27 -0
- package/dist/appearance/brands.d.ts +17 -0
- package/dist/appearance/brands.js +14 -0
- package/dist/chat/MessageBubble.js +1 -1
- package/dist/chat/MessageList.d.ts +9 -2
- package/dist/chat/MessageList.js +11 -8
- package/dist/css/appearance.js +1 -1
- package/dist/css/calliope.css +5 -0
- package/dist/css/tokens.css +5 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/primitives/Badge.d.ts +14 -0
- package/dist/primitives/Badge.js +25 -0
- package/dist/primitives/ContextMenu.d.ts +18 -0
- package/dist/primitives/ContextMenu.js +34 -0
- package/dist/primitives/ScrollArea.d.ts +16 -0
- package/dist/primitives/ScrollArea.js +11 -0
- package/dist/primitives/Toast.d.ts +37 -0
- package/dist/primitives/Toast.js +43 -0
- package/dist/primitives/Tooltip.d.ts +16 -0
- package/dist/primitives/Tooltip.js +26 -0
- package/dist/provider/messages.d.ts +69 -0
- package/dist/provider/messages.js +60 -0
- package/dist/theme/css-layer.js +2 -1
- package/dist/theme/tokens.generated.d.ts +3 -0
- package/dist/theme/tokens.generated.js +3 -0
- package/dist/workbench/FileBrowser.d.ts +62 -0
- package/dist/workbench/FileBrowser.js +117 -0
- package/dist/workbench/FileGrid.d.ts +13 -0
- package/dist/workbench/FileGrid.js +114 -0
- package/dist/workbench/FileIcon.d.ts +10 -0
- package/dist/workbench/FileIcon.js +24 -0
- package/dist/workbench/FileList.d.ts +35 -0
- package/dist/workbench/FileList.js +160 -0
- package/dist/workbench/FilePreview.d.ts +66 -0
- package/dist/workbench/FilePreview.js +108 -0
- package/dist/workbench/FileTree.d.ts +37 -0
- package/dist/workbench/FileTree.js +218 -0
- package/dist/workbench/UploadDropzone.d.ts +52 -0
- package/dist/workbench/UploadDropzone.js +115 -0
- package/dist/workbench/dnd.d.ts +17 -0
- package/dist/workbench/dnd.js +77 -0
- package/dist/workbench/index.d.ts +16 -0
- package/dist/workbench/index.js +16 -0
- package/dist/workbench/model.d.ts +72 -0
- package/dist/workbench/model.js +214 -0
- package/dist/workbench/selection.d.ts +16 -0
- package/dist/workbench/selection.js +46 -0
- package/dist/workbench/typeahead.d.ts +15 -0
- package/dist/workbench/typeahead.js +22 -0
- package/package.json +5 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronRightIcon, RotateCwIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "../primitives/ContextMenu.js";
|
|
7
|
+
import { Button } from "../primitives/Button.js";
|
|
8
|
+
import { Skeleton } from "../primitives/Skeleton.js";
|
|
9
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
10
|
+
import { startFileDrag, useFileDrop } from "./dnd.js";
|
|
11
|
+
import { FileIcon } from "./FileIcon.js";
|
|
12
|
+
import { parentPath } from "./model.js";
|
|
13
|
+
import { typeahead } from "./typeahead.js";
|
|
14
|
+
/**
|
|
15
|
+
* A lazy file tree on the WAI-ARIA tree pattern: one tab stop, arrows move
|
|
16
|
+
* and open, Home and End jump, typing a letter finds the next name. The
|
|
17
|
+
* host owns the data: it answers `onLoad` with a branch per directory, so
|
|
18
|
+
* the tree fetches nothing and works over any backend.
|
|
19
|
+
*/
|
|
20
|
+
export function FileTree({ label, root, branches = {}, onLoad, onRetry, expanded: expandedProp, defaultExpanded, onExpandedChange, selectedPath = null, onSelect, showFiles = false, onMove, contextMenu, className, }) {
|
|
21
|
+
const { messages } = useUI();
|
|
22
|
+
const t = messages.workbench;
|
|
23
|
+
const [ownExpanded, setOwnExpanded] = React.useState(() => new Set(defaultExpanded ?? []));
|
|
24
|
+
const expanded = expandedProp ?? ownExpanded;
|
|
25
|
+
const [focusKey, setFocusKey] = React.useState(null);
|
|
26
|
+
const [menuItem, setMenuItem] = React.useState(null);
|
|
27
|
+
const nodes = React.useRef(new Map());
|
|
28
|
+
const pendingFocus = React.useRef(null);
|
|
29
|
+
const requested = React.useRef(new Set());
|
|
30
|
+
const search = React.useRef({ text: "", at: 0 });
|
|
31
|
+
const rootDrop = useFileDrop("", onMove);
|
|
32
|
+
const shown = React.useCallback((items) => showFiles ? items : items.filter((item) => item.kind === "directory"), [showFiles]);
|
|
33
|
+
const rows = React.useMemo(() => {
|
|
34
|
+
const out = [];
|
|
35
|
+
const walk = (items, level) => {
|
|
36
|
+
const list = shown(items);
|
|
37
|
+
list.forEach((item, i) => {
|
|
38
|
+
const branch = branches[item.path];
|
|
39
|
+
const leaf = item.kind !== "directory" ||
|
|
40
|
+
(branch?.state === "ready" && shown(branch.items).length === 0);
|
|
41
|
+
const open = !leaf && expanded.has(item.path);
|
|
42
|
+
out.push({
|
|
43
|
+
type: "item",
|
|
44
|
+
key: item.path,
|
|
45
|
+
item,
|
|
46
|
+
level,
|
|
47
|
+
size: list.length,
|
|
48
|
+
pos: i + 1,
|
|
49
|
+
open,
|
|
50
|
+
leaf,
|
|
51
|
+
});
|
|
52
|
+
if (!open)
|
|
53
|
+
return;
|
|
54
|
+
if (branch?.state === "ready")
|
|
55
|
+
walk(branch.items, level + 1);
|
|
56
|
+
else if (branch?.state === "error")
|
|
57
|
+
out.push({
|
|
58
|
+
type: "error",
|
|
59
|
+
key: `error:${item.path}`,
|
|
60
|
+
path: item.path,
|
|
61
|
+
level: level + 1,
|
|
62
|
+
message: branch.message,
|
|
63
|
+
});
|
|
64
|
+
else
|
|
65
|
+
out.push({
|
|
66
|
+
type: "loading",
|
|
67
|
+
key: `loading:${item.path}`,
|
|
68
|
+
path: item.path,
|
|
69
|
+
level: level + 1,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
if (root.state === "ready")
|
|
74
|
+
walk(root.items, 1);
|
|
75
|
+
return out;
|
|
76
|
+
}, [root, branches, expanded, shown]);
|
|
77
|
+
// Ask for each open directory's children once; a branch arriving clears the request.
|
|
78
|
+
React.useEffect(() => {
|
|
79
|
+
for (const path of requested.current)
|
|
80
|
+
if (branches[path])
|
|
81
|
+
requested.current.delete(path);
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
if (row.type !== "loading" || branches[row.path] || requested.current.has(row.path))
|
|
84
|
+
continue;
|
|
85
|
+
requested.current.add(row.path);
|
|
86
|
+
onLoad?.(row.path);
|
|
87
|
+
}
|
|
88
|
+
}, [rows, branches, onLoad]);
|
|
89
|
+
React.useLayoutEffect(() => {
|
|
90
|
+
if (pendingFocus.current === null)
|
|
91
|
+
return;
|
|
92
|
+
nodes.current.get(pendingFocus.current)?.focus();
|
|
93
|
+
pendingFocus.current = null;
|
|
94
|
+
});
|
|
95
|
+
const setExpanded = (next) => {
|
|
96
|
+
if (!expandedProp)
|
|
97
|
+
setOwnExpanded(next);
|
|
98
|
+
onExpandedChange?.(next);
|
|
99
|
+
};
|
|
100
|
+
const toggle = (path, open) => {
|
|
101
|
+
if (expanded.has(path) === open)
|
|
102
|
+
return;
|
|
103
|
+
const next = new Set(expanded);
|
|
104
|
+
if (open)
|
|
105
|
+
next.add(path);
|
|
106
|
+
else
|
|
107
|
+
next.delete(path);
|
|
108
|
+
setExpanded(next);
|
|
109
|
+
};
|
|
110
|
+
const tabKey = (focusKey !== null && rows.some((r) => r.key === focusKey) && focusKey) ||
|
|
111
|
+
(selectedPath !== null && rows.some((r) => r.key === selectedPath) && selectedPath) ||
|
|
112
|
+
rows[0]?.key;
|
|
113
|
+
const focusRow = (key) => {
|
|
114
|
+
if (key === undefined)
|
|
115
|
+
return;
|
|
116
|
+
setFocusKey(key);
|
|
117
|
+
pendingFocus.current = key;
|
|
118
|
+
};
|
|
119
|
+
const activate = (row) => {
|
|
120
|
+
if (row.type === "error")
|
|
121
|
+
onRetry?.(row.path);
|
|
122
|
+
else if (row.type === "item") {
|
|
123
|
+
onSelect?.(row.item);
|
|
124
|
+
if (row.item.kind === "directory" && !row.leaf)
|
|
125
|
+
toggle(row.item.path, true);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const onKeyDown = (event) => {
|
|
129
|
+
const index = rows.findIndex((r) => r.key === tabKey);
|
|
130
|
+
const row = rows[index];
|
|
131
|
+
if (!row)
|
|
132
|
+
return;
|
|
133
|
+
const handled = () => {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
event.stopPropagation();
|
|
136
|
+
};
|
|
137
|
+
switch (event.key) {
|
|
138
|
+
case "ArrowDown":
|
|
139
|
+
handled();
|
|
140
|
+
focusRow(rows[Math.min(rows.length - 1, index + 1)]?.key);
|
|
141
|
+
return;
|
|
142
|
+
case "ArrowUp":
|
|
143
|
+
handled();
|
|
144
|
+
focusRow(rows[Math.max(0, index - 1)]?.key);
|
|
145
|
+
return;
|
|
146
|
+
case "Home":
|
|
147
|
+
handled();
|
|
148
|
+
focusRow(rows[0]?.key);
|
|
149
|
+
return;
|
|
150
|
+
case "End":
|
|
151
|
+
handled();
|
|
152
|
+
focusRow(rows[rows.length - 1]?.key);
|
|
153
|
+
return;
|
|
154
|
+
case "ArrowRight":
|
|
155
|
+
handled();
|
|
156
|
+
if (row.type !== "item" || row.leaf)
|
|
157
|
+
return;
|
|
158
|
+
if (!row.open)
|
|
159
|
+
toggle(row.item.path, true);
|
|
160
|
+
else
|
|
161
|
+
focusRow(rows[index + 1]?.key);
|
|
162
|
+
return;
|
|
163
|
+
case "ArrowLeft": {
|
|
164
|
+
handled();
|
|
165
|
+
if (row.type === "item" && row.open) {
|
|
166
|
+
toggle(row.item.path, false);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const parent = row.type === "item" ? parentPath(row.item.path) : row.path;
|
|
170
|
+
if (parent !== "" && rows.some((r) => r.key === parent))
|
|
171
|
+
focusRow(parent);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
case "Enter":
|
|
175
|
+
case " ":
|
|
176
|
+
handled();
|
|
177
|
+
activate(row);
|
|
178
|
+
return;
|
|
179
|
+
default: {
|
|
180
|
+
const names = rows.map((r) => (r.type === "item" ? r.item.name : ""));
|
|
181
|
+
const next = typeahead(search.current, event, names, index);
|
|
182
|
+
if (next !== null) {
|
|
183
|
+
handled();
|
|
184
|
+
focusRow(rows[next]?.key);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
if (root.state !== "ready" || rows.length === 0) {
|
|
190
|
+
return (_jsx("div", { className: cn("p-2 text-sm text-muted-foreground", className), ...rootDrop.props, children: root.state === "loading" ? (_jsx("div", { role: "status", "aria-label": t.loading, className: "flex flex-col gap-2 p-1", children: Array.from({ length: 5 }, (_, i) => (_jsx(Skeleton, { className: cn("h-4", i % 2 ? "w-2/3" : "w-4/5") }, i))) })) : root.state === "error" ? (_jsxs("div", { role: "alert", className: "flex flex-col items-start gap-2 p-1", children: [_jsx("p", { className: "[overflow-wrap:anywhere]", children: root.message || t.loadFailed }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: () => onRetry(""), children: t.retry }))] })) : (_jsx("p", { className: "p-1", children: showFiles ? t.empty : t.noFolders })) }));
|
|
191
|
+
}
|
|
192
|
+
const tree = (_jsx("ul", { role: "tree", "aria-label": label ?? t.folders, onKeyDown: onKeyDown, ...rootDrop.props, className: cn("flex min-h-full flex-col gap-px p-1 text-sm outline-none", rootDrop.over && "bg-primary/5", className), children: rows.map((row) => row.type === "item" ? (_jsx(TreeNode, { row: row, nodeRef: (el) => {
|
|
193
|
+
if (el)
|
|
194
|
+
nodes.current.set(row.key, el);
|
|
195
|
+
else
|
|
196
|
+
nodes.current.delete(row.key);
|
|
197
|
+
}, tabbable: row.key === tabKey, selected: row.item.path === selectedPath, onFocus: () => setFocusKey(row.key), onActivate: () => activate(row), onToggle: () => toggle(row.item.path, !row.open), onContextMenu: () => setMenuItem(row.item), onMove: onMove, dragLabel: row.item.name }, row.key)) : (_jsx("li", { ref: (el) => {
|
|
198
|
+
if (el)
|
|
199
|
+
nodes.current.set(row.key, el);
|
|
200
|
+
else
|
|
201
|
+
nodes.current.delete(row.key);
|
|
202
|
+
}, role: "treeitem", "aria-level": row.level, "aria-selected": false, "aria-disabled": row.type === "loading" || undefined, "aria-busy": row.type === "loading" || undefined, tabIndex: row.key === tabKey ? 0 : -1, onFocus: () => setFocusKey(row.key), onClick: () => activate(row), style: { paddingInlineStart: `${row.level * 0.75 + 0.75}rem` }, className: cn("flex h-7 items-center gap-1.5 rounded-lg pr-2 text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50", row.type === "error" && "cursor-pointer text-danger-fg hover:bg-accent"), children: row.type === "loading" ? (t.loading) : (_jsxs(_Fragment, { children: [_jsx(RotateCwIcon, { "aria-hidden": true, className: "size-3" }), _jsx("span", { className: "truncate", children: t.retryFolder })] })) }, row.key))) }));
|
|
203
|
+
if (!contextMenu)
|
|
204
|
+
return tree;
|
|
205
|
+
return (_jsxs(ContextMenu, { onOpenChange: (open) => {
|
|
206
|
+
if (!open)
|
|
207
|
+
setMenuItem(null);
|
|
208
|
+
}, children: [_jsx(ContextMenuTrigger, { asChild: true, children: tree }), menuItem && _jsx(ContextMenuContent, { children: contextMenu(menuItem) })] }));
|
|
209
|
+
}
|
|
210
|
+
function TreeNode({ row, nodeRef, tabbable, selected, onFocus, onActivate, onToggle, onContextMenu, onMove, dragLabel, }) {
|
|
211
|
+
const { item, level, size, pos, open, leaf } = row;
|
|
212
|
+
const drop = useFileDrop(item.kind === "directory" ? item.path : null, onMove);
|
|
213
|
+
return (_jsxs("li", { ref: nodeRef, role: "treeitem", "aria-level": level, "aria-setsize": size, "aria-posinset": pos, "aria-expanded": leaf ? undefined : open, "aria-selected": selected, tabIndex: tabbable ? 0 : -1, draggable: Boolean(onMove), onDragStart: onMove ? (event) => startFileDrag(event, [item.path], dragLabel) : undefined, ...drop.props, onFocus: onFocus, onClick: onActivate, onContextMenu: onContextMenu, style: { paddingInlineStart: `${(level - 1) * 0.75 + 0.25}rem` }, className: cn("flex h-7 shrink-0 cursor-default items-center gap-1 rounded-lg pr-2 outline-none select-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50", selected && "bg-accent font-medium text-accent-foreground", drop.over && "bg-primary/10 ring-2 ring-primary"), children: [_jsx("span", { "aria-hidden": true, onClick: (event) => {
|
|
214
|
+
event.stopPropagation();
|
|
215
|
+
if (!leaf)
|
|
216
|
+
onToggle();
|
|
217
|
+
}, className: cn("inline-flex size-4 shrink-0 items-center justify-center rounded text-muted-foreground", leaf && "invisible"), children: _jsx(ChevronRightIcon, { className: cn("size-3 transition-transform", open && "rotate-90") }) }), _jsx(FileIcon, { name: item.name, kind: item.kind, open: open }), _jsx("span", { className: "truncate", children: item.name })] }));
|
|
218
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type UploadState } from "./model.js";
|
|
3
|
+
/** One upload as the host tracks it. */
|
|
4
|
+
export interface UploadItem {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
size?: number | null;
|
|
8
|
+
/** 0 to 100; null or absent while the size of the work is unknown. */
|
|
9
|
+
progress?: number | null;
|
|
10
|
+
state: UploadState;
|
|
11
|
+
/** Why it failed, for `error`. */
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface UploadRejection {
|
|
15
|
+
file: File;
|
|
16
|
+
reason: "too-large" | "type";
|
|
17
|
+
}
|
|
18
|
+
export interface UploadDropzoneProps {
|
|
19
|
+
/** Files dropped or picked that pass `accept` and `maxSize`. The host uploads them. */
|
|
20
|
+
onFiles: (files: File[]) => void;
|
|
21
|
+
/** Files refused by `accept` or `maxSize`. */
|
|
22
|
+
onReject?: (rejections: UploadRejection[]) => void;
|
|
23
|
+
/** As the file input's `accept`: extensions (`.csv`) and MIME types (`image/*`), comma-separated. */
|
|
24
|
+
accept?: string;
|
|
25
|
+
/** Bytes. */
|
|
26
|
+
maxSize?: number;
|
|
27
|
+
multiple?: boolean;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
/** The uploads to show, with their progress. */
|
|
30
|
+
uploads?: readonly UploadItem[];
|
|
31
|
+
/** Cancel a queued or running upload. */
|
|
32
|
+
onCancel?: (id: string) => void;
|
|
33
|
+
/** Clear a finished, failed or cancelled upload from the list. */
|
|
34
|
+
onDismiss?: (id: string) => void;
|
|
35
|
+
/**
|
|
36
|
+
* The area to drop onto (a whole file browser): the dropzone wraps it,
|
|
37
|
+
* draws an overlay while files hover, and floats the progress in a
|
|
38
|
+
* corner. Without children it draws its own box with a browse button.
|
|
39
|
+
*/
|
|
40
|
+
children?: React.ReactNode;
|
|
41
|
+
/** The box's prompt. Defaults to "Drop files here". */
|
|
42
|
+
label?: string;
|
|
43
|
+
className?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Drop files from the desktop, or pick them, and see each upload's
|
|
47
|
+
* progress. Host-driven: it hands the files to `onFiles` and draws the
|
|
48
|
+
* `uploads` the host reports, so any transport (chunked, presigned, a
|
|
49
|
+
* mutation) fits. Drags between the browser's own views are not files and
|
|
50
|
+
* pass through.
|
|
51
|
+
*/
|
|
52
|
+
export declare function UploadDropzone({ onFiles, onReject, accept, maxSize, multiple, disabled, uploads, onCancel, onDismiss, children, label, className, }: UploadDropzoneProps): React.JSX.Element;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { CheckIcon, UploadIcon, XIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { Button } from "../primitives/Button.js";
|
|
7
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
8
|
+
import { formatBytes } from "./model.js";
|
|
9
|
+
function accepts(file, accept) {
|
|
10
|
+
if (!accept)
|
|
11
|
+
return true;
|
|
12
|
+
const name = file.name.toLowerCase();
|
|
13
|
+
const type = file.type.toLowerCase();
|
|
14
|
+
return accept
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((part) => part.trim().toLowerCase())
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.some((rule) => rule.startsWith(".")
|
|
19
|
+
? name.endsWith(rule)
|
|
20
|
+
: rule.endsWith("/*")
|
|
21
|
+
? type.startsWith(rule.slice(0, -1))
|
|
22
|
+
: type === rule);
|
|
23
|
+
}
|
|
24
|
+
const hasFiles = (event) => event.dataTransfer.types.includes("Files");
|
|
25
|
+
/**
|
|
26
|
+
* Drop files from the desktop, or pick them, and see each upload's
|
|
27
|
+
* progress. Host-driven: it hands the files to `onFiles` and draws the
|
|
28
|
+
* `uploads` the host reports, so any transport (chunked, presigned, a
|
|
29
|
+
* mutation) fits. Drags between the browser's own views are not files and
|
|
30
|
+
* pass through.
|
|
31
|
+
*/
|
|
32
|
+
export function UploadDropzone({ onFiles, onReject, accept, maxSize, multiple = true, disabled = false, uploads = [], onCancel, onDismiss, children, label, className, }) {
|
|
33
|
+
const { messages } = useUI();
|
|
34
|
+
const t = messages.workbench;
|
|
35
|
+
const input = React.useRef(null);
|
|
36
|
+
const depth = React.useRef(0);
|
|
37
|
+
const [over, setOver] = React.useState(false);
|
|
38
|
+
const overlay = children !== undefined;
|
|
39
|
+
const take = (list) => {
|
|
40
|
+
if (!list || disabled)
|
|
41
|
+
return;
|
|
42
|
+
const files = Array.from(list).slice(0, multiple ? undefined : 1);
|
|
43
|
+
const ok = [];
|
|
44
|
+
const rejected = [];
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
if (!accepts(file, accept))
|
|
47
|
+
rejected.push({ file, reason: "type" });
|
|
48
|
+
else if (maxSize !== undefined && file.size > maxSize)
|
|
49
|
+
rejected.push({ file, reason: "too-large" });
|
|
50
|
+
else
|
|
51
|
+
ok.push(file);
|
|
52
|
+
}
|
|
53
|
+
if (ok.length)
|
|
54
|
+
onFiles(ok);
|
|
55
|
+
if (rejected.length)
|
|
56
|
+
onReject?.(rejected);
|
|
57
|
+
};
|
|
58
|
+
const dropProps = disabled
|
|
59
|
+
? {}
|
|
60
|
+
: {
|
|
61
|
+
onDragEnter: (event) => {
|
|
62
|
+
if (!hasFiles(event))
|
|
63
|
+
return;
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
depth.current++;
|
|
66
|
+
setOver(true);
|
|
67
|
+
},
|
|
68
|
+
onDragOver: (event) => {
|
|
69
|
+
if (!hasFiles(event))
|
|
70
|
+
return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
event.dataTransfer.dropEffect = "copy";
|
|
73
|
+
},
|
|
74
|
+
onDragLeave: (event) => {
|
|
75
|
+
if (!hasFiles(event))
|
|
76
|
+
return;
|
|
77
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
78
|
+
if (depth.current === 0)
|
|
79
|
+
setOver(false);
|
|
80
|
+
},
|
|
81
|
+
onDrop: (event) => {
|
|
82
|
+
if (!hasFiles(event))
|
|
83
|
+
return;
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
depth.current = 0;
|
|
86
|
+
setOver(false);
|
|
87
|
+
take(event.dataTransfer.files);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const picker = (_jsx("input", { ref: input, type: "file", hidden: true, tabIndex: -1, accept: accept, multiple: multiple, onChange: (event) => {
|
|
91
|
+
take(event.target.files);
|
|
92
|
+
event.target.value = "";
|
|
93
|
+
} }));
|
|
94
|
+
const progress = uploads.length > 0 && (_jsx(UploadList, { uploads: uploads, onCancel: onCancel, onDismiss: onDismiss, className: overlay ? "absolute right-3 bottom-3 z-20 w-72 shadow-md" : "mt-3" }));
|
|
95
|
+
if (overlay) {
|
|
96
|
+
return (_jsxs("div", { "data-slot": "upload-dropzone", ...dropProps, className: cn("relative flex min-h-0 flex-col", className), children: [children, picker, over && (_jsx("div", { "aria-hidden": true, className: "pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-lg border-2 border-dashed border-primary bg-primary/10", children: _jsxs("span", { className: "flex flex-col items-center gap-2 rounded-lg bg-background/80 px-4 py-3 font-medium text-primary", children: [_jsx(UploadIcon, { "aria-hidden": true, className: "size-6" }), t.dropToUpload] }) })), progress] }));
|
|
97
|
+
}
|
|
98
|
+
return (_jsxs("div", { "data-slot": "upload-dropzone", className: className, children: [_jsxs("div", { ...dropProps, "aria-disabled": disabled || undefined, className: cn("flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-6 py-8 text-center text-sm text-muted-foreground transition-colors", over && "border-primary bg-primary/10 text-primary", disabled && "opacity-50"), children: [_jsx(UploadIcon, { "aria-hidden": true, className: "size-6" }), _jsx("p", { className: "font-medium text-foreground", children: label ?? t.dropHere }), _jsx(Button, { type: "button", size: "sm", variant: "outline", disabled: disabled, onClick: () => input.current?.click(), children: t.browse }), (accept || maxSize !== undefined) && (_jsx("p", { className: "text-xs", children: [accept, maxSize !== undefined && t.maxSize(formatBytes(maxSize))]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join(" · ") })), picker] }), progress] }));
|
|
101
|
+
}
|
|
102
|
+
function UploadList({ uploads, onCancel, onDismiss, className, }) {
|
|
103
|
+
const { messages } = useUI();
|
|
104
|
+
const t = messages.workbench;
|
|
105
|
+
const running = uploads.filter((u) => u.state === "queued" || u.state === "uploading").length;
|
|
106
|
+
return (_jsxs("section", { "aria-label": t.uploads, className: cn("rounded-lg border bg-popover p-3 text-popover-foreground", className), children: [_jsx("h3", { className: "mb-2 text-xs font-medium", "aria-live": "polite", children: running > 0 ? t.uploading(running) : t.uploadsDone }), _jsx("ul", { className: "flex max-h-60 flex-col gap-2 overflow-auto", children: uploads.map((u) => {
|
|
107
|
+
const active = u.state === "queued" || u.state === "uploading";
|
|
108
|
+
const pct = u.state === "done" ? 100 : Math.round(Math.min(100, Math.max(0, u.progress ?? 0)));
|
|
109
|
+
return (_jsxs("li", { "data-state": u.state, className: "flex flex-col gap-1 text-xs", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", title: u.name, children: u.name }), _jsx("span", { className: cn("shrink-0 tabular-nums", u.state === "error" ? "text-danger-fg" : "text-muted-foreground"), children: u.state === "uploading" && u.progress != null
|
|
110
|
+
? `${pct}%`
|
|
111
|
+
: t.uploadState(u.state) }), u.state === "done" && (_jsx(CheckIcon, { "aria-hidden": true, className: "size-3.5 text-success-fg" })), active && onCancel && (_jsx("button", { type: "button", "aria-label": t.cancelUpload(u.name), onClick: () => onCancel(u.id), className: "inline-flex size-5 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50", children: _jsx(XIcon, { "aria-hidden": true, className: "size-3" }) })), !active && onDismiss && (_jsx("button", { type: "button", "aria-label": t.dismissUpload(u.name), onClick: () => onDismiss(u.id), className: "inline-flex size-5 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50", children: _jsx(XIcon, { "aria-hidden": true, className: "size-3" }) }))] }), active && (_jsx("div", { role: "progressbar", "aria-label": u.name, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": u.progress != null ? pct : undefined, className: "h-1 overflow-hidden rounded-full bg-muted", children: _jsx("div", { className: cn("h-full rounded-full bg-primary transition-[width]", u.state === "uploading" &&
|
|
112
|
+
u.progress == null &&
|
|
113
|
+
"w-1/3 animate-pulse motion-reduce:animate-none"), style: u.progress != null || u.state === "queued" ? { width: `${pct}%` } : undefined }) })), u.state === "error" && u.error && (_jsx("p", { className: "[overflow-wrap:anywhere] text-danger-fg", children: u.error }))] }, u.id));
|
|
114
|
+
}) })] }));
|
|
115
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** The data type of a drag between the browser's own views. Uploads are `Files` and never match. */
|
|
3
|
+
export declare const FILE_DRAG_TYPE = "application/x-calliope-files";
|
|
4
|
+
export type MoveHandler = (sources: string[], target: string, options: {
|
|
5
|
+
copy: boolean;
|
|
6
|
+
}) => void;
|
|
7
|
+
/** Starts an internal drag of `paths`, with a small label as its image. */
|
|
8
|
+
export declare function startFileDrag(event: React.DragEvent, paths: readonly string[], label: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Makes an element a drop target for the directory `target`. Holding Alt
|
|
11
|
+
* (Option) copies instead of moving. The sources are only readable on drop,
|
|
12
|
+
* so a hover cannot tell a no-op; `canDrop` filters it out then.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useFileDrop(target: string | null, onMove: MoveHandler | undefined): {
|
|
15
|
+
over: boolean;
|
|
16
|
+
props: React.HTMLAttributes<HTMLElement>;
|
|
17
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { canDrop } from "./model.js";
|
|
4
|
+
/** The data type of a drag between the browser's own views. Uploads are `Files` and never match. */
|
|
5
|
+
export const FILE_DRAG_TYPE = "application/x-calliope-files";
|
|
6
|
+
/** Starts an internal drag of `paths`, with a small label as its image. */
|
|
7
|
+
export function startFileDrag(event, paths, label) {
|
|
8
|
+
event.dataTransfer.setData(FILE_DRAG_TYPE, JSON.stringify(paths));
|
|
9
|
+
event.dataTransfer.effectAllowed = "copyMove";
|
|
10
|
+
const ghost = document.createElement("div");
|
|
11
|
+
ghost.className =
|
|
12
|
+
"fixed -top-96 left-0 rounded-lg bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10";
|
|
13
|
+
ghost.textContent = label;
|
|
14
|
+
document.body.appendChild(ghost);
|
|
15
|
+
event.dataTransfer.setDragImage(ghost, 0, 0);
|
|
16
|
+
requestAnimationFrame(() => ghost.remove());
|
|
17
|
+
}
|
|
18
|
+
function readPaths(event) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(event.dataTransfer.getData(FILE_DRAG_TYPE));
|
|
21
|
+
return Array.isArray(parsed) && parsed.every((p) => typeof p === "string") ? parsed : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Makes an element a drop target for the directory `target`. Holding Alt
|
|
29
|
+
* (Option) copies instead of moving. The sources are only readable on drop,
|
|
30
|
+
* so a hover cannot tell a no-op; `canDrop` filters it out then.
|
|
31
|
+
*/
|
|
32
|
+
export function useFileDrop(target, onMove) {
|
|
33
|
+
const [over, setOver] = React.useState(false);
|
|
34
|
+
const depth = React.useRef(0);
|
|
35
|
+
const enabled = target !== null && Boolean(onMove);
|
|
36
|
+
const accepts = (event) => enabled && event.dataTransfer.types.includes(FILE_DRAG_TYPE);
|
|
37
|
+
const props = enabled
|
|
38
|
+
? {
|
|
39
|
+
onDragEnter: (event) => {
|
|
40
|
+
if (!accepts(event))
|
|
41
|
+
return;
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
depth.current++;
|
|
45
|
+
setOver(true);
|
|
46
|
+
},
|
|
47
|
+
onDragOver: (event) => {
|
|
48
|
+
if (!accepts(event))
|
|
49
|
+
return;
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
event.stopPropagation();
|
|
52
|
+
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
|
|
53
|
+
},
|
|
54
|
+
onDragLeave: (event) => {
|
|
55
|
+
if (!accepts(event))
|
|
56
|
+
return;
|
|
57
|
+
event.stopPropagation();
|
|
58
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
59
|
+
if (depth.current === 0)
|
|
60
|
+
setOver(false);
|
|
61
|
+
},
|
|
62
|
+
onDrop: (event) => {
|
|
63
|
+
if (!accepts(event))
|
|
64
|
+
return;
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
depth.current = 0;
|
|
68
|
+
setOver(false);
|
|
69
|
+
const sources = readPaths(event);
|
|
70
|
+
const copy = Boolean(event.altKey);
|
|
71
|
+
if (sources && target !== null && canDrop(sources, target, copy))
|
|
72
|
+
onMove?.(sources, target, { copy });
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
: {};
|
|
76
|
+
return { over, props };
|
|
77
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@calliopelabs/ui/workbench`: the pieces of an IDE-style tool that more
|
|
3
|
+
* than one Calliope AI product draws. Opt-in, like `./agent`: an app that
|
|
4
|
+
* has no file browser never loads it. Everything is host-driven: data comes
|
|
5
|
+
* in as props, every change goes out through a callback, nothing fetches.
|
|
6
|
+
* Styles come from the package preset (`@calliopelabs/ui/preset.css`).
|
|
7
|
+
*/
|
|
8
|
+
export { canDrop, fileExtension, formatBytes, isWithin, joinPath, parentPath, parseDelimited, pathSegments, previewKindFor, sortFiles, type FileBranch, type FileItem, type FileKind, type FileSort, type FileSortKey, type FileView, type PreviewKind, type UploadState, } from "./model.js";
|
|
9
|
+
export { FILE_DRAG_TYPE, type MoveHandler } from "./dnd.js";
|
|
10
|
+
export { FileIcon, type FileIconProps } from "./FileIcon.js";
|
|
11
|
+
export { FileTree, type FileTreeProps } from "./FileTree.js";
|
|
12
|
+
export { FileList, type FileListProps, type FileViewProps } from "./FileList.js";
|
|
13
|
+
export { FileGrid, type FileGridProps } from "./FileGrid.js";
|
|
14
|
+
export { FileBrowser, type FileBrowserProps } from "./FileBrowser.js";
|
|
15
|
+
export { UploadDropzone, type UploadDropzoneProps, type UploadItem, type UploadRejection, } from "./UploadDropzone.js";
|
|
16
|
+
export { FilePreview, type FilePreviewContent, type FilePreviewProps } from "./FilePreview.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@calliopelabs/ui/workbench`: the pieces of an IDE-style tool that more
|
|
3
|
+
* than one Calliope AI product draws. Opt-in, like `./agent`: an app that
|
|
4
|
+
* has no file browser never loads it. Everything is host-driven: data comes
|
|
5
|
+
* in as props, every change goes out through a callback, nothing fetches.
|
|
6
|
+
* Styles come from the package preset (`@calliopelabs/ui/preset.css`).
|
|
7
|
+
*/
|
|
8
|
+
export { canDrop, fileExtension, formatBytes, isWithin, joinPath, parentPath, parseDelimited, pathSegments, previewKindFor, sortFiles, } from "./model.js";
|
|
9
|
+
export { FILE_DRAG_TYPE } from "./dnd.js";
|
|
10
|
+
export { FileIcon } from "./FileIcon.js";
|
|
11
|
+
export { FileTree } from "./FileTree.js";
|
|
12
|
+
export { FileList } from "./FileList.js";
|
|
13
|
+
export { FileGrid } from "./FileGrid.js";
|
|
14
|
+
export { FileBrowser } from "./FileBrowser.js";
|
|
15
|
+
export { UploadDropzone, } from "./UploadDropzone.js";
|
|
16
|
+
export { FilePreview } from "./FilePreview.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file browser's data: plain values the host maps its own listing onto.
|
|
3
|
+
* Pure (no "use client"), so a server can sort or build fixtures with it.
|
|
4
|
+
*/
|
|
5
|
+
export type FileKind = "file" | "directory";
|
|
6
|
+
/** One entry of a listing. `path` is its identity: unique, `/`-separated, no leading slash. */
|
|
7
|
+
export interface FileItem {
|
|
8
|
+
path: string;
|
|
9
|
+
name: string;
|
|
10
|
+
kind: FileKind;
|
|
11
|
+
/** Bytes. Absent or null for directories and unknown sizes. */
|
|
12
|
+
size?: number | null;
|
|
13
|
+
/** ISO 8601 or a Date. */
|
|
14
|
+
modified?: string | Date | null;
|
|
15
|
+
mimeType?: string | null;
|
|
16
|
+
}
|
|
17
|
+
export type FileSortKey = "name" | "size" | "modified" | "kind";
|
|
18
|
+
export interface FileSort {
|
|
19
|
+
key: FileSortKey;
|
|
20
|
+
dir: "asc" | "desc";
|
|
21
|
+
}
|
|
22
|
+
export type FileView = "list" | "grid";
|
|
23
|
+
/** Where an upload stands. */
|
|
24
|
+
export type UploadState = "queued" | "uploading" | "done" | "error" | "canceled";
|
|
25
|
+
/**
|
|
26
|
+
* A directory's children as the host knows them. Absent from the map means
|
|
27
|
+
* "never asked for": the tree calls `onLoad` when it first opens.
|
|
28
|
+
*/
|
|
29
|
+
export type FileBranch = {
|
|
30
|
+
state: "loading";
|
|
31
|
+
} | {
|
|
32
|
+
state: "error";
|
|
33
|
+
message?: string;
|
|
34
|
+
} | {
|
|
35
|
+
state: "ready";
|
|
36
|
+
items: readonly FileItem[];
|
|
37
|
+
};
|
|
38
|
+
/** The parent directory of a path; the root is "". */
|
|
39
|
+
export declare function parentPath(path: string): string;
|
|
40
|
+
/** A path under a directory; the root is "". */
|
|
41
|
+
export declare function joinPath(dir: string, name: string): string;
|
|
42
|
+
/** True when `path` is `ancestor` or lies under it. */
|
|
43
|
+
export declare function isWithin(path: string, ancestor: string): boolean;
|
|
44
|
+
/** The lower-case extension without the dot, or "" (".bashrc" has none). */
|
|
45
|
+
export declare function fileExtension(name: string): string;
|
|
46
|
+
/** The breadcrumb trail of a path: the root, then each directory down to it. */
|
|
47
|
+
export declare function pathSegments(path: string): {
|
|
48
|
+
name: string;
|
|
49
|
+
path: string;
|
|
50
|
+
}[];
|
|
51
|
+
/**
|
|
52
|
+
* Whether moving `sources` into `target` does anything sensible: never into
|
|
53
|
+
* itself or its own subtree, and a move into the folder it is already in is
|
|
54
|
+
* a no-op. Copies (`copy`) may land in the same folder.
|
|
55
|
+
*/
|
|
56
|
+
export declare function canDrop(sources: readonly string[], target: string, copy?: boolean): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* A listing in display order: directories first, then by the key, ties and
|
|
59
|
+
* missing values by name. Natural order, so "file10" follows "file9".
|
|
60
|
+
*/
|
|
61
|
+
export declare function sortFiles(items: readonly FileItem[], sort: FileSort): FileItem[];
|
|
62
|
+
/** Bytes for people: 0 B, 912 B, 1.5 KB, 23 MB (1024-based, as file managers show). */
|
|
63
|
+
export declare function formatBytes(bytes: number, locale?: string): string;
|
|
64
|
+
/** What a preview can show for a file, from its name and MIME type. */
|
|
65
|
+
export type PreviewKind = "csv" | "json" | "image" | "markdown" | "text" | "binary";
|
|
66
|
+
export declare function previewKindFor(name: string, mimeType?: string | null): PreviewKind;
|
|
67
|
+
/**
|
|
68
|
+
* Parses CSV (RFC 4180: quoted fields, doubled quotes, newlines inside
|
|
69
|
+
* quotes) or, with `delimiter: "\t"`, TSV. Returns rows of strings; the
|
|
70
|
+
* caller decides whether the first row is a header.
|
|
71
|
+
*/
|
|
72
|
+
export declare function parseDelimited(text: string, delimiter?: string): string[][];
|