Skip to content

Commit 9764ea0

Browse files
NuroDevdependabot[bot]Wrangler automated PR updatervicbpetebacondarwin
authored
feat(local-explorer-ui): Added initial data studio (#12453)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Wrangler automated PR updater <wrangler@cloudflare.com> Co-authored-by: Victor Berchet <victor@suumit.com> Co-authored-by: Pete Bacon Darwin <pete@bacondarwin.com>
1 parent ea57dfd commit 9764ea0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+7578
-635
lines changed

‎.changeset/bright-peas-stare.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/local-explorer-ui": minor
3+
---
4+
5+
Add initial data studio with D1 and Durable Objects support
6+
7+
Adds a data studio interface to the local explorer UI, allowing you to browse and interact with D1 databases and Durable Objects during local development. The studio provides table browsing, query execution, and data editing capabilities.

‎packages/local-explorer-ui/package.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,17 @@
2222
"@cloudflare/kumo": "^1.5.0",
2323
"@cloudflare/workers-editor-shared": "^0.1.1",
2424
"@codemirror/autocomplete": "^6.20.0",
25+
"@codemirror/commands": "^6.10.2",
2526
"@codemirror/lang-sql": "^6.10.0",
2627
"@codemirror/language": "^6.12.1",
2728
"@codemirror/state": "^6.5.4",
2829
"@codemirror/view": "^6.39.14",
30+
"@dnd-kit/core": "^6.3.1",
31+
"@dnd-kit/sortable": "^10.0.0",
32+
"@dnd-kit/utilities": "^3.2.2",
33+
"@floating-ui/react": "^0.27.18",
34+
"@lezer/common": "^1.5.1",
35+
"@lezer/highlight": "^1.2.3",
2936
"@phosphor-icons/react": "^2.1.10",
3037
"@tailwindcss/vite": "^4.0.15",
3138
"@tanstack/react-router": "^1.158.0",

‎packages/local-explorer-ui/src/components/Breadcrumbs.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { CaretRightIcon } from "@phosphor-icons/react";
22
import { Fragment } from "react";
3-
import type { FC, ReactNode } from "react";
3+
import type { FC, PropsWithChildren, ReactNode } from "react";
44

5-
interface BreadcrumbsProps {
5+
interface BreadcrumbsProps extends PropsWithChildren {
66
icon: FC;
77
items: Array<ReactNode>;
88
title: string;
99
}
1010

1111
export function Breadcrumbs({
12+
children,
1213
icon: Icon,
1314
items,
1415
title,
@@ -26,6 +27,8 @@ export function Breadcrumbs({
2627
{item}
2728
</Fragment>
2829
))}
30+
31+
{children}
2932
</div>
3033
);
3134
}

‎packages/local-explorer-ui/src/components/Sidebar.tsx‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { Collapsible } from "@base-ui/react/collapsible";
22
import { cn } from "@cloudflare/kumo";
3-
import { CaretRightIcon, DatabaseIcon } from "@phosphor-icons/react";
3+
import { CaretRightIcon, CubeIcon, DatabaseIcon } from "@phosphor-icons/react";
44
import { Link } from "@tanstack/react-router";
55
import CloudflareLogo from "../assets/icons/cloudflare-logo.svg?react";
66
import KVIcon from "../assets/icons/kv.svg?react";
7-
import type { D1DatabaseResponse, WorkersKvNamespace } from "../api";
7+
import type {
8+
D1DatabaseResponse,
9+
WorkersKvNamespace,
10+
WorkersNamespace,
11+
} from "../api";
812
import type { FileRouteTypes } from "../routeTree.gen";
913
import type { FC } from "react";
1014

@@ -92,18 +96,22 @@ interface SidebarProps {
9296
currentPath: string;
9397
d1Error: string | null;
9498
databases: D1DatabaseResponse[];
99+
doError: string | null;
100+
doNamespaces: WorkersNamespace[];
95101
kvError: string | null;
102+
kvNamespaces: WorkersKvNamespace[];
96103
loading: boolean;
97-
namespaces: WorkersKvNamespace[];
98104
}
99105

100106
export function Sidebar({
101107
currentPath,
102108
d1Error,
103109
databases,
110+
doError,
111+
doNamespaces,
104112
kvError,
113+
kvNamespaces,
105114
loading,
106-
namespaces,
107115
}: SidebarProps) {
108116
return (
109117
<aside className="w-sidebar bg-bg-secondary border-r border-border flex flex-col">
@@ -126,7 +134,7 @@ export function Sidebar({
126134
emptyLabel="No namespaces"
127135
error={kvError}
128136
icon={KVIcon}
129-
items={namespaces.map((ns) => ({
137+
items={kvNamespaces.map((ns) => ({
130138
id: ns.id,
131139
isActive: currentPath === `/kv/${ns.id}`,
132140
label: ns.title,
@@ -156,6 +164,28 @@ export function Sidebar({
156164
loading={loading}
157165
title="D1 Databases"
158166
/>
167+
168+
<SidebarItemGroup
169+
emptyLabel="No namespaces"
170+
error={doError}
171+
icon={CubeIcon}
172+
items={doNamespaces.map((ns) => {
173+
const className = ns.class ?? ns.name ?? ns.id ?? "Unknown";
174+
return {
175+
id: ns.id as string,
176+
isActive:
177+
currentPath === `/do/${className}` ||
178+
currentPath.startsWith(`/do/${className}/`),
179+
label: className,
180+
link: {
181+
params: { className },
182+
to: "/do/$className",
183+
},
184+
};
185+
})}
186+
loading={loading}
187+
title="Durable Objects"
188+
/>
159189
</aside>
160190
);
161191
}

‎packages/local-explorer-ui/src/components/studio/ContextMenu.tsx‎

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,26 @@ import {
77
useRef,
88
useState,
99
} from "react";
10-
import type { ComponentProps, PropsWithChildren } from "react";
10+
import type { VirtualElement } from "@floating-ui/react";
11+
import type { Icon } from "@phosphor-icons/react";
12+
import type { PropsWithChildren, ReactNode } from "react";
1113

12-
type DropdownItemBuilderProps = ComponentProps<typeof DropdownMenu.Content>;
14+
interface DropdownButtonItem {
15+
destructiveAction?: boolean;
16+
disabled?: boolean;
17+
icon?: Icon;
18+
label: ReactNode;
19+
onClick?: () => void;
20+
shortcut?: string;
21+
sub?: DropdownItemBuilderProps[];
22+
type: "button";
23+
}
24+
25+
interface DropdownDividerItem {
26+
type: "divider";
27+
}
28+
29+
export type DropdownItemBuilderProps = DropdownButtonItem | DropdownDividerItem;
1330

1431
type OnOpenChangeHandler = (open: boolean) => void;
1532

@@ -19,7 +36,7 @@ type OpenContextMenuHandler = (
1936
onOpenChange?: OnOpenChangeHandler
2037
) => void;
2138

22-
export const StudioContextMenu = createContext<{
39+
const StudioContextMenu = createContext<{
2340
openContextMenu: OpenContextMenuHandler;
2441
} | null>(null);
2542

@@ -41,12 +58,61 @@ interface Position {
4158

4259
type StudioContextMenuProvider = PropsWithChildren;
4360

61+
interface DropdownMenuItemsBuilderProps {
62+
items: DropdownItemBuilderProps[];
63+
}
64+
65+
function DropdownMenuItemsBuilder({
66+
items,
67+
}: DropdownMenuItemsBuilderProps): JSX.Element {
68+
return (
69+
<>
70+
{items.map((item, index) => {
71+
if (item.type === "divider") {
72+
return <DropdownMenu.Separator key={index} />;
73+
}
74+
75+
if (item.sub && item.sub.length > 0) {
76+
return (
77+
<DropdownMenu.Sub key={index}>
78+
<DropdownMenu.SubTrigger
79+
disabled={item.disabled}
80+
icon={item.icon}
81+
>
82+
{item.label}
83+
</DropdownMenu.SubTrigger>
84+
<DropdownMenu.SubContent>
85+
<DropdownMenuItemsBuilder items={item.sub} />
86+
</DropdownMenu.SubContent>
87+
</DropdownMenu.Sub>
88+
);
89+
}
90+
91+
return (
92+
<DropdownMenu.Item
93+
disabled={item.disabled}
94+
icon={item.icon}
95+
key={index}
96+
onClick={item.onClick}
97+
variant={item.destructiveAction ? "danger" : "default"}
98+
>
99+
{item.label}
100+
{item.shortcut && (
101+
<DropdownMenu.Shortcut>{item.shortcut}</DropdownMenu.Shortcut>
102+
)}
103+
</DropdownMenu.Item>
104+
);
105+
})}
106+
</>
107+
);
108+
}
109+
44110
export function StudioContextMenuProvider({
45111
children,
46112
}: StudioContextMenuProvider) {
47113
const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
48114
const [open, setOpen] = useState<boolean>(false);
49-
const [_menuItems, setMenuItems] = useState<DropdownItemBuilderProps[]>([]);
115+
const [menuItems, setMenuItems] = useState<DropdownItemBuilderProps[]>([]);
50116
const onOpenChange = useRef<OnOpenChangeHandler | null>(null);
51117

52118
const openContextMenu = useCallback(
@@ -68,31 +134,56 @@ export function StudioContextMenuProvider({
68134

69135
const value = useMemo(() => ({ openContextMenu }), [openContextMenu]);
70136

137+
const virtualAnchor = useMemo<VirtualElement>(
138+
() => ({
139+
getBoundingClientRect: () => ({
140+
bottom: position.y,
141+
height: 0,
142+
left: position.x,
143+
right: position.x,
144+
top: position.y,
145+
width: 0,
146+
x: position.x,
147+
y: position.y,
148+
}),
149+
}),
150+
[position.x, position.y]
151+
);
152+
153+
const handleOpenChange = useCallback(
154+
(isOpen: boolean, eventDetails: { reason: string }) => {
155+
// Only close on valid reasons, ignore focus-out to prevent
156+
// the context menu from closing when moving mouse away
157+
if (
158+
!isOpen &&
159+
eventDetails.reason !== "outside-press" &&
160+
eventDetails.reason !== "escape-key" &&
161+
eventDetails.reason !== "item-press"
162+
) {
163+
return;
164+
}
165+
166+
setOpen(isOpen);
167+
onOpenChange.current?.(isOpen);
168+
},
169+
[]
170+
);
171+
71172
return (
72173
<StudioContextMenu.Provider value={value}>
73-
<DropdownMenu
74-
onOpenChange={(isOpen) => {
75-
setOpen(isOpen);
76-
onOpenChange.current?.(isOpen);
77-
}}
78-
open={open}
79-
>
174+
<DropdownMenu onOpenChange={handleOpenChange} open={open}>
80175
<DropdownMenu.Trigger
81-
render={
82-
<button
83-
className="fixed hidden z-200"
84-
style={{ top: `${position.y}px`, left: `${position.x}px` }}
85-
/>
86-
}
176+
nativeButton={false}
177+
render={<span className="hidden" />}
87178
/>
88179

89180
<DropdownMenu.Content
90181
align="start"
182+
anchor={virtualAnchor}
183+
className="w-62.5"
91184
side="bottom"
92-
style={{ width: 250 }}
93185
>
94-
{/* TODO: Add stub implementation of this dropdown using the new Kumo components */}
95-
{/* <DropdownMenuItemsBuilder items={menuItems} /> */}
186+
<DropdownMenuItemsBuilder items={menuItems} />
96187
</DropdownMenu.Content>
97188
</DropdownMenu>
98189

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { memo } from "react";
2+
3+
interface HighlightTextProps {
4+
highlight?: string;
5+
text: string;
6+
}
7+
8+
/**
9+
* Renders a text string with all case-insensitive occurrences
10+
* of the `highlight` substring visually emphasized.
11+
* Each matched segment is wrapped in a styled <span> element.
12+
*
13+
* @param text - The full text content to render.
14+
* @param highlight - The substring to highlight within the text (optional).
15+
*
16+
* @returns A JSX element with all matching substrings highlighted.
17+
*/
18+
export const StudioHighlightText = memo(
19+
({ text, highlight }: HighlightTextProps) => {
20+
// Avoid highlighting if the input is falsy (e.g., null, undefined, or empty string).
21+
// This also prevents an infinite loop when highlight === "".
22+
if (!highlight) {
23+
return <span>{text}</span>;
24+
}
25+
26+
const lowerText = text.toLowerCase();
27+
const lowerHighlight = highlight.toLowerCase();
28+
const result: React.ReactNode[] = [];
29+
30+
let i = 0;
31+
let key = 0;
32+
while (i < text.length) {
33+
const matchIndex = lowerText.indexOf(lowerHighlight, i);
34+
if (matchIndex === -1) {
35+
result.push(<span key={key++}>{text.slice(i)}</span>);
36+
break;
37+
}
38+
39+
if (matchIndex > i) {
40+
result.push(<span key={key++}>{text.slice(i, matchIndex)}</span>);
41+
}
42+
43+
result.push(
44+
<span key={key++} className="bg-yellow-300 text-black">
45+
{text.slice(matchIndex, matchIndex + highlight.length)}
46+
</span>
47+
);
48+
49+
i = matchIndex + highlight.length;
50+
}
51+
52+
return <span>{result}</span>;
53+
}
54+
);
55+
56+
StudioHighlightText.displayName = "StudioHighlightText";

0 commit comments

Comments
 (0)