@calliopelabs/ui 0.13.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 +192 -4
- package/dist/agent/AgentChat.d.ts +63 -0
- package/dist/agent/AgentChat.js +117 -0
- package/dist/agent/AgentLauncher.d.ts +25 -0
- package/dist/agent/AgentLauncher.js +25 -0
- package/dist/agent/AgentProvider.d.ts +82 -0
- package/dist/agent/AgentProvider.js +176 -0
- package/dist/agent/CalliopeAgent.d.ts +43 -0
- package/dist/agent/CalliopeAgent.js +111 -0
- package/dist/agent/WorkflowRenderers.d.ts +61 -0
- package/dist/agent/WorkflowRenderers.js +102 -0
- package/dist/agent/controller.d.ts +167 -0
- package/dist/agent/controller.js +496 -0
- package/dist/agent/copilotkit/CopilotKitAgentProvider.d.ts +41 -0
- package/dist/agent/copilotkit/CopilotKitAgentProvider.js +145 -0
- package/dist/agent/copilotkit/index.d.ts +6 -0
- package/dist/agent/copilotkit/index.js +6 -0
- package/dist/agent/engine.d.ts +41 -0
- package/dist/agent/engine.js +93 -0
- package/dist/agent/index.d.ts +17 -0
- package/dist/agent/index.js +17 -0
- package/dist/agent/mock.d.ts +56 -0
- package/dist/agent/mock.js +143 -0
- package/dist/agent/renderers.d.ts +49 -0
- package/dist/agent/renderers.js +27 -0
- package/dist/agent/surface.d.ts +30 -0
- package/dist/agent/surface.js +90 -0
- package/dist/agent/tool.d.ts +98 -0
- package/dist/agent/tool.js +81 -0
- package/dist/appearance/brands.d.ts +31 -12
- package/dist/appearance/brands.js +32 -51
- package/dist/chat/AgentProgress.d.ts +19 -0
- package/dist/chat/AgentProgress.js +34 -0
- package/dist/chat/Composer.d.ts +31 -0
- package/dist/chat/Composer.js +44 -0
- package/dist/chat/MessageBubble.d.ts +23 -0
- package/dist/chat/MessageBubble.js +26 -0
- package/dist/chat/MessageList.d.ts +43 -0
- package/dist/chat/MessageList.js +71 -0
- package/dist/chat/ModelPicker.d.ts +17 -0
- package/dist/chat/ModelPicker.js +13 -0
- package/dist/chat/ToolCallCard.d.ts +27 -0
- package/dist/chat/ToolCallCard.js +52 -0
- package/dist/chat/model.d.ts +123 -0
- package/dist/chat/model.js +337 -0
- package/dist/chat/patch.d.ts +12 -0
- package/dist/chat/patch.js +149 -0
- package/dist/chat/useAgUiRun.d.ts +52 -0
- package/dist/chat/useAgUiRun.js +137 -0
- 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 +15 -1
- package/dist/index.js +16 -1
- package/dist/primitives/Badge.d.ts +14 -0
- package/dist/primitives/Badge.js +25 -0
- package/dist/primitives/Button.d.ts +1 -1
- 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 +135 -0
- package/dist/provider/messages.js +131 -0
- package/dist/theme/css-layer.js +2 -1
- package/dist/theme/mappings/palette.d.ts +52 -0
- package/dist/theme/mappings/palette.js +208 -0
- package/dist/theme/mappings/render.d.ts +9 -0
- package/dist/theme/mappings/render.js +520 -0
- package/dist/theme/mappings/targets.d.ts +43 -0
- package/dist/theme/mappings/targets.js +222 -0
- package/dist/theme/tokens.d.ts +10 -9
- package/dist/theme/tokens.generated.d.ts +356 -0
- package/dist/theme/tokens.generated.js +360 -0
- package/dist/theme/tokens.js +20 -264
- package/dist/tokens/README.jupyterlab.md +125 -0
- package/dist/tokens/README.md +15 -0
- package/dist/tokens/README.native.md +29 -0
- package/dist/tokens/README.plain.md +84 -0
- package/dist/tokens/README.python.md +33 -0
- package/dist/tokens/README.vscode.md +114 -0
- package/dist/tokens/jupyterlab.css +1710 -0
- package/dist/tokens/plain.css +930 -0
- package/dist/tokens/tokens.native.d.ts +85 -0
- package/dist/tokens/tokens.native.js +773 -0
- package/dist/tokens/tokens.py +1043 -0
- package/dist/tokens/vscode.css +1138 -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 +39 -2
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { FileQuestionIcon, XIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { DataTable } from "../data/DataTable.js";
|
|
6
|
+
import { useLocalTable } from "../data/use-local-table.js";
|
|
7
|
+
import { cn } from "../lib/cn.js";
|
|
8
|
+
import { Button } from "../primitives/Button.js";
|
|
9
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
10
|
+
import { EmptyState } from "../states/EmptyState.js";
|
|
11
|
+
import { ErrorState } from "../states/ErrorState.js";
|
|
12
|
+
import { LoadingState } from "../states/LoadingState.js";
|
|
13
|
+
import { FileIcon } from "./FileIcon.js";
|
|
14
|
+
import { formatBytes, parseDelimited } from "./model.js";
|
|
15
|
+
function TablePreview({ name, columns, rows, }) {
|
|
16
|
+
const { messages } = useUI();
|
|
17
|
+
const t = messages.workbench;
|
|
18
|
+
const controller = useLocalTable(rows, {
|
|
19
|
+
searchText: (row) => row.cells.join(" "),
|
|
20
|
+
pageSize: 50,
|
|
21
|
+
sortBy: Object.fromEntries(columns.map((_, i) => [String(i), (row) => row.cells[i] ?? ""])),
|
|
22
|
+
});
|
|
23
|
+
const tableColumns = columns.map((header, i) => ({
|
|
24
|
+
id: String(i),
|
|
25
|
+
header: header || t.column(i + 1),
|
|
26
|
+
sortKey: String(i),
|
|
27
|
+
cellClassName: "max-w-64 truncate",
|
|
28
|
+
cell: (row) => row.cells[i] ?? "",
|
|
29
|
+
}));
|
|
30
|
+
return (_jsx(DataTable, { label: name, controller: controller, columns: tableColumns, getRowId: (row) => row.id, empty: { title: t.noRows }, density: "compact" }));
|
|
31
|
+
}
|
|
32
|
+
function stringify(value) {
|
|
33
|
+
if (value == null)
|
|
34
|
+
return "";
|
|
35
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A file's contents in a side panel: CSV and tables in the library's
|
|
39
|
+
* DataTable (search, sort, paging), JSON pretty-printed, images, markdown
|
|
40
|
+
* through the host's renderer, and text. It reads nothing itself: the host
|
|
41
|
+
* fetches the bytes and hands over the content.
|
|
42
|
+
*/
|
|
43
|
+
export function FilePreview({ name, size, mimeType, content, error, onRetry, truncated, renderMarkdown, onClose, actions, className, }) {
|
|
44
|
+
const { messages } = useUI();
|
|
45
|
+
const t = messages.workbench;
|
|
46
|
+
const table = React.useMemo(() => {
|
|
47
|
+
if (content?.kind === "csv") {
|
|
48
|
+
const parsed = parseDelimited(content.text, content.delimiter);
|
|
49
|
+
const header = content.header !== false;
|
|
50
|
+
const width = Math.max(0, ...parsed.map((r) => r.length));
|
|
51
|
+
const columns = header ? (parsed[0] ?? []) : Array.from({ length: width }, () => "");
|
|
52
|
+
const body = header ? parsed.slice(1) : parsed;
|
|
53
|
+
return { columns, rows: body.map((cells, i) => ({ id: String(i), cells })) };
|
|
54
|
+
}
|
|
55
|
+
if (content?.kind === "table")
|
|
56
|
+
return {
|
|
57
|
+
columns: content.columns,
|
|
58
|
+
rows: content.rows.map((row, i) => ({
|
|
59
|
+
id: String(i),
|
|
60
|
+
cells: content.columns.map((c) => stringify(row[c])),
|
|
61
|
+
})),
|
|
62
|
+
};
|
|
63
|
+
return null;
|
|
64
|
+
}, [content]);
|
|
65
|
+
const json = React.useMemo(() => {
|
|
66
|
+
if (content?.kind !== "json")
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.stringify(JSON.parse(content.text), null, 2);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return content.text;
|
|
73
|
+
}
|
|
74
|
+
}, [content]);
|
|
75
|
+
const errorMessage = typeof error === "string" ? error : error?.message;
|
|
76
|
+
let body;
|
|
77
|
+
if (error) {
|
|
78
|
+
body = (_jsx(ErrorState, { title: t.previewFailed, error: errorMessage || t.previewFailed, onRetry: onRetry, retryLabel: t.retry }));
|
|
79
|
+
}
|
|
80
|
+
else if (!content) {
|
|
81
|
+
body = _jsx(LoadingState, { label: t.previewLoading, rows: 4 });
|
|
82
|
+
}
|
|
83
|
+
else if (table) {
|
|
84
|
+
body = _jsx(TablePreview, { name: name, columns: table.columns, rows: table.rows });
|
|
85
|
+
}
|
|
86
|
+
else if (content.kind === "image") {
|
|
87
|
+
body = (_jsx("img", { src: content.src, alt: content.alt ?? name, className: "mx-auto max-h-full max-w-full rounded-lg object-contain" }));
|
|
88
|
+
}
|
|
89
|
+
else if (content.kind === "markdown" && renderMarkdown) {
|
|
90
|
+
body = _jsx("div", { className: "text-sm", children: renderMarkdown(content.text) });
|
|
91
|
+
}
|
|
92
|
+
else if (content.kind === "binary") {
|
|
93
|
+
body = (_jsx(EmptyState, { icon: _jsx(FileQuestionIcon, { "aria-hidden": true, className: "size-6" }), title: t.noPreview, description: [mimeType, size != null ? formatBytes(size) : null]
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join(" · ") }));
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
body = (_jsx("pre", {
|
|
99
|
+
// Long text scrolls the panel: focusable so a keyboard can reach and scroll it.
|
|
100
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
|
101
|
+
tabIndex: 0, "aria-label": t.contentsOf(name), className: "rounded-lg font-mono text-xs leading-relaxed break-words whitespace-pre-wrap outline-none focus-visible:ring-2 focus-visible:ring-ring/50", children: json ?? (content.kind === "text" || content.kind === "markdown" ? content.text : "") }));
|
|
102
|
+
}
|
|
103
|
+
const partial = truncated &&
|
|
104
|
+
(typeof truncated === "object"
|
|
105
|
+
? t.showing(truncated.shown, truncated.total ?? null)
|
|
106
|
+
: t.partial);
|
|
107
|
+
return (_jsxs("section", { "aria-label": t.previewOf(name), "data-slot": "file-preview", className: cn("flex min-h-0 min-w-0 flex-col bg-background", className), children: [_jsxs("header", { className: "flex min-h-11 items-center gap-2 border-b px-3 py-1.5", children: [_jsx(FileIcon, { name: name, kind: "file" }), _jsx("h2", { className: "min-w-0 flex-1 truncate text-sm font-medium", title: name, children: name }), size != null && (_jsx("span", { className: "shrink-0 text-xs text-muted-foreground", children: formatBytes(size) })), actions, onClose && (_jsx(Button, { variant: "ghost", size: "icon", className: "size-7", "aria-label": t.closePreview, onClick: onClose, children: _jsx(XIcon, { "aria-hidden": true }) }))] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-3", children: body }), partial && _jsx("p", { className: "border-t px-3 py-1.5 text-xs text-muted-foreground", children: partial })] }));
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type MoveHandler } from "./dnd.js";
|
|
3
|
+
import { type FileBranch, type FileItem } from "./model.js";
|
|
4
|
+
export interface FileTreeProps {
|
|
5
|
+
/** Names the tree. Defaults to "Folders". */
|
|
6
|
+
label?: string;
|
|
7
|
+
/** The top level. */
|
|
8
|
+
root: FileBranch;
|
|
9
|
+
/** Each loaded directory's children, by path. A directory missing here has not been asked for. */
|
|
10
|
+
branches?: Readonly<Record<string, FileBranch>>;
|
|
11
|
+
/** A directory opened with no branch yet: fetch it and put a `loading`, then `ready`, branch in `branches`. */
|
|
12
|
+
onLoad?: (path: string) => void;
|
|
13
|
+
/** Re-fetch after an error. `""` is the root. */
|
|
14
|
+
onRetry?: (path: string) => void;
|
|
15
|
+
/** Open directories. Controlled when given. */
|
|
16
|
+
expanded?: ReadonlySet<string>;
|
|
17
|
+
defaultExpanded?: Iterable<string>;
|
|
18
|
+
onExpandedChange?: (expanded: Set<string>) => void;
|
|
19
|
+
/** The current directory, drawn as selected. */
|
|
20
|
+
selectedPath?: string | null;
|
|
21
|
+
/** A node chosen by click, Enter or Space. */
|
|
22
|
+
onSelect?: (item: FileItem) => void;
|
|
23
|
+
/** Files as well as directories. Off by default: a folder tree. */
|
|
24
|
+
showFiles?: boolean;
|
|
25
|
+
/** Drag and drop between nodes; directories (and the tree's background, the root) are targets. */
|
|
26
|
+
onMove?: MoveHandler;
|
|
27
|
+
/** A node's context-menu items (`ContextMenuItem`s). */
|
|
28
|
+
contextMenu?: (item: FileItem) => React.ReactNode;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A lazy file tree on the WAI-ARIA tree pattern: one tab stop, arrows move
|
|
33
|
+
* and open, Home and End jump, typing a letter finds the next name. The
|
|
34
|
+
* host owns the data: it answers `onLoad` with a branch per directory, so
|
|
35
|
+
* the tree fetches nothing and works over any backend.
|
|
36
|
+
*/
|
|
37
|
+
export declare function FileTree({ label, root, branches, onLoad, onRetry, expanded: expandedProp, defaultExpanded, onExpandedChange, selectedPath, onSelect, showFiles, onMove, contextMenu, className, }: FileTreeProps): React.JSX.Element;
|
|
@@ -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";
|