@calliopelabs/ui 0.14.0 → 0.18.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 +235 -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 +9 -1
- package/dist/index.js +8 -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/Menubar.d.ts +21 -0
- package/dist/primitives/Menubar.js +43 -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 +217 -0
- package/dist/provider/messages.js +238 -0
- package/dist/shell/RailPanel.js +1 -1
- 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/ActivityBar.d.ts +37 -0
- package/dist/workbench/ActivityBar.js +57 -0
- package/dist/workbench/BottomPanel.d.ts +30 -0
- package/dist/workbench/BottomPanel.js +20 -0
- package/dist/workbench/CodeEditor.d.ts +48 -0
- package/dist/workbench/CodeEditor.js +153 -0
- package/dist/workbench/CommandPalette.d.ts +49 -0
- package/dist/workbench/CommandPalette.js +114 -0
- package/dist/workbench/DataChart.d.ts +45 -0
- package/dist/workbench/DataChart.js +146 -0
- package/dist/workbench/DataGrid.d.ts +89 -0
- package/dist/workbench/DataGrid.js +590 -0
- package/dist/workbench/DataProfile.d.ts +17 -0
- package/dist/workbench/DataProfile.js +51 -0
- package/dist/workbench/EditorTabs.d.ts +62 -0
- package/dist/workbench/EditorTabs.js +115 -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/GridCell.d.ts +19 -0
- package/dist/workbench/GridCell.js +50 -0
- package/dist/workbench/GridDetails.d.ts +31 -0
- package/dist/workbench/GridDetails.js +78 -0
- package/dist/workbench/MenuBar.d.ts +49 -0
- package/dist/workbench/MenuBar.js +25 -0
- package/dist/workbench/PaneGroup.d.ts +50 -0
- package/dist/workbench/PaneGroup.js +146 -0
- package/dist/workbench/PendingChangesBar.d.ts +20 -0
- package/dist/workbench/PendingChangesBar.js +19 -0
- package/dist/workbench/Shortcuts.d.ts +50 -0
- package/dist/workbench/Shortcuts.js +109 -0
- package/dist/workbench/SqlEditor.d.ts +21 -0
- package/dist/workbench/SqlEditor.js +79 -0
- package/dist/workbench/StatusBar.d.ts +28 -0
- package/dist/workbench/StatusBar.js +26 -0
- package/dist/workbench/TruncationBanner.d.ts +11 -0
- package/dist/workbench/TruncationBanner.js +12 -0
- package/dist/workbench/UploadDropzone.d.ts +52 -0
- package/dist/workbench/UploadDropzone.js +115 -0
- package/dist/workbench/WorkbenchShell.d.ts +36 -0
- package/dist/workbench/WorkbenchShell.js +20 -0
- package/dist/workbench/dnd.d.ts +17 -0
- package/dist/workbench/dnd.js +77 -0
- package/dist/workbench/download.d.ts +11 -0
- package/dist/workbench/download.js +28 -0
- package/dist/workbench/editor-theme.d.ts +42 -0
- package/dist/workbench/editor-theme.js +113 -0
- package/dist/workbench/grid-format.d.ts +59 -0
- package/dist/workbench/grid-format.js +169 -0
- package/dist/workbench/grid-model.d.ts +75 -0
- package/dist/workbench/grid-model.js +259 -0
- package/dist/workbench/grid-selection.d.ts +69 -0
- package/dist/workbench/grid-selection.js +183 -0
- package/dist/workbench/grid-stats.d.ts +58 -0
- package/dist/workbench/grid-stats.js +185 -0
- package/dist/workbench/index.d.ts +44 -0
- package/dist/workbench/index.js +47 -0
- package/dist/workbench/keys.d.ts +23 -0
- package/dist/workbench/keys.js +105 -0
- package/dist/workbench/model.d.ts +72 -0
- package/dist/workbench/model.js +214 -0
- package/dist/workbench/palette.d.ts +17 -0
- package/dist/workbench/palette.js +45 -0
- package/dist/workbench/pending.d.ts +92 -0
- package/dist/workbench/pending.js +207 -0
- package/dist/workbench/selection.d.ts +16 -0
- package/dist/workbench/selection.js +46 -0
- package/dist/workbench/sql-completion.d.ts +107 -0
- package/dist/workbench/sql-completion.js +475 -0
- package/dist/workbench/typeahead.d.ts +15 -0
- package/dist/workbench/typeahead.js +22 -0
- package/dist/workflow/WorkflowRuntimeControls.d.ts +21 -0
- package/dist/workflow/WorkflowRuntimeControls.js +54 -0
- package/dist/workflow/runtime-controls.d.ts +29 -0
- package/dist/workflow/runtime-controls.js +10 -0
- package/package.json +17 -2
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
6
|
+
import { EmptyState } from "../states/EmptyState.js";
|
|
7
|
+
import { columnSignature } from "./grid-model.js";
|
|
8
|
+
import { formatStatValue, profileColumn } from "./grid-stats.js";
|
|
9
|
+
import { TruncationBanner } from "./TruncationBanner.js";
|
|
10
|
+
/** Distinct counts and frequencies are a pass per column: past this the profile samples a prefix, and says so. */
|
|
11
|
+
const PROFILE_ROW_CAP = 10_000;
|
|
12
|
+
/**
|
|
13
|
+
* A card per column: completeness, distinct values, range, mean and median
|
|
14
|
+
* for numbers, and the most frequent values. Honest about its sample: a
|
|
15
|
+
* profile of the fetched rows is not a profile of the table.
|
|
16
|
+
*/
|
|
17
|
+
export function DataProfile(props) {
|
|
18
|
+
return _jsx(ProfileSurface, { ...props }, columnSignature(props.columns));
|
|
19
|
+
}
|
|
20
|
+
function ProfileSurface({ columns, rows, truncated, rowCap = PROFILE_ROW_CAP, className, }) {
|
|
21
|
+
const { messages } = useUI();
|
|
22
|
+
const t = messages.workbench;
|
|
23
|
+
const names = React.useMemo(() => columns.map((c) => (typeof c === "string" ? c : c.name)), [columns]);
|
|
24
|
+
const sample = React.useMemo(() => (rows.length > rowCap ? rows.slice(0, rowCap) : rows), [rows, rowCap]);
|
|
25
|
+
const profiles = React.useMemo(() => names.map((name) => profileColumn(name, sample.map((row) => row[name]))), [names, sample]);
|
|
26
|
+
if (rows.length === 0) {
|
|
27
|
+
return _jsx(EmptyState, { title: t.profileNoData, className: className });
|
|
28
|
+
}
|
|
29
|
+
const nulls = profiles.reduce((sum, p) => sum + p.nulls, 0);
|
|
30
|
+
const notes = [truncated, rows.length > rowCap ? t.profileCap(rowCap, rows.length) : null].filter(Boolean);
|
|
31
|
+
return (_jsxs("section", { "aria-label": t.profileLabel, "data-slot": "data-profile", className: cn("@container flex min-h-0 flex-col", className), children: [notes.length > 0 && _jsx(TruncationBanner, { children: notes.join(" ") }), _jsx("p", { className: "shrink-0 border-b bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground", children: t.profileSummary(sample.length, names.length, nulls) }), _jsx("ul", { className: "grid min-h-0 flex-1 grid-cols-1 content-start gap-3 overflow-auto p-3 @md:grid-cols-2 @3xl:grid-cols-3", children: profiles.map((profile) => (_jsx(ProfileCard, { profile: profile }, profile.name))) })] }));
|
|
32
|
+
}
|
|
33
|
+
function ProfileCard({ profile }) {
|
|
34
|
+
const { messages } = useUI();
|
|
35
|
+
const t = messages.workbench;
|
|
36
|
+
const complete = profile.count > 0 ? 1 - profile.nulls / profile.count : 0;
|
|
37
|
+
const rows = [
|
|
38
|
+
[t.gridStat("rows"), profile.count.toLocaleString()],
|
|
39
|
+
[t.gridStat("nulls"), profile.nulls.toLocaleString()],
|
|
40
|
+
[t.gridStat("distinct"), profile.distinct.toLocaleString()],
|
|
41
|
+
];
|
|
42
|
+
if (profile.min !== undefined)
|
|
43
|
+
rows.push([t.gridStat("min"), formatStatValue(profile.min)]);
|
|
44
|
+
if (profile.max !== undefined)
|
|
45
|
+
rows.push([t.gridStat("max"), formatStatValue(profile.max)]);
|
|
46
|
+
if (profile.mean !== undefined)
|
|
47
|
+
rows.push([t.profileMean, formatStatValue(profile.mean)]);
|
|
48
|
+
if (profile.median !== undefined)
|
|
49
|
+
rows.push([t.profileMedian, formatStatValue(profile.median)]);
|
|
50
|
+
return (_jsxs("li", { className: "min-w-0 space-y-3 rounded-lg border bg-card p-3 text-card-foreground", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("h3", { className: "min-w-0 flex-1 truncate font-mono text-sm font-medium", title: profile.name, children: profile.name }), _jsx("span", { className: "shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground", children: t.profileKind(profile.kind) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs text-muted-foreground", children: t.profileComplete(complete) }), _jsx("div", { className: "h-1.5 overflow-hidden rounded-full bg-muted", role: "presentation", children: _jsx("div", { className: "h-full rounded-full bg-chart-1", style: { width: `${complete * 100}%` } }) })] }), _jsx("dl", { className: "grid grid-cols-2 gap-x-4 gap-y-1 text-xs", children: rows.map(([label, value]) => (_jsxs("div", { className: "flex min-w-0 justify-between gap-2", children: [_jsx("dt", { className: "text-muted-foreground", children: label }), _jsx("dd", { className: "truncate tabular-nums", title: value, children: value })] }, label))) }), profile.top.length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs text-muted-foreground", children: t.profileTopValues }), _jsx("ol", { className: "space-y-1", children: profile.top.map((top) => (_jsxs("li", { className: "flex items-center gap-2 text-xs", children: [_jsxs("span", { className: "relative h-5 min-w-0 flex-1 overflow-hidden rounded-sm bg-muted", children: [_jsx("span", { "aria-hidden": true, className: "absolute inset-y-0 left-0 bg-chart-1/25", style: { width: `${top.share * 100}%` } }), _jsx("span", { className: "relative block truncate px-1.5 leading-5", title: top.value, children: top.value })] }), _jsx("span", { className: "w-12 shrink-0 text-right text-muted-foreground tabular-nums", children: top.count.toLocaleString() })] }, top.value))) })] }))] }));
|
|
51
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface EditorTab {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
icon?: React.ReactNode;
|
|
6
|
+
/** Unsaved changes: a dot in place of the close button, and "unsaved" in its name. */
|
|
7
|
+
dirty?: boolean;
|
|
8
|
+
/** Defaults to true. A welcome or home tab sets false. */
|
|
9
|
+
closable?: boolean;
|
|
10
|
+
/** Shown on hover: a full path, a server and database. Defaults to the title. */
|
|
11
|
+
hint?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface EditorTabsProps {
|
|
14
|
+
tabs: readonly EditorTab[];
|
|
15
|
+
/** The tab on screen. */
|
|
16
|
+
activeId: string | null;
|
|
17
|
+
onSelect: (id: string) => void;
|
|
18
|
+
/** Close a tab: its close button, a middle click, or Delete on it. */
|
|
19
|
+
onClose?: (id: string) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Move a tab from one index to another: dragging it, or Alt+Shift with
|
|
22
|
+
* Left or Right. Leave it out and the tabs stay in place.
|
|
23
|
+
*/
|
|
24
|
+
onReorder?: (from: number, to: number) => void;
|
|
25
|
+
/** Context-menu items (`ContextMenuItem`s) for a tab: close others, split, duplicate. */
|
|
26
|
+
contextMenu?: (tab: EditorTab) => React.ReactNode;
|
|
27
|
+
/** Controls after the strip: new tab, close group, a panel toggle. */
|
|
28
|
+
actions?: React.ReactNode;
|
|
29
|
+
/** The id of the panel the active tab shows; `EditorGroup` sets it. */
|
|
30
|
+
panelId?: string;
|
|
31
|
+
/** This group has the focus among several split groups: an accent line marks it. */
|
|
32
|
+
focused?: boolean;
|
|
33
|
+
/** The strip's name. Defaults to "Open editors". */
|
|
34
|
+
label?: string;
|
|
35
|
+
/** Prefix for the tabs' element ids. Defaults to a generated one. */
|
|
36
|
+
idPrefix?: string;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
/** A tab's element id, for a panel's `aria-labelledby`. */
|
|
40
|
+
export declare function editorTabId(prefix: string, tabId: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* The editor tab strip of an IDE layout, with the ARIA tabs pattern: one tab
|
|
43
|
+
* stop, Left and Right move and show, Home and End jump. Tabs carry an
|
|
44
|
+
* unsaved marker, close by button, middle click or Delete, reorder by drag
|
|
45
|
+
* or Alt+Shift+Left/Right, and the strip scrolls with a menu of every tab
|
|
46
|
+
* once it overflows. Host-driven: it holds no tab state of its own.
|
|
47
|
+
*/
|
|
48
|
+
export declare function EditorTabs({ tabs, activeId, onSelect, onClose, onReorder, contextMenu, actions, panelId, focused, label, idPrefix, className, }: EditorTabsProps): React.JSX.Element;
|
|
49
|
+
export interface EditorGroupProps extends Omit<EditorTabsProps, "panelId" | "idPrefix"> {
|
|
50
|
+
/** The active tab's content. */
|
|
51
|
+
children?: React.ReactNode;
|
|
52
|
+
/** Shown when no tab is open. */
|
|
53
|
+
empty?: React.ReactNode;
|
|
54
|
+
/** The group was clicked or focused: a split layout makes it the active group. */
|
|
55
|
+
onFocusGroup?: () => void;
|
|
56
|
+
panelClassName?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Tabs and the panel under them: one editor group. A split layout puts
|
|
60
|
+
* several in a `PaneGroup`, marking the one with focus.
|
|
61
|
+
*/
|
|
62
|
+
export declare function EditorGroup({ children, empty, onFocusGroup, panelClassName, className, ...tabs }: EditorGroupProps): React.JSX.Element;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronDownIcon, XIcon } 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../primitives/DropdownMenu.js";
|
|
8
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
9
|
+
const DRAG_TYPE = "application/x-calliope-editor-tab";
|
|
10
|
+
/** A tab's element id, for a panel's `aria-labelledby`. */
|
|
11
|
+
export function editorTabId(prefix, tabId) {
|
|
12
|
+
return `${prefix}-tab-${tabId}`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The editor tab strip of an IDE layout, with the ARIA tabs pattern: one tab
|
|
16
|
+
* stop, Left and Right move and show, Home and End jump. Tabs carry an
|
|
17
|
+
* unsaved marker, close by button, middle click or Delete, reorder by drag
|
|
18
|
+
* or Alt+Shift+Left/Right, and the strip scrolls with a menu of every tab
|
|
19
|
+
* once it overflows. Host-driven: it holds no tab state of its own.
|
|
20
|
+
*/
|
|
21
|
+
export function EditorTabs({ tabs, activeId, onSelect, onClose, onReorder, contextMenu, actions, panelId, focused = false, label, idPrefix, className, }) {
|
|
22
|
+
const { messages } = useUI();
|
|
23
|
+
const m = messages.workbench;
|
|
24
|
+
const generated = React.useId();
|
|
25
|
+
const prefix = idPrefix ?? generated;
|
|
26
|
+
const refs = React.useRef(new Map());
|
|
27
|
+
const [dropIndex, setDropIndex] = React.useState(null);
|
|
28
|
+
const tabStop = tabs.some((t) => t.id === activeId) ? activeId : tabs[0]?.id;
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
if (activeId)
|
|
31
|
+
refs.current.get(activeId)?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
|
32
|
+
}, [activeId]);
|
|
33
|
+
const focusTab = (index) => {
|
|
34
|
+
const tab = tabs[index];
|
|
35
|
+
if (!tab)
|
|
36
|
+
return;
|
|
37
|
+
onSelect(tab.id);
|
|
38
|
+
refs.current.get(tab.id)?.focus();
|
|
39
|
+
};
|
|
40
|
+
const onKeyDown = (index, tab) => (event) => {
|
|
41
|
+
const last = tabs.length - 1;
|
|
42
|
+
if (event.altKey && event.shiftKey && onReorder) {
|
|
43
|
+
const to = event.key === "ArrowLeft" ? index - 1 : event.key === "ArrowRight" ? index + 1 : -1;
|
|
44
|
+
if (to >= 0 && to <= last) {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
onReorder(index, to);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (event.key === "ArrowRight")
|
|
51
|
+
focusTab(index === last ? 0 : index + 1);
|
|
52
|
+
else if (event.key === "ArrowLeft")
|
|
53
|
+
focusTab(index === 0 ? last : index - 1);
|
|
54
|
+
else if (event.key === "Home")
|
|
55
|
+
focusTab(0);
|
|
56
|
+
else if (event.key === "End")
|
|
57
|
+
focusTab(last);
|
|
58
|
+
else if (event.key === "Delete" && onClose && tab.closable !== false)
|
|
59
|
+
onClose(tab.id);
|
|
60
|
+
else
|
|
61
|
+
return;
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
};
|
|
64
|
+
const strip = tabs.map((tab, index) => {
|
|
65
|
+
const active = tab.id === activeId;
|
|
66
|
+
const closable = tab.closable !== false && onClose !== undefined;
|
|
67
|
+
const item = (_jsxs("div", { role: "presentation", draggable: onReorder !== undefined, onDragStart: (event) => {
|
|
68
|
+
event.dataTransfer.setData(DRAG_TYPE, String(index));
|
|
69
|
+
event.dataTransfer.effectAllowed = "move";
|
|
70
|
+
}, onDragOver: (event) => {
|
|
71
|
+
if (!event.dataTransfer.types.includes(DRAG_TYPE))
|
|
72
|
+
return;
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
setDropIndex(index);
|
|
75
|
+
}, onDragLeave: () => setDropIndex((i) => (i === index ? null : i)), onDrop: (event) => {
|
|
76
|
+
const from = Number(event.dataTransfer.getData(DRAG_TYPE));
|
|
77
|
+
setDropIndex(null);
|
|
78
|
+
if (!Number.isNaN(from) && from !== index) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
onReorder?.(from, index);
|
|
81
|
+
}
|
|
82
|
+
}, onMouseDown: (event) => {
|
|
83
|
+
if (event.button === 1 && closable) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
onClose?.(tab.id);
|
|
86
|
+
}
|
|
87
|
+
}, className: cn("group relative flex h-full max-w-52 min-w-28 shrink-0 items-center border-r text-xs transition-colors", active
|
|
88
|
+
? "bg-background text-foreground"
|
|
89
|
+
: "bg-surface-1 text-muted-foreground hover:bg-surface-2 hover:text-foreground", dropIndex === index && "ring-2 ring-ring ring-inset"), children: [active && (_jsx("span", { "aria-hidden": true, className: cn("absolute inset-x-0 top-0 h-0.5", focused ? "bg-primary" : "bg-ring") })), _jsxs("button", { ref: (el) => {
|
|
90
|
+
if (el)
|
|
91
|
+
refs.current.set(tab.id, el);
|
|
92
|
+
else
|
|
93
|
+
refs.current.delete(tab.id);
|
|
94
|
+
}, type: "button", role: "tab", id: editorTabId(prefix, tab.id), "aria-selected": active, "aria-controls": active ? panelId : undefined, tabIndex: tab.id === tabStop ? 0 : -1, title: tab.hint ?? tab.title, onClick: () => onSelect(tab.id), onKeyDown: onKeyDown(index, tab), className: "flex h-full min-w-0 flex-1 items-center gap-1.5 pr-1 pl-3 outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", children: [tab.icon && (_jsx("span", { "aria-hidden": true, className: "flex shrink-0 [&_svg]:size-3.5", children: tab.icon })), _jsx("span", { className: "truncate", children: tab.title }), tab.dirty && (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "sr-only", children: m.unsaved })] }))] }), _jsxs("span", { className: "relative mr-1.5 flex size-4 shrink-0 items-center justify-center", children: [tab.dirty && (_jsx("span", { "aria-hidden": true, className: cn("size-2 rounded-full bg-foreground/60", closable && "group-hover:hidden") })), closable && (_jsx("button", { type: "button", tabIndex: -1, "aria-hidden": true, onClick: (event) => {
|
|
95
|
+
event.stopPropagation();
|
|
96
|
+
onClose?.(tab.id);
|
|
97
|
+
}, className: cn("absolute inset-0 flex items-center justify-center rounded-lg hover:bg-accent hover:text-accent-foreground", tab.dirty
|
|
98
|
+
? "hidden group-hover:flex"
|
|
99
|
+
: active
|
|
100
|
+
? "opacity-70"
|
|
101
|
+
: "opacity-0 group-hover:opacity-70"), children: _jsx(XIcon, { className: "size-3" }) }))] })] }));
|
|
102
|
+
return contextMenu ? (_jsxs(ContextMenu, { modal: false, children: [_jsx(ContextMenuTrigger, { asChild: true, children: item }), _jsx(ContextMenuContent, { children: contextMenu(tab) })] }, tab.id)) : (_jsx(React.Fragment, { children: item }, tab.id));
|
|
103
|
+
});
|
|
104
|
+
return (_jsxs("div", { "data-slot": "editor-tabs", className: cn("flex h-9 shrink-0 items-stretch border-b bg-surface-1", className), children: [_jsx("div", { role: "tablist", "aria-label": label ?? m.openEditors, className: "flex min-w-0 flex-1 items-stretch overflow-x-auto overflow-y-hidden", children: strip }), tabs.length > 1 && (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": m.allTabs(tabs.length), className: "flex items-center gap-0.5 px-1.5 text-muted-foreground outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", children: [_jsx(ChevronDownIcon, { "aria-hidden": true, className: "size-3.5" }), _jsx("span", { "aria-hidden": true, className: "text-2xs tabular-nums", children: tabs.length })] }) }), _jsx(DropdownMenuContent, { align: "end", className: "max-h-[60vh] max-w-80", children: tabs.map((tab) => (_jsxs(DropdownMenuItem, { className: cn("text-xs", tab.id === activeId && "font-medium"), onSelect: () => focusTab(tabs.indexOf(tab)), children: [tab.icon && (_jsx("span", { "aria-hidden": true, className: "flex [&_svg]:size-3.5", children: tab.icon })), _jsx("span", { className: "truncate", children: tab.title }), tab.dirty && (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "sr-only", children: m.unsaved }), _jsx("span", { "aria-hidden": true, className: "ml-auto size-1.5 shrink-0 rounded-full bg-foreground/60" })] }))] }, tab.id))) })] })), actions && _jsx("div", { className: "flex items-stretch border-l", children: actions })] }));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Tabs and the panel under them: one editor group. A split layout puts
|
|
108
|
+
* several in a `PaneGroup`, marking the one with focus.
|
|
109
|
+
*/
|
|
110
|
+
export function EditorGroup({ children, empty, onFocusGroup, panelClassName, className, ...tabs }) {
|
|
111
|
+
const prefix = React.useId();
|
|
112
|
+
const panelId = `${prefix}-panel`;
|
|
113
|
+
const active = tabs.tabs.find((t) => t.id === tabs.activeId);
|
|
114
|
+
return (_jsxs("div", { "data-slot": "editor-group", className: cn("flex h-full min-h-0 flex-col", className), onFocusCapture: onFocusGroup, onPointerDownCapture: onFocusGroup, children: [_jsx(EditorTabs, { ...tabs, idPrefix: prefix, panelId: panelId }), active ? (_jsx("div", { role: "tabpanel", id: panelId, "aria-labelledby": editorTabId(prefix, active.id), className: cn("min-h-0 flex-1 overflow-hidden", panelClassName), children: children })) : (_jsx("div", { className: cn("min-h-0 flex-1 overflow-hidden", panelClassName), children: empty }))] }));
|
|
115
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type MoveHandler } from "./dnd.js";
|
|
3
|
+
import { type FileTreeProps } from "./FileTree.js";
|
|
4
|
+
import { type FileItem, type FileSort, type FileView } from "./model.js";
|
|
5
|
+
export interface FileBrowserProps {
|
|
6
|
+
/** The directory on screen; "" is the root. */
|
|
7
|
+
path: string;
|
|
8
|
+
/** Its entries, in any order (the browser sorts them). Undefined while loading. */
|
|
9
|
+
items?: readonly FileItem[];
|
|
10
|
+
/** The listing failed: shown in place of the entries, with `onRetry`. */
|
|
11
|
+
error?: string | {
|
|
12
|
+
message: string;
|
|
13
|
+
} | null;
|
|
14
|
+
onRetry?: () => void;
|
|
15
|
+
/** Go to a directory: a breadcrumb, a tree node, or opening a folder. */
|
|
16
|
+
onNavigate: (path: string) => void;
|
|
17
|
+
/** Open a file (double-click or Enter). Folders navigate instead. */
|
|
18
|
+
onOpen?: (item: FileItem) => void;
|
|
19
|
+
/** Names the browser. Defaults to "Files". */
|
|
20
|
+
label?: string;
|
|
21
|
+
/** The root's name in the breadcrumb. Defaults to "Home". */
|
|
22
|
+
rootLabel?: string;
|
|
23
|
+
view?: FileView;
|
|
24
|
+
defaultView?: FileView;
|
|
25
|
+
onViewChange?: (view: FileView) => void;
|
|
26
|
+
sort?: FileSort;
|
|
27
|
+
defaultSort?: FileSort;
|
|
28
|
+
onSortChange?: (sort: FileSort) => void;
|
|
29
|
+
/** The selected paths. Controlled when given. */
|
|
30
|
+
selection?: ReadonlySet<string>;
|
|
31
|
+
onSelectionChange?: (selection: Set<string>) => void;
|
|
32
|
+
onMove?: MoveHandler;
|
|
33
|
+
onDelete?: (items: FileItem[]) => void;
|
|
34
|
+
onRename?: (item: FileItem) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Context-menu items (`ContextMenuItem`s) for the items right-clicked (the
|
|
37
|
+
* selection), or for the folder itself when `targets` is empty.
|
|
38
|
+
*/
|
|
39
|
+
contextMenu?: (targets: FileItem[]) => React.ReactNode;
|
|
40
|
+
/** A folder tree beside the listing; the browser wires its selection, moves and navigation. */
|
|
41
|
+
tree?: Omit<FileTreeProps, "selectedPath" | "onSelect" | "onMove" | "contextMenu">;
|
|
42
|
+
/** Controls beside the view switch: new folder, upload, search. */
|
|
43
|
+
toolbar?: React.ReactNode;
|
|
44
|
+
/** A thumbnail for grid tiles. */
|
|
45
|
+
renderThumbnail?: (item: FileItem) => React.ReactNode;
|
|
46
|
+
formatDate?: (value: string | Date) => string;
|
|
47
|
+
/** Copy for an empty folder. */
|
|
48
|
+
empty?: {
|
|
49
|
+
title: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
};
|
|
52
|
+
className?: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The file browser: breadcrumb, view switch, an optional folder tree, and
|
|
56
|
+
* the listing as a table (FileList) or tiles (FileGrid), with sorting,
|
|
57
|
+
* selection, keyboard navigation, a context menu and drag-and-drop moves.
|
|
58
|
+
* It is driven by the host: data comes in as props and every change goes
|
|
59
|
+
* out through a callback, so it fetches nothing and runs over any store.
|
|
60
|
+
* Uncontrolled view, sort and selection work out of the box.
|
|
61
|
+
*/
|
|
62
|
+
export declare function FileBrowser({ path, items, error, onRetry, onNavigate, onOpen, label, rootLabel, view: viewProp, defaultView, onViewChange, sort: sortProp, defaultSort, onSortChange, selection: selectionProp, onSelectionChange, onMove, onDelete, onRename, contextMenu, tree, toolbar, renderThumbnail, formatDate, empty, className, }: FileBrowserProps): React.JSX.Element;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronRightIcon, FolderOpenIcon, LayoutGridIcon, ListIcon } 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 { Segmented } from "../primitives/Segmented.js";
|
|
8
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
9
|
+
import { EmptyState } from "../states/EmptyState.js";
|
|
10
|
+
import { ErrorState } from "../states/ErrorState.js";
|
|
11
|
+
import { LoadingState } from "../states/LoadingState.js";
|
|
12
|
+
import { useFileDrop } from "./dnd.js";
|
|
13
|
+
import { FileGrid } from "./FileGrid.js";
|
|
14
|
+
import { FileList } from "./FileList.js";
|
|
15
|
+
import { FileTree } from "./FileTree.js";
|
|
16
|
+
import { pathSegments, sortFiles } from "./model.js";
|
|
17
|
+
/**
|
|
18
|
+
* The file browser: breadcrumb, view switch, an optional folder tree, and
|
|
19
|
+
* the listing as a table (FileList) or tiles (FileGrid), with sorting,
|
|
20
|
+
* selection, keyboard navigation, a context menu and drag-and-drop moves.
|
|
21
|
+
* It is driven by the host: data comes in as props and every change goes
|
|
22
|
+
* out through a callback, so it fetches nothing and runs over any store.
|
|
23
|
+
* Uncontrolled view, sort and selection work out of the box.
|
|
24
|
+
*/
|
|
25
|
+
export function FileBrowser({ path, items, error, onRetry, onNavigate, onOpen, label, rootLabel, view: viewProp, defaultView = "list", onViewChange, sort: sortProp, defaultSort = { key: "name", dir: "asc" }, onSortChange, selection: selectionProp, onSelectionChange, onMove, onDelete, onRename, contextMenu, tree, toolbar, renderThumbnail, formatDate, empty, className, }) {
|
|
26
|
+
const { messages } = useUI();
|
|
27
|
+
const t = messages.workbench;
|
|
28
|
+
const [ownView, setOwnView] = React.useState(defaultView);
|
|
29
|
+
const [ownSort, setOwnSort] = React.useState(defaultSort);
|
|
30
|
+
const [ownSelection, setOwnSelection] = React.useState(() => new Set());
|
|
31
|
+
const [menuTargets, setMenuTargets] = React.useState([]);
|
|
32
|
+
const view = viewProp ?? ownView;
|
|
33
|
+
const sort = sortProp ?? ownSort;
|
|
34
|
+
const selection = selectionProp ?? ownSelection;
|
|
35
|
+
const name = label ?? t.files;
|
|
36
|
+
const setView = (next) => {
|
|
37
|
+
setOwnView(next);
|
|
38
|
+
onViewChange?.(next);
|
|
39
|
+
};
|
|
40
|
+
const setSort = (next) => {
|
|
41
|
+
setOwnSort(next);
|
|
42
|
+
onSortChange?.(next);
|
|
43
|
+
};
|
|
44
|
+
const setSelection = React.useCallback((next) => {
|
|
45
|
+
setOwnSelection(next);
|
|
46
|
+
onSelectionChange?.(next);
|
|
47
|
+
}, [onSelectionChange]);
|
|
48
|
+
// A new folder starts with nothing selected (a controlled selection is the host's to clear).
|
|
49
|
+
const [shownPath, setShownPath] = React.useState(path);
|
|
50
|
+
if (shownPath !== path) {
|
|
51
|
+
setShownPath(path);
|
|
52
|
+
setOwnSelection(new Set());
|
|
53
|
+
}
|
|
54
|
+
const sorted = React.useMemo(() => (items ? sortFiles(items, sort) : []), [items, sort]);
|
|
55
|
+
const open = (item) => item.kind === "directory" ? onNavigate(item.path) : onOpen?.(item);
|
|
56
|
+
const segments = pathSegments(path);
|
|
57
|
+
const here = segments[segments.length - 1]?.name ?? rootLabel ?? t.root;
|
|
58
|
+
const viewProps = {
|
|
59
|
+
label: t.filesIn(here),
|
|
60
|
+
items: sorted,
|
|
61
|
+
selection,
|
|
62
|
+
onSelectionChange: setSelection,
|
|
63
|
+
onOpen: open,
|
|
64
|
+
onDelete,
|
|
65
|
+
onRename,
|
|
66
|
+
onMove,
|
|
67
|
+
};
|
|
68
|
+
const errorMessage = typeof error === "string" ? error : error?.message;
|
|
69
|
+
let body;
|
|
70
|
+
if (error) {
|
|
71
|
+
body = (_jsx(ErrorState, { title: t.loadFailed, error: errorMessage || t.loadFailed, onRetry: onRetry, retryLabel: t.retry }));
|
|
72
|
+
}
|
|
73
|
+
else if (!items) {
|
|
74
|
+
body = _jsx(LoadingState, { label: t.loading, rows: 6 });
|
|
75
|
+
}
|
|
76
|
+
else if (sorted.length === 0) {
|
|
77
|
+
body = (_jsx(EmptyState, { icon: _jsx(FolderOpenIcon, { "aria-hidden": true, className: "size-6" }), title: empty?.title ?? t.empty, description: empty?.description ?? (onMove ? t.emptyDescription : undefined) }));
|
|
78
|
+
}
|
|
79
|
+
else if (view === "grid") {
|
|
80
|
+
body = _jsx(FileGrid, { ...viewProps, renderThumbnail: renderThumbnail });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
body = _jsx(FileList, { ...viewProps, sort: sort, onSortChange: setSort, formatDate: formatDate });
|
|
84
|
+
}
|
|
85
|
+
const folderDrop = useFileDrop(items ? path : null, onMove);
|
|
86
|
+
const pane = (_jsx("div", { ...folderDrop.props, onContextMenu: (event) => {
|
|
87
|
+
if (!contextMenu)
|
|
88
|
+
return;
|
|
89
|
+
// The menu is for the item under the pointer (with the rest of the
|
|
90
|
+
// selection when it is part of it), or the folder on empty space.
|
|
91
|
+
const path = event.target.closest("[data-path]")?.dataset.path;
|
|
92
|
+
const item = sorted.find((i) => i.path === path);
|
|
93
|
+
const targets = !item
|
|
94
|
+
? []
|
|
95
|
+
: selection.has(item.path)
|
|
96
|
+
? sorted.filter((i) => selection.has(i.path))
|
|
97
|
+
: [item];
|
|
98
|
+
if (!items || error || contextMenu(targets) == null) {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setMenuTargets(targets);
|
|
103
|
+
}, className: cn("min-h-0 min-w-0 flex-1 overflow-auto", folderDrop.over && "bg-primary/5 ring-2 ring-primary ring-inset"), children: body }));
|
|
104
|
+
return (_jsxs("section", { "aria-label": name, "data-slot": "file-browser", className: cn("@container flex min-h-0 flex-col bg-background text-sm", className), children: [_jsxs("div", { className: "flex min-h-11 flex-wrap items-center gap-2 border-b px-3 py-1.5", children: [_jsx(PathBar, { path: path, rootLabel: rootLabel ?? t.root, onNavigate: onNavigate, onMove: onMove }), _jsxs("div", { className: "ml-auto flex items-center gap-1.5", children: [toolbar, _jsx(Segmented, { label: t.view, size: "sm", iconOnly: true, value: view, onValueChange: setView, options: [
|
|
105
|
+
{ value: "list", label: t.listView, icon: ListIcon },
|
|
106
|
+
{ value: "grid", label: t.gridView, icon: LayoutGridIcon },
|
|
107
|
+
] })] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [tree && (_jsx("div", { className: "hidden w-56 shrink-0 overflow-auto border-r @2xl:block", children: _jsx(FileTree, { ...tree, selectedPath: path, onSelect: (item) => item.kind === "directory" ? onNavigate(item.path) : onOpen?.(item), onMove: onMove }) })), contextMenu ? (_jsxs(ContextMenu, { children: [_jsx(ContextMenuTrigger, { asChild: true, children: pane }), _jsx(ContextMenuContent, { children: contextMenu(menuTargets) })] })) : (pane)] }), _jsxs("div", { role: "status", className: "flex h-7 items-center gap-3 border-t px-3 text-xs text-muted-foreground", children: [items && !error && _jsx("span", { children: t.itemCount(sorted.length) }), selection.size > 0 && _jsx("span", { children: t.selected(selection.size) })] })] }));
|
|
108
|
+
}
|
|
109
|
+
function PathBar({ path, rootLabel, onNavigate, onMove, }) {
|
|
110
|
+
const { messages } = useUI();
|
|
111
|
+
const crumbs = [{ name: rootLabel, path: "" }, ...pathSegments(path)];
|
|
112
|
+
return (_jsx("nav", { "aria-label": messages.workbench.location, className: "min-w-0 flex-1", children: _jsx("ol", { className: "flex min-w-0 items-center gap-0.5", children: crumbs.map((crumb, i) => (_jsx(Crumb, { name: crumb.name, path: crumb.path, first: i === 0, current: i === crumbs.length - 1, onNavigate: onNavigate, onMove: onMove }, crumb.path || "/"))) }) }));
|
|
113
|
+
}
|
|
114
|
+
function Crumb({ name, path, first, current, onNavigate, onMove, }) {
|
|
115
|
+
const drop = useFileDrop(current ? null : path, onMove);
|
|
116
|
+
return (_jsxs("li", { className: cn("flex min-w-0 items-center gap-0.5", current ? "shrink" : "shrink-[2]"), children: [!first && _jsx(ChevronRightIcon, { "aria-hidden": true, className: "size-3 shrink-0 text-muted-foreground" }), _jsx("button", { type: "button", "aria-current": current ? "location" : undefined, onClick: () => onNavigate(path), ...drop.props, className: cn("min-w-0 truncate rounded-lg px-1.5 py-0.5 outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50", current ? "font-medium text-foreground" : "text-muted-foreground", drop.over && "bg-primary/10 ring-2 ring-primary"), children: name })] }));
|
|
117
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { FileItem } from "./model.js";
|
|
3
|
+
import type { FileViewProps } from "./FileList.js";
|
|
4
|
+
export interface FileGridProps extends FileViewProps {
|
|
5
|
+
/** A thumbnail in place of the type icon (an image's own preview). */
|
|
6
|
+
renderThumbnail?: (item: FileItem) => React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A directory as tiles: a multi-select listbox laid out in rows, so the
|
|
10
|
+
* arrows move in two dimensions (up and down by a row of tiles). The same
|
|
11
|
+
* selection, opening, typeahead and drag rules as FileList.
|
|
12
|
+
*/
|
|
13
|
+
export declare function FileGrid({ label, items, selection, onSelectionChange, onOpen, onDelete, onRename, onMove, renderThumbnail, className, }: FileGridProps): React.JSX.Element;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
6
|
+
import { startFileDrag, useFileDrop } from "./dnd.js";
|
|
7
|
+
import { FileIcon } from "./FileIcon.js";
|
|
8
|
+
import { modeOf, useSelectionModel } from "./selection.js";
|
|
9
|
+
import { typeahead } from "./typeahead.js";
|
|
10
|
+
/**
|
|
11
|
+
* A directory as tiles: a multi-select listbox laid out in rows, so the
|
|
12
|
+
* arrows move in two dimensions (up and down by a row of tiles). The same
|
|
13
|
+
* selection, opening, typeahead and drag rules as FileList.
|
|
14
|
+
*/
|
|
15
|
+
export function FileGrid({ label, items, selection, onSelectionChange, onOpen, onDelete, onRename, onMove, renderThumbnail, className, }) {
|
|
16
|
+
const { messages } = useUI();
|
|
17
|
+
const t = messages.workbench;
|
|
18
|
+
const order = React.useMemo(() => items.map((i) => i.path), [items]);
|
|
19
|
+
const model = useSelectionModel(order, selection, onSelectionChange);
|
|
20
|
+
const [focusIndex, setFocusIndex] = React.useState(0);
|
|
21
|
+
const index = Math.min(focusIndex, items.length - 1);
|
|
22
|
+
const list = React.useRef(null);
|
|
23
|
+
const tiles = React.useRef(new Map());
|
|
24
|
+
const pending = React.useRef(false);
|
|
25
|
+
const search = React.useRef({ text: "", at: 0 });
|
|
26
|
+
React.useLayoutEffect(() => {
|
|
27
|
+
if (!pending.current)
|
|
28
|
+
return;
|
|
29
|
+
pending.current = false;
|
|
30
|
+
tiles.current.get(index)?.focus();
|
|
31
|
+
});
|
|
32
|
+
const columns = () => {
|
|
33
|
+
const template = list.current ? getComputedStyle(list.current).gridTemplateColumns : "";
|
|
34
|
+
return Math.max(1, template.split(" ").filter(Boolean).length);
|
|
35
|
+
};
|
|
36
|
+
const onKeyDown = (event) => {
|
|
37
|
+
const meta = event.metaKey || event.ctrlKey;
|
|
38
|
+
const go = (target) => {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
const next = Math.min(Math.max(target, 0), items.length - 1);
|
|
41
|
+
pending.current = true;
|
|
42
|
+
setFocusIndex(next);
|
|
43
|
+
const item = items[next];
|
|
44
|
+
if (item && !meta)
|
|
45
|
+
model.select(item.path, event.shiftKey ? "range" : "replace");
|
|
46
|
+
};
|
|
47
|
+
switch (event.key) {
|
|
48
|
+
case "ArrowRight":
|
|
49
|
+
return go(index + 1);
|
|
50
|
+
case "ArrowLeft":
|
|
51
|
+
return go(index - 1);
|
|
52
|
+
case "ArrowDown":
|
|
53
|
+
return go(index + columns());
|
|
54
|
+
case "ArrowUp":
|
|
55
|
+
return go(index - columns());
|
|
56
|
+
case "Home":
|
|
57
|
+
return go(0);
|
|
58
|
+
case "End":
|
|
59
|
+
return go(items.length - 1);
|
|
60
|
+
}
|
|
61
|
+
const item = items[index];
|
|
62
|
+
if (!item)
|
|
63
|
+
return;
|
|
64
|
+
if (event.key === "Enter") {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
onOpen(item);
|
|
67
|
+
}
|
|
68
|
+
else if (event.key === " ") {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
model.select(item.path, meta ? "toggle" : event.shiftKey ? "range" : "replace");
|
|
71
|
+
}
|
|
72
|
+
else if (meta && event.key.toLowerCase() === "a") {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
model.selectAll();
|
|
75
|
+
}
|
|
76
|
+
else if (event.key === "Escape" && selection.size > 0) {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
model.clear();
|
|
79
|
+
}
|
|
80
|
+
else if (onDelete && (event.key === "Delete" || (meta && event.key === "Backspace"))) {
|
|
81
|
+
const targets = items.filter((i) => selection.has(i.path));
|
|
82
|
+
if (targets.length) {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
onDelete(targets);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (onRename && event.key === "F2") {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
onRename(item);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const next = typeahead(search.current, event, items.map((i) => i.name), index);
|
|
93
|
+
if (next !== null)
|
|
94
|
+
go(next);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
return (_jsx("div", { ref: list, role: "listbox", "aria-label": label, "aria-multiselectable": true, onKeyDown: onKeyDown, className: cn("grid grid-cols-[repeat(auto-fill,minmax(6.5rem,1fr))] content-start gap-1 p-2", className), children: items.map((item, i) => (_jsx(Tile, { item: item, selected: selection.has(item.path), tabbable: i === index, tileRef: (el) => {
|
|
98
|
+
if (el)
|
|
99
|
+
tiles.current.set(i, el);
|
|
100
|
+
else
|
|
101
|
+
tiles.current.delete(i);
|
|
102
|
+
}, onFocus: () => setFocusIndex(i), onMove: onMove, dragPaths: selection.has(item.path) ? order.filter((p) => selection.has(p)) : [item.path], dragLabel: (n) => (n === 1 ? item.name : t.itemCount(n)), thumbnail: renderThumbnail?.(item), onClick: (event) => {
|
|
103
|
+
setFocusIndex(i);
|
|
104
|
+
model.select(item.path, modeOf(event));
|
|
105
|
+
}, onDoubleClick: () => onOpen(item), onContextMenu: () => {
|
|
106
|
+
// Right-clicking outside the selection selects the item, as file managers do.
|
|
107
|
+
if (!selection.has(item.path))
|
|
108
|
+
model.select(item.path, "replace");
|
|
109
|
+
} }, item.path))) }));
|
|
110
|
+
}
|
|
111
|
+
function Tile({ item, selected, tabbable, tileRef, onFocus, onMove, dragPaths, dragLabel, thumbnail, onClick, onDoubleClick, onContextMenu, }) {
|
|
112
|
+
const drop = useFileDrop(item.kind === "directory" ? item.path : null, onMove);
|
|
113
|
+
return (_jsxs("div", { ref: tileRef, role: "option", "data-path": item.path, "aria-selected": selected, tabIndex: tabbable ? 0 : -1, title: item.name, draggable: Boolean(onMove), onDragStart: onMove ? (event) => startFileDrag(event, dragPaths, dragLabel(dragPaths.length)) : undefined, ...drop.props, onFocus: onFocus, onClick: onClick, onDoubleClick: onDoubleClick, onContextMenu: onContextMenu, className: cn("flex cursor-default flex-col items-center gap-1.5 rounded-lg p-2 text-center outline-none select-none hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring/50", selected && "bg-accent text-accent-foreground hover:bg-accent", drop.over && "bg-primary/10 ring-2 ring-primary"), children: [_jsx("span", { className: "flex size-12 items-center justify-center overflow-hidden rounded-lg", children: thumbnail ?? _jsx(FileIcon, { name: item.name, kind: item.kind, className: "size-8" }) }), _jsx("span", { className: "line-clamp-2 w-full text-xs [overflow-wrap:anywhere]", children: item.name })] }));
|
|
114
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type FileKind } from "./model.js";
|
|
2
|
+
export interface FileIconProps {
|
|
3
|
+
name: string;
|
|
4
|
+
kind: FileKind;
|
|
5
|
+
/** An open directory (an expanded tree node). */
|
|
6
|
+
open?: boolean;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
/** A file-type glyph from the name's extension; directories in the accent. Decorative. */
|
|
10
|
+
export declare function FileIcon({ name, kind, open, className }: FileIconProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { FileArchiveIcon, FileCodeIcon, FileIcon as LucideFileIcon, FileImageIcon, FileJsonIcon, FileSpreadsheetIcon, FileTextIcon, FolderIcon, FolderOpenIcon, } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { fileExtension } from "./model.js";
|
|
5
|
+
const BY_EXTENSION = {};
|
|
6
|
+
const group = (icon, extensions) => {
|
|
7
|
+
for (const ext of extensions.split(" "))
|
|
8
|
+
BY_EXTENSION[ext] = icon;
|
|
9
|
+
};
|
|
10
|
+
group(FileCodeIcon, "py js jsx ts tsx mjs cjs rs go java kt rb sh bash zsh sql html css scss c h cpp hpp cs php swift lua r tf");
|
|
11
|
+
group(FileJsonIcon, "json jsonl ipynb");
|
|
12
|
+
group(FileSpreadsheetIcon, "csv tsv parquet xlsx xls ods");
|
|
13
|
+
group(FileImageIcon, "png jpg jpeg gif svg webp avif bmp ico");
|
|
14
|
+
group(FileTextIcon, "md markdown txt log yaml yml toml ini cfg conf rst pdf doc docx");
|
|
15
|
+
group(FileArchiveIcon, "zip tar gz tgz bz2 xz 7z rar");
|
|
16
|
+
/** A file-type glyph from the name's extension; directories in the accent. Decorative. */
|
|
17
|
+
export function FileIcon({ name, kind, open = false, className }) {
|
|
18
|
+
if (kind === "directory") {
|
|
19
|
+
const Folder = open ? FolderOpenIcon : FolderIcon;
|
|
20
|
+
return _jsx(Folder, { "aria-hidden": true, className: cn("size-4 shrink-0 text-primary", className) });
|
|
21
|
+
}
|
|
22
|
+
const Glyph = BY_EXTENSION[fileExtension(name)] ?? LucideFileIcon;
|
|
23
|
+
return _jsx(Glyph, { "aria-hidden": true, className: cn("size-4 shrink-0 text-muted-foreground", className) });
|
|
24
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type MoveHandler } from "./dnd.js";
|
|
3
|
+
import { type FileItem, type FileSort } from "./model.js";
|
|
4
|
+
export interface FileViewProps {
|
|
5
|
+
/** Names the view for screen readers ("Files in reports"). */
|
|
6
|
+
label: string;
|
|
7
|
+
/** In display order (`sortFiles`). */
|
|
8
|
+
items: readonly FileItem[];
|
|
9
|
+
selection: ReadonlySet<string>;
|
|
10
|
+
onSelectionChange: (next: Set<string>) => void;
|
|
11
|
+
/** Double-click or Enter. */
|
|
12
|
+
onOpen: (item: FileItem) => void;
|
|
13
|
+
/** Delete, or Cmd/Ctrl+Backspace, on the selection. */
|
|
14
|
+
onDelete?: (items: FileItem[]) => void;
|
|
15
|
+
/** F2 on one item. */
|
|
16
|
+
onRename?: (item: FileItem) => void;
|
|
17
|
+
/** Drag items onto directories. */
|
|
18
|
+
onMove?: MoveHandler;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface FileListProps extends FileViewProps {
|
|
22
|
+
sort?: FileSort;
|
|
23
|
+
/** Present, the headers are sort buttons. */
|
|
24
|
+
onSortChange?: (sort: FileSort) => void;
|
|
25
|
+
/** How a modified time reads. Defaults to the locale's medium date and short time. */
|
|
26
|
+
formatDate?: (value: string | Date) => string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A directory as a sortable table, on the WAI-ARIA grid pattern: one tab
|
|
30
|
+
* stop, arrows move between cells, selection follows the focus (Ctrl or Cmd
|
|
31
|
+
* with an arrow moves without selecting, Shift extends), Home and End, Page
|
|
32
|
+
* Up and Down, Enter opens, Cmd/Ctrl+A selects all, Escape clears, and
|
|
33
|
+
* typing finds a name. Rows drag onto folders.
|
|
34
|
+
*/
|
|
35
|
+
export declare function FileList({ label, items, selection, onSelectionChange, onOpen, onDelete, onRename, onMove, sort, onSortChange, formatDate, className, }: FileListProps): React.JSX.Element;
|