diff --git a/.changeset/light-clocks-enter.md b/.changeset/light-clocks-enter.md
new file mode 100644
index 000000000000..9ebf092ddca6
--- /dev/null
+++ b/.changeset/light-clocks-enter.md
@@ -0,0 +1,9 @@
+---
+"@cloudflare/local-explorer-ui": minor
+---
+
+Adds the tab definition for the table explorer.
+
+This serves as another stepping stone for adding the complete data studio to the local explorer.
+
+This is a WIP experimental feature.
diff --git a/packages/local-explorer-ui/package.json b/packages/local-explorer-ui/package.json
index d221181e880d..f4060444d596 100644
--- a/packages/local-explorer-ui/package.json
+++ b/packages/local-explorer-ui/package.json
@@ -20,6 +20,12 @@
"dependencies": {
"@base-ui/react": "^1.1.0",
"@cloudflare/kumo": "^1.5.0",
+ "@cloudflare/workers-editor-shared": "^0.1.1",
+ "@codemirror/autocomplete": "^6.20.0",
+ "@codemirror/lang-sql": "^6.10.0",
+ "@codemirror/language": "^6.12.1",
+ "@codemirror/state": "^6.5.4",
+ "@codemirror/view": "^6.39.14",
"@phosphor-icons/react": "^2.1.10",
"@tailwindcss/vite": "^4.0.15",
"@tanstack/react-router": "^1.158.0",
diff --git a/packages/local-explorer-ui/src/__tests__/utils/studio.test.ts b/packages/local-explorer-ui/src/__tests__/utils/studio.test.ts
index ab700c175f8b..f6fc0d21f2e5 100644
--- a/packages/local-explorer-ui/src/__tests__/utils/studio.test.ts
+++ b/packages/local-explorer-ui/src/__tests__/utils/studio.test.ts
@@ -1,9 +1,9 @@
import { describe, expect, test } from "vitest";
import {
escapeSqlValue,
- tokenizeSQL,
transformStudioArrayBasedResult,
} from "../../utils/studio";
+import { tokenizeSQL } from "../../utils/studio/sql";
describe("escapeSqlValue", () => {
test("undefined returns `DEFAULT`", () => {
diff --git a/packages/local-explorer-ui/src/components/studio/Code/Block.tsx b/packages/local-explorer-ui/src/components/studio/Code/Block.tsx
new file mode 100644
index 000000000000..bd3890c4b1c3
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Code/Block.tsx
@@ -0,0 +1,21 @@
+interface CodeBlockProps {
+ code: string;
+ language?: string;
+ maxHeight?: number;
+}
+
+export function CodeBlock({
+ code,
+ language,
+ maxHeight,
+}: CodeBlockProps): JSX.Element {
+ return (
+
+ {code}
+
+ );
+}
diff --git a/packages/local-explorer-ui/src/components/studio/Code/Mirror.tsx b/packages/local-explorer-ui/src/components/studio/Code/Mirror.tsx
new file mode 100644
index 000000000000..f97585b45a84
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Code/Mirror.tsx
@@ -0,0 +1,180 @@
+import { Annotation, EditorState, StateEffect } from "@codemirror/state";
+import {
+ EditorView,
+ placeholder as placeholderExtension,
+} from "@codemirror/view";
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import type { Extension } from "@codemirror/state";
+import type { ViewUpdate } from "@codemirror/view";
+
+/**
+ * React binding for CodeMirror with minimal built-in extensions.
+ * Supports placeholder and content change events only.
+ */
+export const StudioCodeMirror = forwardRef<
+ StudioCodeMirrorReference,
+ StudioCodeMirrorProps
+>(
+ (
+ {
+ autoFocus,
+ className,
+ defaultValue,
+ extensions,
+ onChange,
+ onCursorChange,
+ placeholder,
+ readOnly,
+ },
+ ref
+ ) => {
+ const container = useRef(null);
+ const defaultValueRef = useRef(defaultValue);
+
+ const [editorView, setEditorView] = useState();
+
+ useLayoutEffect(() => {
+ if (!container.current) {
+ return;
+ }
+
+ const view = new EditorView({
+ parent: container.current,
+ doc: defaultValueRef?.current,
+ });
+
+ setEditorView(view);
+
+ return () => view.destroy();
+ }, [container, defaultValueRef]);
+
+ // Registers new extensions with CodeMirror,
+ // including built-in support for placeholder and onChange events.
+ useEffect(() => {
+ if (!editorView) {
+ return;
+ }
+
+ const combinedExtensions = [...(extensions ?? [])] satisfies Extension[];
+
+ if (onChange) {
+ combinedExtensions.push(
+ EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
+ if (
+ viewUpdate.docChanged &&
+ onChange &&
+ !viewUpdate.transactions.some((tr) =>
+ tr.annotation(BlockOnChangeTrigger)
+ )
+ ) {
+ onChange(viewUpdate);
+ }
+ })
+ );
+ }
+
+ if (onCursorChange) {
+ combinedExtensions.push(
+ EditorView.updateListener.of((state) => {
+ const position = state.state.selection.main.head;
+ const line = state.state.doc.lineAt(position);
+ const lineNumber = line.number;
+ const columnNumber = position - line.from;
+ onCursorChange(position, lineNumber, columnNumber);
+ })
+ );
+ }
+
+ if (placeholder) {
+ combinedExtensions.push(placeholderExtension(placeholder));
+ }
+
+ if (readOnly) {
+ combinedExtensions.push(EditorState.readOnly.of(true));
+ }
+
+ editorView.dispatch({
+ effects: StateEffect.reconfigure.of(combinedExtensions),
+ });
+ }, [
+ editorView,
+ extensions,
+ onChange,
+ onCursorChange,
+ placeholder,
+ readOnly,
+ ]);
+
+ // Exposes the CodeMirror editor instance and helper methods
+ // for getting and setting the editor content via ref.
+ useImperativeHandle(
+ ref,
+ () => ({
+ getValue: (): string => {
+ if (!editorView) {
+ return "";
+ }
+
+ return editorView.state.doc.toString();
+ },
+ setValue: (value: string): void => {
+ if (!editorView) {
+ return;
+ }
+
+ const currentValue = editorView.state.doc.toString();
+ editorView.dispatch({
+ annotations: [BlockOnChangeTrigger.of(true)],
+ changes: {
+ from: 0,
+ insert: value || "",
+ to: currentValue.length,
+ },
+ });
+ },
+ view: editorView,
+ }),
+ [editorView]
+ );
+
+ // Auto focus
+ useEffect((): void => {
+ if (autoFocus && editorView) {
+ editorView.focus();
+ }
+ }, [autoFocus, editorView]);
+
+ return ;
+ }
+);
+StudioCodeMirror.displayName = "StudioCodeMirror";
+
+const BlockOnChangeTrigger = Annotation.define();
+
+export interface StudioCodeMirrorProps {
+ autoFocus?: boolean;
+ className?: string;
+ defaultValue?: string;
+ extensions?: Extension[];
+ onChange?: (update: ViewUpdate) => void;
+ onCursorChange?: (
+ position: number,
+ lineNumber: number,
+ columnNumber: number
+ ) => void;
+ placeholder?: string;
+ readOnly?: boolean;
+}
+
+export interface StudioCodeMirrorReference {
+ getValue: () => string;
+ setValue: (value: string) => void;
+ view?: EditorView;
+}
diff --git a/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx b/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx
new file mode 100644
index 000000000000..dc8dc8fa527f
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx
@@ -0,0 +1,87 @@
+import { Button, Dialog } from "@cloudflare/kumo";
+import { PlayIcon, SpinnerIcon } from "@phosphor-icons/react";
+import { useState } from "react";
+import { CodeBlock } from "../Code/Block";
+
+interface StudioCommitConfirmationProps {
+ closeModal: () => void;
+ isOpen: boolean;
+ onConfirm: () => Promise;
+ statements: string[];
+}
+
+export function StudioCommitConfirmation({
+ closeModal,
+ isOpen,
+ onConfirm,
+ statements,
+}: StudioCommitConfirmationProps) {
+ const [errorMessage, setErrorMessage] = useState("");
+ const [isRequesting, setIsRequesting] = useState(false);
+
+ const handleConfirm = async (): Promise => {
+ setIsRequesting(true);
+ setErrorMessage("");
+
+ try {
+ await onConfirm();
+ closeModal();
+ } catch (err) {
+ if (err instanceof Error) {
+ setErrorMessage(err.message);
+ } else {
+ setErrorMessage(String(err));
+ }
+ } finally {
+ setIsRequesting(false);
+ }
+ };
+
+ return (
+ {
+ if (!open) {
+ closeModal();
+ }
+ }}
+ >
+
+
+ );
+}
diff --git a/packages/local-explorer-ui/src/components/studio/Modal/DeleteConfirmation.tsx b/packages/local-explorer-ui/src/components/studio/Modal/DeleteConfirmation.tsx
new file mode 100644
index 000000000000..fd8a7bda2f0d
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Modal/DeleteConfirmation.tsx
@@ -0,0 +1,105 @@
+import { Button, Dialog, Text } from "@cloudflare/kumo";
+import { useState } from "react";
+import type { ReactNode, SubmitEvent } from "react";
+
+interface StudioDeleteConfirmationModalProps {
+ body: ReactNode;
+ challenge?: string;
+ closeModal: () => void;
+ confirmationText?: string;
+ confirmDisabled?: boolean;
+ confirmType?: "primary" | "danger";
+ failureText?: ReactNode;
+ isOpen: boolean;
+ onConfirm: (e: SubmitEvent) => Promise | void;
+ title: ReactNode;
+}
+
+export const StudioDeleteConfirmationModal = ({
+ body,
+ challenge,
+ closeModal,
+ confirmationText = "Delete",
+ confirmDisabled,
+ confirmType = "danger",
+ failureText,
+ isOpen,
+ onConfirm,
+ title,
+}: StudioDeleteConfirmationModalProps) => {
+ const [challengeInput, setChallengeInput] = useState("");
+ const [deleteFailed, setDeleteFailed] = useState(false);
+ const [isRequesting, setIsRequesting] = useState(false);
+
+ const isValid = !challenge || challengeInput === challenge;
+
+ const handleSubmit = async (e: SubmitEvent) => {
+ e.preventDefault();
+ setIsRequesting(true);
+ try {
+ await onConfirm(e);
+ closeModal();
+ } catch (err) {
+ setIsRequesting(false);
+ if (failureText) {
+ setDeleteFailed(true);
+ } else {
+ throw err;
+ }
+ }
+ };
+
+ return (
+ {
+ if (!open) {
+ closeModal();
+ }
+ }}
+ open={isOpen}
+ >
+
+
+ );
+};
diff --git a/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx b/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx
new file mode 100644
index 000000000000..2c743322b0da
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx
@@ -0,0 +1,85 @@
+import { Tooltip } from "@cloudflare/kumo";
+import { QuestionIcon } from "@phosphor-icons/react";
+import { useMemo } from "react";
+import type { StudioResultStat } from "../../../types/studio";
+import type { ReactElement } from "react";
+
+function formatDuration(duration: number): string {
+ if (duration < 1000) {
+ return `${duration.toFixed(1)}ms`;
+ }
+
+ if (duration < 60_000) {
+ return `${(duration / 1000).toFixed(2)}s`;
+ }
+
+ return `${(duration / 60_000).toFixed(2)}m`;
+}
+
+interface StudioQueryResultStatsProps {
+ stats: StudioResultStat;
+}
+
+export function StudioQueryResultStats({
+ stats,
+}: StudioQueryResultStatsProps): JSX.Element {
+ const statsComponents = useMemo((): ReactElement[] => {
+ const content = new Array();
+
+ if (stats.queryDurationMs !== null) {
+ content.push(
+
+ Query Time
+
+
+
+
+ : {formatDuration(stats.queryDurationMs)}
+
+ );
+ }
+
+ if (stats.requestDurationMs) {
+ content.push(
+
+ Response Time
+
+
+
+
+ : {formatDuration(stats.requestDurationMs)}
+
+ );
+ }
+
+ if (stats.rowsRead) {
+ content.push(
+
+ Rows Read: {stats.rowsRead}
+
+ );
+ }
+
+ if (stats.rowsWritten) {
+ content.push(
+
+ Rows Written:{" "}
+ {stats.rowsWritten}
+
+ );
+ }
+
+ if (stats.rowsAffected) {
+ content.push(
+
+ Affected Rows:{" "}
+ {stats.rowsAffected}
+
+ );
+ }
+
+ return content;
+ }, [stats]);
+
+ return {statsComponents}
;
+}
diff --git a/packages/local-explorer-ui/src/components/studio/SQLEditor/SQLThemePlugin.tsx b/packages/local-explorer-ui/src/components/studio/SQLEditor/SQLThemePlugin.tsx
new file mode 100644
index 000000000000..ddbe693ce656
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/SQLEditor/SQLThemePlugin.tsx
@@ -0,0 +1,104 @@
+import { EditorView } from "@codemirror/view";
+
+export const StudioSQLBaseTheme = EditorView.baseTheme({
+ "&": {
+ height: "100%",
+ minHeight: "100%",
+ },
+ "&.cm-editor": {
+ fontSize: "14px",
+ },
+ "&.cm-focused": {
+ outline: "none !important",
+ },
+ ".cm-scroller": {
+ outline: "none",
+ },
+ "& .cm-line": {
+ borderLeft: "3px solid transparent",
+ paddingLeft: "10px",
+ },
+ ".cm-completionIcon-property::after": {
+ content: '"🧱" !important',
+ },
+});
+
+/**
+ * Unified CodeMirror theme that uses CSS custom properties from tailwind.css.
+ * Automatically adapts to light/dark mode via prefers-color-scheme media queries
+ * without needing any runtime dark mode detection.
+ */
+export const StudioSQLTheme = EditorView.baseTheme({
+ /* Cursor — ensure the caret is visible against both light and dark backgrounds */
+ ".cm-content": {
+ caretColor: "var(--color-text)",
+ },
+
+ /* Selection highlight */
+ "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
+ backgroundColor: "var(--color-selection) !important",
+ },
+
+ /* Active line */
+ ".cm-activeLine": {
+ backgroundColor: "var(--color-active-line)",
+ },
+ ".cm-activeLineGutter": {
+ backgroundColor: "var(--color-active-line)",
+ },
+
+ /* Syntax token colors */
+ ".tok-keyword": {
+ color: "var(--color-syntax-keyword)",
+ },
+ ".tok-string": {
+ color: "var(--color-syntax-string)",
+ },
+ ".tok-number": {
+ color: "var(--color-syntax-number)",
+ },
+ ".tok-comment": {
+ color: "var(--color-syntax-comment)",
+ },
+ ".tok-operator": {
+ color: "var(--color-syntax-operator)",
+ },
+
+ ".cm-table-name": {
+ color: "var(--color-syntax-table)",
+ },
+
+ /* Gutters */
+ ".cm-gutters": {
+ backgroundColor: "var(--color-bg-secondary)",
+ color: "var(--color-muted)",
+ borderRight: "none",
+ width: "30px",
+ },
+
+ /* Autocomplete tooltip */
+ ".cm-tooltip-autocomplete": {
+ backgroundColor: "var(--color-bg)",
+ color: "var(--color-text)",
+ border: "1px solid var(--color-border)",
+ borderRadius: "6px",
+ boxShadow: "0 2px 6px rgba(0, 0, 0, 0.08)",
+ },
+
+ ".cm-completionLabel": {
+ color: "var(--color-text)",
+ },
+ ".cm-completionIcon": {
+ color: "var(--color-muted)",
+ },
+ ".cm-completionDetail": {
+ color: "var(--color-muted)",
+ fontStyle: "italic",
+ marginLeft: "auto",
+ },
+ ".cm-tooltip-autocomplete > ul > li[aria-selected]": {
+ backgroundColor: "var(--color-accent)",
+ borderRadius: "3px",
+ color: "var(--color-text)",
+ },
+});
diff --git a/packages/local-explorer-ui/src/components/studio/TabRegister.tsx b/packages/local-explorer-ui/src/components/studio/TabRegister.tsx
index 3f105c1c32dc..55c15389e254 100644
--- a/packages/local-explorer-ui/src/components/studio/TabRegister.tsx
+++ b/packages/local-explorer-ui/src/components/studio/TabRegister.tsx
@@ -1,16 +1,53 @@
+import { BinocularsIcon, PencilIcon, TableIcon } from "@phosphor-icons/react";
+import { StudioTableExplorerTab } from "./Tabs/TableExplorer";
import type { Icon } from "@phosphor-icons/react";
import type { ReactElement } from "react";
-const RegisteredTabDefinition = [
- // TODO: Add query, table, edit table and new table tab definitions
-] as Array<
- TabDefinition<{
- id?: string;
- schemaName?: string;
- tableName?: string;
- type: string;
- }>
->;
+const QueryTab: TabDefinition<{ id: string; type: "query" }> = {
+ icon: BinocularsIcon,
+ makeComponent: () => <>>,
+ makeIdentifier: (tab) => `query/${tab.id}`,
+ makeTitle: () => "Query",
+ type: "query",
+};
+
+const TableTab: TabDefinition<{
+ schemaName: string;
+ tableName: string;
+ type: "table";
+}> = {
+ icon: TableIcon,
+ makeComponent: ({ schemaName, tableName }) => (
+
+ ),
+ makeIdentifier: (tab) => `table/${tab.schemaName}.${tab.tableName}`,
+ makeTitle: ({ tableName }) => tableName,
+ type: "table",
+};
+
+const EditTableTab: TabDefinition<{
+ schemaName: string;
+ tableName: string;
+ type: "edit-table";
+}> = {
+ icon: PencilIcon,
+ makeComponent: () => <>>,
+ makeIdentifier: (tab) => `edit-table/${tab.schemaName}.${tab.tableName}`,
+ makeTitle: ({ tableName }) => tableName,
+ type: "edit-table",
+};
+
+const NewTableTab: TabDefinition<{
+ type: "create-table";
+}> = {
+ icon: PencilIcon,
+ makeComponent: () => <>>,
+ makeIdentifier: () => `create-table`,
+ makeTitle: () => "Create table",
+ type: "create-table",
+};
+
+const RegisteredTabDefinition = [QueryTab, TableTab, EditTableTab, NewTableTab];
export interface TabDefinition {
icon: Icon;
diff --git a/packages/local-explorer-ui/src/components/studio/Table/Result/EditableCell.tsx b/packages/local-explorer-ui/src/components/studio/Table/Result/EditableCell.tsx
new file mode 100644
index 000000000000..390cf8e55f2e
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Table/Result/EditableCell.tsx
@@ -0,0 +1 @@
+export type StudioTableCellEditorType = "input" | "json" | "text";
diff --git a/packages/local-explorer-ui/src/components/studio/Table/State/Helpers.tsx b/packages/local-explorer-ui/src/components/studio/Table/State/Helpers.tsx
new file mode 100644
index 000000000000..945487ba4cdf
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Table/State/Helpers.tsx
@@ -0,0 +1,358 @@
+import { FlowArrowIcon, KeyIcon, SigmaIcon } from "@phosphor-icons/react";
+import { StudioTableState } from "./index";
+import type {
+ IStudioDriver,
+ StudioColumnTypeHint,
+ StudioResultRow,
+ StudioResultSet,
+ StudioSchemas,
+ StudioTableColumn,
+ StudioTableIndex,
+ StudioTableSchema,
+} from "../../../../types/studio";
+import type { StudioTableHeaderInput } from "./index";
+
+export interface StudioResultHeaderMetadata {
+ from?: {
+ schema: string;
+ table: string;
+ column: string;
+ };
+ isPrimaryKey: boolean;
+ referenceTo?: {
+ schema: string;
+ table: string;
+ column: string;
+ };
+ indexes?: StudioTableIndex[];
+ originalType?: string;
+ typeHint: StudioColumnTypeHint;
+ columnSchema?: StudioTableColumn;
+}
+
+interface StudioTableStateFromResultOptions {
+ driver?: IStudioDriver;
+ result: StudioResultSet;
+ rowNumberOffset?: number;
+ schemas?: StudioSchemas;
+ tableSchema?: StudioTableSchema;
+}
+
+/**
+ * Builds a table header configuration from a query result,
+ * enriching it with metadata and applying multiple transformations.
+ */
+export function createStudioTableStateFromResult(
+ props: StudioTableStateFromResultOptions
+) {
+ const r = new StudioTableState(
+ buildTableResultHeader(props),
+ props.result.rows.map((row) => ({ ...row }))
+ );
+
+ r.rowNumberOffset = props.rowNumberOffset ?? 0;
+ const maxRowNumber = r.getRowsCount() + r.rowNumberOffset;
+ r.gutterColumnWidth = Math.max(40, 10 + maxRowNumber.toString().length * 10);
+
+ return r;
+}
+
+function buildTableResultHeader(
+ props: StudioTableStateFromResultOptions
+): StudioTableHeaderInput[] {
+ const { driver, result, tableSchema } = props;
+
+ const headers = result.headers.map((column) => {
+ return {
+ display: {
+ text: column.displayName,
+ },
+ metadata: {
+ originalType: column.columnType,
+ },
+ name: column.name,
+ setting: {
+ readonly: true,
+ resizable: true,
+ },
+ store: new Map(),
+ } as StudioTableHeaderInput;
+ });
+
+ pipeWithTableSchema(headers, props);
+ pipeEditableTable(headers, props);
+ pipeVirtualColumnAsReadOnly(headers);
+
+ for (const header of headers) {
+ if (driver) {
+ header.metadata.typeHint = driver.getColumnTypeHint(
+ header.metadata.originalType
+ );
+ }
+
+ if (tableSchema) {
+ // Increase the width of the ranking column to accommodate longer scores.
+ if (tableSchema.fts5 && header.name === "rank") {
+ header.display.initialSize = 220;
+ }
+ }
+ }
+
+ pipeCalculateInitialSize(headers, props);
+ pipeColumnIcon(headers);
+
+ return headers;
+}
+
+/**
+ * Estimates an initial column width based on up to 100 row samples.
+ * Wider strings result in wider columns, bounded between 150 and 500 pixels.
+ */
+function pipeCalculateInitialSize(
+ headers: StudioTableHeaderInput[],
+ { result }: StudioTableStateFromResultOptions
+) {
+ for (const header of headers) {
+ // Skip if the initial size is already set
+ if (header.display.initialSize !== undefined) {
+ continue;
+ }
+
+ let maxSize = 0;
+
+ if (header.metadata?.typeHint === "NUMBER") {
+ header.display.initialSize = 100;
+ continue;
+ }
+
+ for (let i = 0; i < Math.min(result.rows.length, 100); i++) {
+ const row = result.rows[i] as StudioResultRow;
+ const cell = row[header.name ?? ""];
+
+ if (typeof cell === "string") {
+ maxSize = Math.max(maxSize, cell.length * 8);
+ } else if (typeof cell === "number") {
+ maxSize = Math.max(maxSize, 100);
+ }
+ }
+
+ header.display.initialSize = Math.min(500, Math.max(150, maxSize));
+ }
+}
+
+/**
+ * Adds schema-related metadata to each column header,
+ * including column type, primary key, and foreign key references.
+ */
+function pipeWithTableSchema(
+ headers: StudioTableHeaderInput[],
+ { tableSchema }: StudioTableStateFromResultOptions
+) {
+ if (!tableSchema) {
+ return;
+ }
+
+ for (const header of headers) {
+ const columnSchema = tableSchema.columns.find(
+ (c) => c.name.toLowerCase() === header.name.toLowerCase()
+ );
+
+ header.metadata.columnSchema = columnSchema;
+ header.metadata.originalType = columnSchema?.type;
+
+ header.metadata.from = {
+ column: header.name,
+ schema: tableSchema.schemaName,
+ table: tableSchema.tableName as string,
+ };
+
+ if (
+ tableSchema.pk
+ .map((p) => p.toLowerCase())
+ .includes(header.name.toLowerCase())
+ ) {
+ header.metadata.isPrimaryKey = true;
+ }
+
+ if (
+ columnSchema &&
+ columnSchema.constraint?.foreignKey &&
+ columnSchema.constraint.foreignKey.foreignColumns
+ ) {
+ header.metadata.referenceTo = {
+ column: columnSchema.constraint.foreignKey.foreignColumns[0] as string,
+ schema: columnSchema.constraint.foreignKey.foreignSchemaName as string,
+ table: columnSchema.constraint.foreignKey.foreignTableName as string,
+ };
+ }
+
+ if (tableSchema.constraints) {
+ for (const constraint of tableSchema.constraints) {
+ if (constraint.foreignKey && constraint.foreignKey.columns) {
+ const foundIndex = constraint.foreignKey.columns.indexOf(header.name);
+ if (foundIndex !== -1) {
+ header.metadata.referenceTo = {
+ column: constraint.foreignKey.columns[foundIndex] as string,
+ schema: constraint.foreignKey.foreignSchemaName as string,
+ table: constraint.foreignKey.foreignTableName as string,
+ };
+ }
+ }
+ }
+ }
+
+ // Binding the indexes to meta
+ header.metadata.indexes = (tableSchema.indexes || []).filter((index) =>
+ index.columns.some(
+ (col) => col.toLowerCase() === header.name.toLowerCase()
+ )
+ );
+ }
+}
+
+/**
+ * Determines which columns are editable based on primary key coverage.
+ * A table is editable only if all PK columns are present in the result.
+ * Adds metadata so editable columns can be toggled appropriately.
+ */
+function pipeEditableTable(
+ headers: StudioTableHeaderInput[],
+ { schemas }: StudioTableStateFromResultOptions
+) {
+ const tables = new Array<{
+ columns: string[];
+ pkColumns: string[];
+ schema: string;
+ table: string;
+ }>();
+
+ for (const header of headers) {
+ const from = header.metadata.from;
+
+ if (from && header.metadata.isPrimaryKey) {
+ const table = tables.find(
+ (t) => t.schema === from.schema && t.table === from.table
+ );
+
+ if (table) {
+ table.columns.push(from.column);
+ } else if (schemas) {
+ const pkColumns =
+ schemas[from.schema]?.find((t) => t.tableName === from.table)
+ ?.tableSchema?.pk ?? [];
+
+ tables.push({
+ columns: [from.column],
+ pkColumns,
+ schema: from.schema,
+ table: from.table,
+ });
+ }
+ }
+ }
+
+ for (const table of tables) {
+ let editable = false;
+ const matchedColumns = table.columns.filter((c) =>
+ table.pkColumns.includes(c)
+ );
+
+ // Mark table as editable if all primary key columns are matched
+ if (matchedColumns.length === table.pkColumns.length) {
+ editable = true;
+ }
+
+ // In SQLite, we can use rowid as a primary key if there is no primary key
+ if (
+ !editable &&
+ table.pkColumns.length === 0 &&
+ table.columns.length === 1 &&
+ table.columns[0] === "rowid"
+ ) {
+ editable = true;
+ }
+
+ // If the table is editable, we will mark the whole columns that belongs to
+ // that table as editable.
+ if (editable) {
+ for (const header of headers) {
+ const from = header.metadata.from;
+
+ if (
+ from &&
+ from.schema === table.schema &&
+ from.table === table.table
+ ) {
+ header.setting.readonly = false;
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Marks virtual (generated) columns as read-only.
+ * These are not meant to be edited by users.
+ */
+function pipeVirtualColumnAsReadOnly(
+ headers: StudioTableHeaderInput[]
+) {
+ for (const header of headers) {
+ if (header.metadata.columnSchema?.constraint?.generatedExpression) {
+ header.setting.readonly = true;
+ }
+ }
+}
+
+function pipeColumnIcon(
+ headers: StudioTableHeaderInput[]
+) {
+ for (const header of headers) {
+ const hasPrimaryKey = header.metadata.isPrimaryKey;
+ const indexes = header.metadata.indexes || [];
+ const hasUniqueIndex = indexes.some((idx) => idx.type === "UNIQUE");
+ const hasKeyIndex = indexes.some((idx) => idx.type === "KEY");
+
+ const hasIcon =
+ hasPrimaryKey ||
+ hasUniqueIndex ||
+ hasKeyIndex ||
+ header.metadata.referenceTo ||
+ header.metadata.columnSchema?.constraint?.generatedExpression;
+
+ if (!hasIcon) {
+ continue;
+ }
+
+ const iconStack = (
+
+ {hasPrimaryKey && (
+
+ )}
+ {hasUniqueIndex && (
+
+ )}
+ {hasKeyIndex && (
+
+ )}
+ {header.metadata.referenceTo && (
+
+ )}
+ {header.metadata.columnSchema?.constraint?.generatedExpression && (
+
+ )}
+
+ );
+
+ header.display.iconElement = iconStack;
+ }
+}
diff --git a/packages/local-explorer-ui/src/components/studio/Table/State/index.tsx b/packages/local-explorer-ui/src/components/studio/Table/State/index.tsx
new file mode 100644
index 000000000000..3490bab39e24
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Table/State/index.tsx
@@ -0,0 +1,867 @@
+import { isEqual } from "../../../../utils/is-equal";
+import type { StudioTableCellEditorType } from "../Result/EditableCell";
+import type { Icon } from "@phosphor-icons/react";
+import type { ReactNode } from "react";
+
+export class StudioTableState {
+ protected focus: [number, number] | null = null;
+ protected data = new Array();
+
+ // Selelection range will be replaced our old selected rows implementation
+ // It offers better flexiblity and allow us to implement more features
+ protected selectionRanges = new Array();
+
+ // Gutter is a sticky column on the left side of the table
+ // We primary use it to display row number at the moment
+ public gutterColumnWidth = 40;
+ public rowNumberOffset = 0;
+
+ protected headers = new Array>();
+ protected headerWidth = new Array();
+
+ protected forceEditorType?: {
+ type: StudioTableCellEditorType;
+ x: number;
+ y: number;
+ };
+
+ protected editMode = false;
+ protected readOnlyMode = false;
+ protected container: HTMLDivElement | null = null;
+
+ protected changeCounter = 1;
+ protected changeLogs: Record = {};
+
+ constructor(
+ headers: StudioTableHeaderInput[],
+ data: Record[]
+ ) {
+ this.headers = headers;
+ this.data = data.map((row) => ({ raw: row }));
+ this.headerWidth = headers.map((h) => h.display.initialSize);
+ }
+
+ setReadOnlyMode(readOnly: boolean): void {
+ this.readOnlyMode = readOnly;
+ }
+
+ getReadOnlyMode(): boolean {
+ return this.readOnlyMode;
+ }
+
+ setContainer(div: HTMLDivElement | null): void {
+ this.container = div;
+ }
+
+ // ------------------------------------------------
+ // Event Handlers
+ // ------------------------------------------------
+ // This section contains methods and properties related to event handling,
+ // such as managing listeners and broadcasting changes.
+ protected changeListeners = new Array();
+ protected changeBroadcastDebounceTimer: NodeJS.Timeout | null = null;
+
+ addChangeListener(cb: TableStateChangeListener) {
+ this.changeListeners.push(cb);
+
+ return () => {
+ this.changeListeners = this.changeListeners.filter((c) => c !== cb);
+ };
+ }
+
+ /**
+ * Broadcasts changes to registered callbacks, either instantly or with a debounce delay.
+ *
+ * @param instant If `true`, the changes are broadcasted immediately without any delay.
+ * Otherwise, the changes are broadcasted after a debounce delay.
+ */
+ protected broadcastChange(instant?: boolean): boolean {
+ if (instant) {
+ if (this.changeBroadcastDebounceTimer) {
+ clearTimeout(this.changeBroadcastDebounceTimer);
+ }
+
+ this.changeListeners.reverse().forEach((cb) => cb(this));
+ }
+
+ if (this.changeBroadcastDebounceTimer) {
+ return false;
+ }
+
+ this.changeBroadcastDebounceTimer = setTimeout(() => {
+ this.changeBroadcastDebounceTimer = null;
+ this.changeListeners.reverse().forEach((cb) => cb(this));
+ }, 5);
+
+ return true;
+ }
+ // End of Event Handlers
+
+ protected mergeSelectionRanges(): void {
+ // Sort ranges to simplify merging
+ this.selectionRanges.sort((a, b) => a.y1 - b.y1 || a.x1 - b.x1);
+
+ const merged: TableSelectionRange[] = [];
+ let isLastMoveMerged = false;
+
+ for (const range of this.selectionRanges) {
+ const last = merged[merged.length - 1];
+ if (
+ last &&
+ ((last.y1 === range.y1 &&
+ last.y2 === range.y2 &&
+ last.x2 + 1 === range.x1) ||
+ (last.x1 === range.x1 &&
+ last.x2 === range.x2 &&
+ last.y2 + 1 === range.y1))
+ ) {
+ last.x2 = Math.max(last.x2, range.x2);
+ last.y2 = Math.max(last.y2, range.y2);
+ isLastMoveMerged = true;
+ } else {
+ merged.push({ ...range });
+ isLastMoveMerged = false;
+ }
+ }
+ this.selectionRanges = merged;
+ if (isLastMoveMerged) {
+ this.mergeSelectionRanges();
+ }
+ }
+
+ protected splitSelectionRange(
+ selection: TableSelectionRange,
+ deselection: TableSelectionRange
+ ): TableSelectionRange[] {
+ const result = new Array();
+
+ if (deselection.y1 > selection.y1) {
+ result.push({
+ x1: selection.x1,
+ y1: selection.y1,
+ x2: selection.x2,
+ y2: deselection.y1 - 1,
+ });
+ }
+
+ if (deselection.y2 < selection.y2) {
+ result.push({
+ x1: selection.x1,
+ y1: deselection.y2 + 1,
+ x2: selection.x2,
+ y2: selection.y2,
+ });
+ }
+
+ if (deselection.x1 > selection.x1) {
+ result.push({
+ x1: selection.x1,
+ y1: Math.max(selection.y1, deselection.y1),
+ x2: deselection.x1 - 1,
+ y2: Math.min(selection.y2, deselection.y2),
+ });
+ }
+
+ if (deselection.x2 < selection.x2) {
+ result.push({
+ x1: deselection.x2 + 1,
+ y1: Math.max(selection.y1, deselection.y1),
+ x2: selection.x2,
+ y2: Math.min(selection.y2, deselection.y2),
+ });
+ }
+
+ return result;
+ }
+
+ // ------------------------------------------------
+ // Handle headers and data
+ // ------------------------------------------------
+ getHeaders(): StudioTableHeaderInput[] {
+ return this.headers;
+ }
+
+ getValue(y: number, x: number): unknown {
+ const rowChange = this.data[y]?.change;
+ if (rowChange) {
+ const currentHeaderName = this.headers[x]?.name ?? "";
+ if (currentHeaderName in rowChange) {
+ return rowChange[currentHeaderName];
+ }
+
+ return this.getOriginalValue(y, x);
+ }
+ return this.getOriginalValue(y, x);
+ }
+
+ hasCellChange(y: number, x: number): boolean {
+ const changeLog = this.data[y]?.change;
+ if (!changeLog) {
+ return false;
+ }
+
+ const currentHeaderName = this.headers[x]?.name ?? "";
+ return currentHeaderName in changeLog;
+ }
+
+ getOriginalValue(y: number, x: number): unknown {
+ const currentHeaderName = this.headers[x]?.name ?? "";
+ return this.data[y]?.raw[currentHeaderName];
+ }
+
+ changeValue(y: number, x: number, newValue: unknown): void {
+ if (this.readOnlyMode || this.headers[x]?.setting.readonly) {
+ return;
+ }
+
+ const oldValue = this.getOriginalValue(y, x);
+
+ const row = this.data[y];
+ if (!row) {
+ return;
+ }
+
+ const headerName = this.headers[x]?.name ?? "";
+
+ if (isEqual(oldValue, newValue)) {
+ const rowChange = row.change;
+ if (rowChange && headerName in rowChange) {
+ delete rowChange[headerName];
+ if (Object.entries(rowChange).length === 0) {
+ if (row.changeKey) {
+ delete this.changeLogs[row.changeKey];
+ delete row.changeKey;
+ }
+ delete row.change;
+ }
+ }
+ } else {
+ const rowChange = row.change;
+ if (rowChange) {
+ rowChange[headerName] = newValue;
+ } else {
+ row.changeKey = ++this.changeCounter;
+ row.change = { [headerName]: newValue };
+ this.changeLogs[row.changeKey] = row;
+ }
+ }
+
+ this.broadcastChange();
+ }
+
+ getChangedRows(): StudioTableStateRow[] {
+ return Object.values(this.changeLogs);
+ }
+
+ getRowsCount(): number {
+ return this.data.length;
+ }
+
+ getHeaderCount(): number {
+ return this.headers.length;
+ }
+
+ discardAllChange(): void {
+ const newRows = new Array();
+
+ for (const row of Object.values(this.changeLogs)) {
+ if (row.isNewRow) {
+ newRows.push(row);
+ delete row.change;
+ delete row.changeKey;
+ delete row.isNewRow;
+ } else {
+ delete row.change;
+ delete row.changeKey;
+ delete row.isRemoved;
+ }
+ }
+
+ // Remove all new rows
+ this.data = this.data.filter((row) => !newRows.includes(row));
+ this.changeLogs = {};
+
+ this.broadcastChange(true);
+ }
+
+ applyChanges(
+ updatedRows: {
+ row: StudioTableStateRow;
+ updated: Record;
+ }[]
+ ): void {
+ const rowChanges = this.getChangedRows();
+
+ const removedRows = rowChanges.filter((row) => row.isRemoved);
+ for (const row of rowChanges) {
+ const updated = updatedRows.find((updateRow) => updateRow.row === row);
+ row.raw = { ...row.raw, ...row.change, ...updated?.updated };
+ delete row.changeKey;
+ delete row.change;
+ delete row.isNewRow;
+ delete row.isRemoved;
+ }
+
+ if (removedRows.length > 0) {
+ this.data = this.data.filter((row) => !removedRows.includes(row));
+ // after rows were removed, we need to deselect them
+ this.selectionRanges = [];
+ }
+
+ this.changeLogs = {};
+ this.broadcastChange();
+ }
+
+ insertNewRow(index = -1, initialData: Record = {}): void {
+ if (index === -1) {
+ const focus = this.getFocus();
+ if (focus) {
+ index = focus.y;
+ }
+ }
+
+ if (index < 0) {
+ index = 0;
+ }
+
+ const newRow = {
+ change: initialData,
+ changeKey: ++this.changeCounter,
+ isNewRow: true,
+ raw: {},
+ };
+
+ this.data.splice(index, 0, newRow);
+ this.changeLogs[newRow.changeKey] = newRow;
+ this.broadcastChange();
+ }
+
+ isNewRow(index: number): boolean {
+ return !!this.data[index]?.isNewRow;
+ }
+
+ removeRow(index = -1): void {
+ if (index === -1) {
+ // Remove the row at focus
+ const focus = this.getFocus();
+ if (focus) {
+ index = focus.y;
+ }
+ }
+
+ const row = this.data[index];
+
+ if (row) {
+ if (row.isNewRow && row.changeKey) {
+ delete this.changeLogs[row.changeKey];
+ this.data = this.data.filter((dataRow) => dataRow != row);
+ } else {
+ row.isRemoved = true;
+ if (!row.changeKey) {
+ row.change = {};
+ row.changeKey = ++this.changeCounter;
+ this.changeLogs[row.changeKey] = row;
+ }
+ }
+ }
+
+ this.broadcastChange();
+ }
+
+ isRemovedRow(index: number): boolean {
+ return !!this.data[index]?.isRemoved;
+ }
+
+ getAllRows(): StudioTableStateRow[] {
+ return this.data;
+ }
+
+ getRowByIndex(idx: number): StudioTableStateRow | undefined {
+ return this.data[idx];
+ }
+
+ // ------------------------------------------------
+ // Handle focus logic
+ // ------------------------------------------------
+ getFocus(): { x: number; y: number } | null {
+ return this.focus
+ ? {
+ x: this.focus[1],
+ y: this.focus[0],
+ }
+ : null;
+ }
+
+ getFocusValue(): unknown {
+ const focusCell = this.getFocus();
+ if (focusCell) {
+ return this.getValue(focusCell.y, focusCell.x);
+ }
+
+ return undefined;
+ }
+
+ setFocusValue(newValue: unknown): void {
+ const focusCell = this.getFocus();
+ if (focusCell) {
+ this.changeValue(focusCell.y, focusCell.x, newValue);
+ }
+ }
+
+ hasFocus(y: number, x: number): boolean {
+ if (!this.focus) {
+ return false;
+ }
+
+ return this.focus[0] === y && this.focus[1] === x;
+ }
+
+ setFocus(y: number, x: number): void {
+ this.focus = [y, x];
+ this.broadcastChange();
+ }
+
+ isInEditMode(): boolean {
+ return this.editMode;
+ }
+
+ enterEditMode(type?: StudioTableCellEditorType): void {
+ this.editMode = true;
+
+ // Store the focused cell and forced editor type if provided
+ this.forceEditorType =
+ type && this.focus
+ ? { type, y: this.focus[0], x: this.focus[1] }
+ : undefined;
+
+ this.broadcastChange();
+ }
+
+ getForcedEditorType(): StudioTableCellEditorType | undefined {
+ if (!this.focus) {
+ return;
+ }
+ if (!this.forceEditorType) {
+ return;
+ }
+ if (this.focus[0] !== this.forceEditorType.y) {
+ return;
+ }
+ if (this.focus[1] !== this.forceEditorType.x) {
+ return;
+ }
+
+ return this.forceEditorType.type;
+ }
+
+ exitEditMode(): void {
+ this.editMode = false;
+
+ if (this.container) {
+ this.container.focus();
+ }
+
+ this.broadcastChange();
+ }
+
+ clearFocus(): void {
+ this.focus = null;
+ this.broadcastChange();
+ }
+
+ setHeaderWidth(idx: number, newWidth: number): void {
+ this.headerWidth[idx] = newWidth;
+ }
+
+ getHeaderWidth(): number[] {
+ return this.headerWidth;
+ }
+
+ scrollToCell(
+ horizontal: "left" | "right",
+ vertical: "top" | "bottom",
+ cell: { x: number; y: number }
+ ): void {
+ if (this.container && cell) {
+ const cellX = cell.x;
+ const cellY = cell.y;
+ let cellLeft = 0;
+ let cellRight = 0;
+ const cellTop = (cellY + 1) * 38;
+ const cellBottom = cellTop + 38;
+
+ for (let i = 0; i < cellX; i++) {
+ cellLeft += this.headerWidth[i] ?? 0;
+ }
+ cellRight = cellLeft + (this.headerWidth[cellX] ?? 0);
+
+ const width = this.container.clientWidth;
+ const height = this.container.clientHeight;
+ const containerLeft = this.container.scrollLeft;
+ const containerRight = containerLeft + this.container.clientWidth;
+ const containerTop = this.container.scrollTop;
+ const containerBottom = containerTop + height;
+
+ if (horizontal === "right") {
+ if (cellRight > containerRight) {
+ this.container.scrollLeft = Math.max(
+ 0,
+ cellRight - width + this.gutterColumnWidth
+ );
+ }
+ } else {
+ if (cellLeft < containerLeft) {
+ this.container.scrollLeft = cellLeft;
+ }
+ }
+
+ if (vertical === "bottom") {
+ if (cellBottom > containerBottom) {
+ this.container.scrollTop = Math.max(0, cellBottom - height);
+ }
+ } else {
+ if (cellTop - 38 < containerTop) {
+ this.container.scrollTop = Math.max(0, cellTop - 38);
+ }
+ }
+ }
+ }
+
+ clearSelect(): void {
+ this.selectionRanges = [];
+ this.broadcastChange();
+ }
+
+ getSelectionRanges(): TableSelectionRange[] {
+ return this.selectionRanges;
+ }
+
+ setSelectionRanges(ranges: TableSelectionRange[]): void {
+ this.selectionRanges = ranges;
+ this.broadcastChange();
+ }
+
+ getSelectedRowCount(): number {
+ return this.getSelectedRowIndex().length;
+ }
+
+ getSelectedRowsArray(): unknown[][] {
+ return selectArrayFromIndexList(this.data, this.getSelectedRowIndex()).map(
+ (row) => this.headers.map((header) => row.raw[header.name])
+ );
+ }
+
+ getSelectedRowIndex(): number[] {
+ const selectedRows = new Set();
+
+ for (const range of this.selectionRanges) {
+ for (let i = range.y1; i <= range.y2; i++) {
+ selectedRows.add(i);
+ }
+ }
+
+ return Array.from(selectedRows.values());
+ }
+
+ getSelectedColIndex(): number[] {
+ const selectedCols = new Set();
+
+ for (const range of this.selectionRanges) {
+ for (let i = range.x1; i <= range.x2; i++) {
+ selectedCols.add(i);
+ }
+ }
+
+ return Array.from(selectedCols.values());
+ }
+
+ isFullSelectionRow(y: number): boolean {
+ for (const range of this.selectionRanges) {
+ if (
+ range.y1 <= y &&
+ range.y2 >= y &&
+ range.x1 === 0 &&
+ range.x2 === this.getHeaderCount() - 1
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ getFullSelectionRowsIndex(): number[] {
+ const selectedRows = new Set();
+
+ for (const range of this.selectionRanges) {
+ if (range.x1 === 0 && range.x2 === this.getHeaderCount() - 1) {
+ for (let i = range.y1; i <= range.y2; i++) {
+ if (!selectedRows.has(i)) {
+ selectedRows.add(i);
+ }
+ }
+ }
+ }
+
+ return Array.from(selectedRows.values());
+ }
+
+ getFullSelectionColsIndex(): number[] {
+ const selectedCols = new Set();
+
+ for (const range of this.selectionRanges) {
+ if (range.y1 === 0 && range.y2 === this.getRowsCount() - 1) {
+ for (let i = range.x1; i <= range.x2; i++) {
+ if (!selectedCols.has(i)) {
+ selectedCols.add(i);
+ }
+ }
+ }
+ }
+
+ return Array.from(selectedCols.values());
+ }
+
+ isFullSelectionCol(x: number): boolean {
+ for (const range of this.selectionRanges) {
+ if (
+ range.x1 <= x &&
+ range.x2 >= x &&
+ range.y1 === 0 &&
+ range.y2 === this.getRowsCount() - 1
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ selectRow(y: number): void {
+ this.selectionRanges = [
+ { x1: 0, y1: y, x2: this.headers.length - 1, y2: y },
+ ];
+
+ this.broadcastChange();
+ }
+
+ selectColumn(x: number): void {
+ this.selectionRanges = [
+ { x1: x, y1: 0, x2: x, y2: this.getRowsCount() - 1 },
+ ];
+
+ this.broadcastChange();
+ }
+
+ selectCell(y: number, x: number, focus = true): void {
+ this.selectionRanges = [{ x1: x, y1: y, x2: x, y2: y }];
+
+ if (focus) {
+ this.setFocus(y, x);
+ } else {
+ this.broadcastChange();
+ }
+ }
+
+ selectCellRange(y1: number, x1: number, y2: number, x2: number): void {
+ this.selectionRanges = [
+ {
+ x1: Math.min(x1, x2),
+ y1: Math.min(y1, y2),
+ x2: Math.max(x1, x2),
+ y2: Math.max(y1, y2),
+ },
+ ];
+ this.broadcastChange();
+ }
+
+ findSelectionRange(range: TableSelectionRange): number {
+ return this.selectionRanges.findIndex(
+ (r) =>
+ r.x1 <= range.x1 &&
+ r.x2 >= range.x2 &&
+ r.y1 <= range.y1 &&
+ r.y2 >= range.y2
+ );
+ }
+
+ addSelectionRange(y1: number, x1: number, y2: number, x2: number): void {
+ const newRange = {
+ x1: Math.min(x1, x2),
+ x2: Math.max(x1, x2),
+ y1: Math.min(y1, y2),
+ y2: Math.max(y1, y2),
+ };
+
+ const selectedRangeIndex = this.findSelectionRange(newRange);
+ if (selectedRangeIndex < 0) {
+ this.selectionRanges.push(newRange);
+ this.mergeSelectionRanges();
+ } else {
+ // `findSelectionRange` returned a non-negative index, guaranteeing this exists
+ const selectedRange = this.selectionRanges[
+ selectedRangeIndex
+ ] as TableSelectionRange;
+
+ const splitedRanges = this.splitSelectionRange(selectedRange, newRange);
+ if (splitedRanges.length >= 0) {
+ this.selectionRanges.splice(selectedRangeIndex, 1);
+ this.selectionRanges = [...this.selectionRanges, ...splitedRanges];
+ this.mergeSelectionRanges();
+ }
+ }
+
+ this.broadcastChange();
+ }
+
+ addSelectionRow(y: number): void {
+ const newRange = {
+ x1: 0,
+ x2: this.headers.length - 1,
+ y1: y,
+ y2: y,
+ };
+
+ this.addSelectionRange(newRange.y1, newRange.x1, newRange.y2, newRange.x2);
+ }
+
+ addSelectionCol(x: number): void {
+ const newRange = {
+ x1: x,
+ x2: x,
+ y1: 0,
+ y2: this.getRowsCount() - 1,
+ };
+
+ this.addSelectionRange(newRange.y1, newRange.x1, newRange.y2, newRange.x2);
+ }
+
+ selectRowRange(y1: number, y2: number): void {
+ const newRange = {
+ x1: 0,
+ x2: this.headers.length - 1,
+ y1: Math.min(y1, y2),
+ y2: Math.max(y1, y2),
+ };
+
+ this.selectionRanges = [newRange];
+ this.broadcastChange();
+ }
+
+ selectColRange(x1: number, x2: number): void {
+ const newRange = {
+ x1: Math.min(x1, x2),
+ x2: Math.max(x1, x2),
+ y1: 0,
+ y2: this.getRowsCount() - 1,
+ };
+
+ this.selectionRanges = [newRange];
+ this.broadcastChange();
+ }
+
+ isRowSelected(y: number): boolean {
+ for (const range of this.selectionRanges) {
+ if (y >= range.y1 && y <= range.y2) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ getSelectionRange(y: number, x: number): TableSelectionRange | null {
+ for (const range of this.selectionRanges) {
+ if (y >= range.y1 && y <= range.y2 && x >= range.x1 && x <= range.x2) {
+ return range;
+ }
+ }
+
+ return null;
+ }
+
+ getCellStatus(
+ y: number,
+ x: number
+ ): {
+ isBorderBottom: boolean;
+ isBorderRight: boolean;
+ isFocus: boolean;
+ isSelected: boolean;
+ } {
+ const focus = this.getFocus();
+ const isFocus = !!focus && focus.y === y && focus.x === x;
+
+ // Finding the selection range
+ let isSelected = false;
+ let isBorderRight = false;
+ let isBorderBottom = false;
+
+ for (const range of this.selectionRanges) {
+ if (y >= range.y1 && y <= range.y2) {
+ if (x >= range.x1 && x <= range.x2) {
+ isSelected = true;
+ }
+
+ if (x === range.x2 || x + 1 === range.x1) {
+ isBorderRight = true;
+ }
+ }
+
+ if (x >= range.x1 && x <= range.x2) {
+ if (y === range.y2 || y + 1 === range.y1) {
+ isBorderBottom = true;
+ }
+ }
+ }
+
+ return {
+ isBorderBottom,
+ isBorderRight,
+ isFocus,
+ isSelected,
+ };
+ }
+}
+
+function selectArrayFromIndexList(
+ data: T[],
+ indexList: number[]
+): T[] {
+ return indexList.map((index) => data[index]) as T[];
+}
+
+export interface StudioTableStateRow {
+ change?: Record;
+ changeKey?: number;
+ isNewRow?: boolean;
+ isRemoved?: boolean;
+ raw: Record;
+}
+
+type TableStateChangeListener = (state: StudioTableState) => void;
+
+interface TableSelectionRange {
+ x1: number;
+ x2: number;
+ y1: number;
+ y2: number;
+}
+
+export interface StudioTableHeaderInput {
+ display: {
+ icon?: Icon;
+ iconElement?: ReactNode;
+ initialSize: number;
+ text: string;
+ tooltip?: string;
+ };
+ metadata: MetadataType;
+ name: string;
+ onContextMenu?: (e: React.MouseEvent, headerIndex: number) => void;
+ setting: {
+ readonly: boolean;
+ resizable: boolean;
+ };
+ store: Map;
+}
diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx
new file mode 100644
index 000000000000..323e169da67f
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx
@@ -0,0 +1,525 @@
+import { Button, InputGroup } from "@cloudflare/kumo";
+import {
+ ArrowsCounterClockwiseIcon,
+ CaretLeftIcon,
+ CaretRightIcon,
+ SpinnerIcon,
+} from "@phosphor-icons/react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ buildStudioMutationPlans,
+ commitStudioTableChanges,
+} from "../../../utils/studio/commit";
+import { useStudioContext } from "../Context";
+import { useModal } from "../Modal";
+import { StudioCommitConfirmation } from "../Modal/CommitConfirmation";
+import { StudioDeleteConfirmationModal } from "../Modal/DeleteConfirmation";
+import { StudioQueryResultStats } from "../Query/ResultStats";
+import { createStudioTableStateFromResult } from "../Table/State/Helpers";
+import { useStudioCurrentWindowTab } from "../WindowTab/Context";
+import type {
+ StudioResultStat,
+ StudioSortDirection,
+ StudioTableSchema,
+} from "../../../types/studio";
+import type { StudioTableState } from "../Table/State";
+import type { StudioResultHeaderMetadata } from "../Table/State/Helpers";
+
+interface StudioTableExplorerTabProps {
+ schemaName: string;
+ tableName: string;
+}
+
+const DEFAULT_PAGE_SIZE = 50;
+
+export function StudioTableExplorerTab({
+ schemaName,
+ tableName,
+}: StudioTableExplorerTabProps): JSX.Element {
+ const { driver, schemas } = useStudioContext();
+
+ const [changeNumber, setChangeNumber] = useState(0);
+ const [error, setError] = useState("");
+ const [hasNextPage, setHasNextPage] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [orderBy, setOrderBy] = useState<{
+ columName: string;
+ direction: StudioSortDirection;
+ }>();
+ const [pageLimit, setPageLimit] = useState(DEFAULT_PAGE_SIZE);
+ const [pageLimitInput, setPageLimitInput] = useState(
+ DEFAULT_PAGE_SIZE.toString()
+ );
+ const [pageOffset, setPageOffset] = useState(0);
+ const [pageOffsetInput, setPageOffsetInput] = useState("0");
+ const [queryStats, setQueryStats] = useState();
+ const [schema, setSchema] = useState();
+ const [state, setState] =
+ useState>();
+ const [whereRaw, setWhereRaw] = useState("");
+
+ const { openModal } = useModal();
+
+ // @ts-expect-error TODO: Re-enable in a later PR
+ const _filterAutoCompleteColumns = useMemo(() => {
+ if (!schema) {
+ return [];
+ }
+
+ const autoCompleteColumns = schema.columns.map((column) => column.name);
+
+ // This is necessary for FTS5 tables, as the search syntax is "{table_name} MATCH 'your_search'".
+ // Without this, this syntax is not valid, making the search pretty useless for full text search tables.
+ if (schema.fts5 && schema.tableName) {
+ autoCompleteColumns.push(schema.tableName);
+ }
+
+ return autoCompleteColumns;
+ }, [schema]);
+
+ const { setDirtyState, setBeforeTabClosingHandler } =
+ useStudioCurrentWindowTab();
+
+ // This effect subscribes to the external table state's change event and syncs
+ // the change count into React state. The initial synchronous setState is valid
+ // setup before subscribing to the external store.
+ useEffect(() => {
+ if (state) {
+ setChangeNumber(state.getChangedRows().length);
+
+ return state.addChangeListener(() => {
+ setChangeNumber(state.getChangedRows().length);
+ });
+ }
+ }, [state, setDirtyState]);
+
+ const removedRowsCount = state
+ ? state
+ .getChangedRows()
+ .reduce((acc, row) => (row.isRemoved ? acc + 1 : acc), 0)
+ : 0;
+
+ // Mark the current tab as dirty if there are unsaved changes
+ useEffect((): void => {
+ setDirtyState(changeNumber > 0);
+ }, [setDirtyState, changeNumber]);
+
+ // Prompt the user before closing the tab if there are unsaved changes
+ useEffect((): void => {
+ setBeforeTabClosingHandler((currentTab) => {
+ if (currentTab.isDirty) {
+ return confirm(
+ "You have unsaved changes. Do you want to close without saving?"
+ );
+ }
+
+ return true;
+ });
+ }, [setBeforeTabClosingHandler]);
+
+ const onRefreshClicked = useCallback(async (): Promise => {
+ if (!schemas) {
+ return;
+ }
+
+ setLoading(true);
+ setError("");
+
+ try {
+ const { result, schema: fetchedSchema } = await driver.selectTable(
+ schemaName,
+ tableName,
+ {
+ limit: pageLimit + 1, // We try to get 1 more than limit so we can decide if there is next page
+ offset: pageOffset,
+ orderByColumn: orderBy?.columName,
+ orderByDirection: orderBy?.direction,
+ whereRaw,
+ }
+ );
+
+ // Fetch one extra row to check if a next page exists.
+ // If more than `pageLimit` rows are returned, we know there's another page.
+ // Then trim the result back down to the actual page size.
+ setHasNextPage(result.rows.length > pageLimit);
+ setQueryStats(result.stat);
+ result.rows = result.rows.slice(0, pageLimit);
+
+ setState(
+ createStudioTableStateFromResult({
+ driver,
+ result,
+ rowNumberOffset: pageOffset,
+ schemas,
+ tableSchema: fetchedSchema,
+ })
+ );
+
+ setSchema(fetchedSchema);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [
+ driver,
+ orderBy,
+ pageLimit,
+ pageOffset,
+ schemaName,
+ schemas,
+ tableName,
+ whereRaw,
+ ]);
+
+ useEffect((): void => {
+ void onRefreshClicked();
+ }, [onRefreshClicked]);
+
+ const readOnlyMode = useMemo(() => {
+ if (!state) {
+ return true;
+ }
+
+ return state.getHeaders().every((header) => header.setting.readonly);
+ }, [state]);
+
+ // @ts-expect-error TODO: Re-enable in a later PR
+ const _headerIndexList = useMemo((): number[] => {
+ if (!schema) {
+ return [];
+ }
+
+ return Array.from({ length: schema.columns.length }, (_, k) => k);
+ }, [schema]);
+
+ /**
+ * Executes a callback only after confirming discard of unsaved changes.
+ * If there are unsaved changes, shows a confirmation modal first.
+ */
+ const guardUnsavedChanges = useCallback(
+ (confirmCallback: () => void, cancelCallback?: () => void): void => {
+ if (changeNumber > 0) {
+ // The onConfirm and onClose handlers are both triggered by the modal,
+ // so we use a flag isClosingAfterConfirm to distinguish whether the modal
+ // was closed by confirming or by canceling.
+ let isClosingAfterConfirm = false;
+
+ openModal(StudioDeleteConfirmationModal, {
+ body: (
+
+ You have unsaved changes. Are you sure you want to discard them?
+
+ ),
+ confirmationText: "Discard Changes",
+ onConfirm: async () => {
+ confirmCallback();
+ isClosingAfterConfirm = true;
+ },
+ onClose: () => {
+ // Only trigger cancel callback if modal was closed without confirmation
+ if (!isClosingAfterConfirm) {
+ cancelCallback?.();
+ }
+ },
+ title: "Discard Unsaved Changes?",
+ });
+ } else {
+ confirmCallback();
+ }
+ },
+ [openModal, changeNumber]
+ );
+
+ const onOffsetBlur = useCallback(
+ (e: React.FocusEvent): void => {
+ const raw = e.currentTarget.value.trim();
+ const offsetValue = Number(raw);
+ const clampedOffset = Math.max(
+ 0,
+ Number.isFinite(offsetValue) ? offsetValue : 0
+ );
+
+ if (pageOffset !== clampedOffset) {
+ guardUnsavedChanges(
+ () => {
+ setPageOffset(clampedOffset);
+ setPageOffsetInput(clampedOffset.toString());
+ },
+ () => {
+ setPageOffsetInput(pageOffset.toString());
+ }
+ );
+ } else {
+ setPageOffsetInput(clampedOffset.toString());
+ }
+ },
+ [pageOffset, guardUnsavedChanges]
+ );
+
+ const onLimitBlur = useCallback(
+ (e: React.FocusEvent): void => {
+ const raw = e.currentTarget.value.trim();
+ const limitValue = Number(raw);
+ const clampedLimit = Math.max(
+ 1,
+ Number.isFinite(limitValue) ? limitValue : 1
+ );
+
+ if (pageLimit !== clampedLimit) {
+ guardUnsavedChanges(
+ () => {
+ setPageLimit(clampedLimit);
+ setPageLimitInput(clampedLimit.toString());
+ },
+ () => {
+ setPageLimitInput(pageLimit.toString());
+ }
+ );
+ } else {
+ setPageLimitInput(clampedLimit.toString());
+ }
+ },
+ [pageLimit, guardUnsavedChanges]
+ );
+
+ const onPageNext = useCallback((): void => {
+ guardUnsavedChanges(() => {
+ const clampedOffset = Math.max(pageOffset + pageLimit, 0);
+ setPageOffset(clampedOffset);
+ setPageOffsetInput(clampedOffset.toString());
+ });
+ }, [pageOffset, pageLimit, guardUnsavedChanges]);
+
+ const onPagePrevious = useCallback((): void => {
+ guardUnsavedChanges(() => {
+ const clampedOffset = Math.max(pageOffset - pageLimit, 0);
+ setPageOffset(clampedOffset);
+ setPageOffsetInput(clampedOffset.toString());
+ });
+ }, [pageOffset, pageLimit, guardUnsavedChanges]);
+
+ // @ts-expect-error TODO: Re-enable in a later PR
+ const _onWhereRawApplied = useCallback(
+ (newWhereRaw: string): void => {
+ guardUnsavedChanges(() => {
+ setWhereRaw(newWhereRaw);
+
+ // Reset the pagination when apply new filter
+ setPageOffset(0);
+ setPageOffsetInput("0");
+ setPageLimitInput(DEFAULT_PAGE_SIZE.toString());
+ setPageLimit(DEFAULT_PAGE_SIZE);
+ });
+ },
+ [guardUnsavedChanges]
+ );
+
+ const onAddRowClick = useCallback((): void => {
+ if (state) {
+ state.insertNewRow();
+ }
+ }, [state]);
+
+ const onDeleteRowClick = useCallback((): void => {
+ if (state) {
+ state.getSelectedRowIndex().map((rowIndex) => state.removeRow(rowIndex));
+ }
+ }, [state]);
+
+ const onDiscardClick = useCallback((): void => {
+ if (state) {
+ state.discardAllChange();
+ }
+ }, [state]);
+
+ const onCommit = useCallback((): void => {
+ if (!schema || !state) {
+ return;
+ }
+
+ try {
+ const plans = buildStudioMutationPlans({
+ data: state,
+ tableSchema: schema,
+ });
+
+ openModal(StudioCommitConfirmation, {
+ onClose: () => {},
+ onConfirm: async (): Promise => {
+ const commitResult = await commitStudioTableChanges({
+ data: state,
+ driver,
+ tableName,
+ tableSchema: schema,
+ });
+
+ if (commitResult.errorMessage) {
+ throw new Error(commitResult.errorMessage);
+ }
+ },
+ statements: driver.createMutationStatements(
+ schema.schemaName,
+ tableName,
+ plans.map((plan) => plan.plan),
+ schema
+ ),
+ });
+ } catch (e) {
+ if (e instanceof Error) {
+ alert(e.message);
+ } else {
+ alert(String(e));
+ }
+ }
+ }, [driver, tableName, schema, state, openModal]);
+
+ // @ts-expect-error TODO: Re-enable in a later PR
+ const _onOrderByColumnChange = useCallback(
+ (columName: string, direction: StudioSortDirection) => {
+ guardUnsavedChanges(() => {
+ setOrderBy({ columName, direction });
+ });
+ },
+ [guardUnsavedChanges]
+ );
+
+ return (
+
+
+
+
+ {!readOnlyMode && (
+ <>
+
+
+ >
+ )}
+
+
+ {/* TODO: Re-add in a later PR */}
+ {/* */}
+
+
+ {changeNumber > 0 && (
+ <>
+
+
+ >
+ )}
+
+
+ {/* TODO: Re-add in a later PR */}
+ {/* {schema && state && !error && (
+
+ )} */}
+
+ {error &&
{error}
}
+
+ {loading && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+ {queryStats && }
+
+
+
+
+
+ );
+}
diff --git a/packages/local-explorer-ui/src/drivers/sqlite/generate.ts b/packages/local-explorer-ui/src/drivers/sqlite/generate.ts
index db5e3817024c..0b1b889f3807 100644
--- a/packages/local-explorer-ui/src/drivers/sqlite/generate.ts
+++ b/packages/local-explorer-ui/src/drivers/sqlite/generate.ts
@@ -1,3 +1,4 @@
+import { isEqual } from "../../utils/is-equal";
import type {
IStudioDriver,
StudioTableColumn,
@@ -23,58 +24,6 @@ function omit(obj: T, keys: Array): Partial {
return result;
}
-/**
- * Performs a recursive deep equality comparison between two values.
- *
- * Handles primitives, `null`, arrays, and plain objects. Values are
- * considered equal when they share the same keys and all nested
- * values are themselves deeply equal.
- *
- * @param a - The first value to compare.
- * @param b - The second value to compare.
- *
- * @returns `true` if the values are deeply equal, `false` otherwise.
- */
-function isEqual(a: unknown, b: unknown): boolean {
- if (a === b) {
- return true;
- }
-
- if (
- a === null ||
- b === null ||
- typeof a !== "object" ||
- typeof b !== "object"
- ) {
- return false;
- }
-
- if (Array.isArray(a) !== Array.isArray(b)) {
- return false;
- }
-
- const keysA = Object.keys(a);
- const keysB = Object.keys(b);
-
- if (keysA.length !== keysB.length) {
- return false;
- }
-
- for (const key of keysA) {
- if (
- !Object.prototype.hasOwnProperty.call(b, key) ||
- !isEqual(
- (a as Record)[key],
- (b as Record)[key]
- )
- ) {
- return false;
- }
- }
-
- return true;
-}
-
/**
* Ensures a string is wrapped in parentheses. If the string already
* starts with `(` and ends with `)`, it is returned as-is.
diff --git a/packages/local-explorer-ui/src/drivers/sqlite/index.ts b/packages/local-explorer-ui/src/drivers/sqlite/index.ts
index 029a5de11b66..a2ed81ddd7c2 100644
--- a/packages/local-explorer-ui/src/drivers/sqlite/index.ts
+++ b/packages/local-explorer-ui/src/drivers/sqlite/index.ts
@@ -1,6 +1,6 @@
import { StethoscopeIcon } from "@phosphor-icons/react";
import { StudioSQLiteExplainTab } from "../../components/studio/Explain/SQLiteExplainTab";
-import { tokenizeSQL } from "../../utils/studio";
+import { tokenizeSQL } from "../../utils/studio/sql";
import { StudioDriverCommon } from "../common";
import { buildSQLiteSchemaDiffStatement } from "./generate";
import {
diff --git a/packages/local-explorer-ui/src/drivers/sqlite/parsers.ts b/packages/local-explorer-ui/src/drivers/sqlite/parsers.ts
index edc0fdf4c242..dcd3549ee020 100644
--- a/packages/local-explorer-ui/src/drivers/sqlite/parsers.ts
+++ b/packages/local-explorer-ui/src/drivers/sqlite/parsers.ts
@@ -1,4 +1,4 @@
-import { tokenizeSQL } from "../../utils/studio";
+import { tokenizeSQL } from "../../utils/studio/sql";
import type {
StudioColumnConflict,
StudioSortDirection,
diff --git a/packages/local-explorer-ui/src/styles/tailwind.css b/packages/local-explorer-ui/src/styles/tailwind.css
index 0bfd05b4f4ad..a97743b36a5b 100644
--- a/packages/local-explorer-ui/src/styles/tailwind.css
+++ b/packages/local-explorer-ui/src/styles/tailwind.css
@@ -1,3 +1,6 @@
+@source "../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}";
+
+@import "@cloudflare/kumo/styles/tailwind";
@import "tailwindcss";
@custom-variant dark (@media (prefers-color-scheme: dark));
@@ -16,6 +19,14 @@
--color-danger-hover: #dc2626;
--color-success: #16a34a;
+ /* Surface colors */
+ --color-surface: #fef7ed;
+ --color-surface-secondary: #f5f0eb;
+ --color-surface-tertiary: #ebe3db;
+ --color-muted: #8a6e5c;
+ --color-accent: #f0e6dc;
+ --color-background: #fffdfb;
+
/* Focus ring shadows */
--shadow-focus-primary: 0 0 0 3px rgba(255, 72, 1, 0.15);
--shadow-focus-danger: 0 0 0 3px rgba(251, 44, 54, 0.15);
@@ -24,6 +35,43 @@
--width-sidebar: 240px;
}
+/*
+ * Map @cloudflare/kumo component tokens to our brand theme.
+ * Kumo components reference --color-kumo-* and --text-color-kumo-* variables.
+ * Without these mappings, backgrounds and text colors may not render correctly.
+ */
+:root {
+ /* Background tokens */
+ --color-kumo-base: var(--color-background);
+ --color-kumo-control: var(--color-surface);
+ --color-kumo-elevated: var(--color-surface-secondary);
+ --color-kumo-overlay: var(--color-accent);
+ --color-kumo-recessed: var(--color-surface-tertiary);
+ --color-kumo-tint: var(--color-surface-secondary);
+
+ /* Interactive / brand tokens */
+ --color-kumo-brand: var(--color-primary);
+ --color-kumo-brand-hover: var(--color-primary-hover);
+ --color-kumo-interact: var(--color-surface-tertiary);
+ --color-kumo-danger: var(--color-danger);
+
+ /* Border / line tokens */
+ --color-kumo-fill: var(--color-border);
+ --color-kumo-line: var(--color-border);
+ --color-kumo-ring: var(--color-muted);
+
+ /* Tooltip tokens */
+ --color-kumo-tip-shadow: rgba(0, 0, 0, 0.08);
+ --color-kumo-tip-stroke: transparent;
+
+ /* Text color tokens */
+ --text-color-kumo-default: var(--color-text);
+ --text-color-kumo-subtle: var(--color-muted);
+ --text-color-kumo-inactive: var(--color-muted);
+ --text-color-kumo-danger: var(--color-danger);
+ --text-color-kumo-link: var(--color-primary);
+}
+
/* Dark mode color overrides */
@media (prefers-color-scheme: dark) {
:root {
@@ -39,8 +87,20 @@
--color-danger-hover: #ef4444;
--color-success: #22c55e;
+ /* Surface colors — dark mode */
+ --color-surface: #0f0c0a;
+ --color-surface-secondary: #231c18;
+ --color-surface-tertiary: #3d2e24;
+ --color-muted: #a08678;
+ --color-accent: #2e221a;
+ --color-background: #1a1412;
+
--shadow-focus-primary: 0 0 0 3px rgba(255, 107, 51, 0.25);
--shadow-focus-danger: 0 0 0 3px rgba(255, 77, 85, 0.25);
+
+ /* Kumo tooltip overrides for dark mode */
+ --color-kumo-tip-shadow: transparent;
+ --color-kumo-tip-stroke: var(--color-border);
}
}
diff --git a/packages/local-explorer-ui/src/types/studio.ts b/packages/local-explorer-ui/src/types/studio.ts
index 516fd6606b35..5dddd709ca16 100644
--- a/packages/local-explorer-ui/src/types/studio.ts
+++ b/packages/local-explorer-ui/src/types/studio.ts
@@ -181,7 +181,7 @@ export interface StudioResultHeader {
export type StudioResultValue = T | undefined | null;
-type StudioResultRow = Record;
+export type StudioResultRow = Record;
export interface StudioResultSet {
headers: StudioResultHeader[];
@@ -190,7 +190,7 @@ export interface StudioResultSet {
stat: StudioResultStat;
}
-interface StudioResultStat {
+export interface StudioResultStat {
/**
* Time taken to execute the SQL query on the server (excluding network latency), in milliseconds
*/
diff --git a/packages/local-explorer-ui/src/utils/is-equal.ts b/packages/local-explorer-ui/src/utils/is-equal.ts
new file mode 100644
index 000000000000..3862bc5a66ce
--- /dev/null
+++ b/packages/local-explorer-ui/src/utils/is-equal.ts
@@ -0,0 +1,51 @@
+/**
+ * Performs a recursive deep equality comparison between two values.
+ *
+ * Handles primitives, `null`, arrays, and plain objects. Values are
+ * considered equal when they share the same keys and all nested
+ * values are themselves deeply equal.
+ *
+ * @param a - The first value to compare.
+ * @param b - The second value to compare.
+ *
+ * @returns `true` if the values are deeply equal, `false` otherwise.
+ */
+export function isEqual(a: unknown, b: unknown): boolean {
+ if (a === b) {
+ return true;
+ }
+
+ if (
+ a === null ||
+ b === null ||
+ typeof a !== "object" ||
+ typeof b !== "object"
+ ) {
+ return false;
+ }
+
+ if (Array.isArray(a) !== Array.isArray(b)) {
+ return false;
+ }
+
+ const keysA = Object.keys(a);
+ const keysB = Object.keys(b);
+
+ if (keysA.length !== keysB.length) {
+ return false;
+ }
+
+ for (const key of keysA) {
+ if (
+ !Object.prototype.hasOwnProperty.call(b, key) ||
+ !isEqual(
+ (a as Record)[key],
+ (b as Record)[key]
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/packages/local-explorer-ui/src/utils/studio/commit.ts b/packages/local-explorer-ui/src/utils/studio/commit.ts
new file mode 100644
index 000000000000..9ac99b2e5eec
--- /dev/null
+++ b/packages/local-explorer-ui/src/utils/studio/commit.ts
@@ -0,0 +1,121 @@
+import type {
+ StudioTableState,
+ StudioTableStateRow,
+} from "../../components/studio/Table/State";
+import type {
+ IStudioDriver,
+ StudioTableRowMutationRequest,
+ StudioTableSchema,
+} from "../../types/studio";
+
+interface StudioExecutePlan {
+ row: StudioTableStateRow;
+ plan: StudioTableRowMutationRequest;
+}
+
+// TODO: Re-add in a later PR from `components/studio/Table/StateHelpers`
+type StudioResultHeaderMetadata = object;
+
+export async function commitStudioTableChanges({
+ data,
+ driver,
+ tableName,
+ tableSchema,
+}: {
+ data: StudioTableState;
+ driver: IStudioDriver;
+ tableName: string;
+ tableSchema: StudioTableSchema;
+}): Promise<{
+ errorMessage?: string;
+}> {
+ const plans = buildStudioMutationPlans({
+ data,
+ tableSchema,
+ });
+
+ try {
+ const result = await driver.mutateTableRows(
+ tableSchema.schemaName,
+ tableName,
+ plans.map((p) => p.plan),
+ tableSchema
+ );
+
+ data.applyChanges(
+ plans.map((p, idx) => ({
+ row: p.row,
+ updated: result[idx]?.record ?? {},
+ }))
+ );
+ } catch (e) {
+ return {
+ errorMessage: e instanceof Error ? e.message : String(e),
+ };
+ }
+
+ return {};
+}
+
+export function buildStudioMutationPlans({
+ data,
+ tableSchema,
+}: {
+ data: StudioTableState;
+ tableSchema: StudioTableSchema;
+}): StudioExecutePlan[] {
+ const rowChangeList = data.getChangedRows();
+
+ const plans = new Array();
+ for (const row of rowChangeList) {
+ const rowChange = row.change;
+ if (rowChange) {
+ const pk = tableSchema.pk;
+
+ const wherePrimaryKey = pk.reduce>(
+ (condition, pkColumnName) => {
+ condition[pkColumnName] = row.raw[pkColumnName];
+ return condition;
+ },
+ {}
+ );
+
+ if (row.isNewRow) {
+ plans.push({
+ plan: {
+ autoIncrementPkColumn: tableSchema.autoIncrement
+ ? tableSchema.pk[0]
+ : undefined,
+ operation: "INSERT",
+ pk: tableSchema.pk,
+ values: rowChange,
+ },
+ row,
+ });
+ continue;
+ }
+
+ if (row.isRemoved) {
+ plans.push({
+ plan: {
+ operation: "DELETE",
+ where: wherePrimaryKey,
+ },
+ row,
+ });
+ continue;
+ }
+
+ plans.push({
+ plan: {
+ operation: "UPDATE",
+ where: wherePrimaryKey,
+ values: rowChange,
+ },
+ row,
+ });
+ }
+ }
+
+ return plans;
+}
diff --git a/packages/local-explorer-ui/src/utils/studio/index.ts b/packages/local-explorer-ui/src/utils/studio/index.ts
index 8e187b83f96f..b9c77f733c2e 100644
--- a/packages/local-explorer-ui/src/utils/studio/index.ts
+++ b/packages/local-explorer-ui/src/utils/studio/index.ts
@@ -1,9 +1,4 @@
-import type {
- StudioDialect,
- StudioResultHeader,
- StudioResultSet,
- StudioSQLToken,
-} from "../../types/studio";
+import type { StudioResultHeader, StudioResultSet } from "../../types/studio";
function escapeSqlString(str: string): string {
return `'${str.replace(/'/g, `''`)}'`;
@@ -44,130 +39,6 @@ export function escapeSqlValue(value: unknown): string {
throw new Error(value.toString() + " is unrecognized type of value");
}
-const TOKEN_WHITESPACE = /^\s+/;
-const TOKEN_IDENTIFIER =
- /^(`([^`\n]|``)+`|"([^"\n]|"")+"|\[[^\]\n]+\]|[a-zA-Z_][a-zA-Z0-9_]*)/;
-const TOKEN_STRING_LITERAL = /^(?:'(?:[^'\n]|'')*'|"(?:[^"\n]|"")*")/;
-const TOKEN_NUMBER_LITERAL = /^\d+(\.\d+)?/;
-const TOKEN_PLACEHOLDER = /^:[a-zA-Z_][a-zA-Z0-9_]*/;
-
-const TOKEN_COMMENT = /^(--.*|\/\*[\s\S]*?\*\/)/; // Supporting -- and /* comments */
-
-const TOKEN_OPERATOR = /^(::|<>|!=|<=|>=|=|<|>|\+|-|\*|\/)/;
-const TOKEN_PUNCTUATION = /^[`,;().]/;
-
-/**
- * Definitions for each SQL token type, with matching logic.
- * Each rule tries to match from the start of the remaining SQL string.
- * IMPORTANT: Order matters and the first matching token will be used
- */
-const TOKEN_DEFINITIONS = [
- {
- matchToken: (input) => TOKEN_WHITESPACE.exec(input)?.[0] ?? null,
- type: "WHITESPACE",
- },
- {
- matchToken: (input) => TOKEN_IDENTIFIER.exec(input)?.[0] ?? null,
- type: "IDENTIFIER",
- },
- {
- matchToken: (input) => TOKEN_STRING_LITERAL.exec(input)?.[0] ?? null,
- type: "STRING",
- },
-
- {
- matchToken: (input) => TOKEN_NUMBER_LITERAL.exec(input)?.[0] ?? null,
- type: "NUMBER",
- },
- {
- matchToken: (input) => TOKEN_PLACEHOLDER.exec(input)?.[0] ?? null,
- type: "PLACEHOLDER",
- },
- {
- matchToken: (input) => TOKEN_COMMENT.exec(input)?.[0] ?? null,
- type: "COMMENT",
- },
- {
- matchToken: (input) => TOKEN_OPERATOR.exec(input)?.[0] ?? null,
- type: "OPERATOR",
- },
- {
- matchToken: (input) => TOKEN_PUNCTUATION.exec(input)?.[0] ?? null,
- type: "PUNCTUATION",
- },
-] satisfies {
- matchToken: (input: string, dialect?: StudioDialect) => string | null;
- type: StudioSQLToken["type"];
-}[];
-
-/**
- * Tokenizes a SQL statement into an array of tokens.
- *
- * This function breaks down a raw SQL string into meaningful parts such as
- * keywords, identifiers, strings, operators, symbols, and comments.
- * It does not perform full SQL parsing, but provides enough structure
- * for simple analysis, syntax highlighting, or building a custom parser.
- *
- * @param sql - The SQL statement to tokenize
- * @param dialect - The studio dialect type
- *
- * @returns A list of SQL tokens
- */
-export function tokenizeSQL(
- sql: string,
- _dialect: StudioDialect
-): StudioSQLToken[] {
- try {
- const tokens = new Array();
- const length = sql.length;
-
- let cursor = 0;
- let accumulateUnknown = "";
- while (cursor < length) {
- let matched = false;
-
- // This creates a new substring on each loop iteration.
- // Performance could be improved by passing the original string with an offset,
- // but JavaScript RegExp does not support matching from a specific index.
- // For now, this approach is simple and fast enough for our use case.
- const remainingSQL = sql.substring(cursor);
-
- for (const { type, matchToken } of TOKEN_DEFINITIONS) {
- const match = matchToken(remainingSQL);
- if (match) {
- if (accumulateUnknown !== "") {
- tokens.push({ type: "UNKNOWN", value: accumulateUnknown });
- accumulateUnknown = "";
- }
-
- tokens.push({ type, value: match });
- cursor += match.length;
- matched = true;
- break;
- }
- }
-
- if (!matched) {
- accumulateUnknown += remainingSQL[0];
- cursor++;
- }
- }
-
- if (accumulateUnknown !== "") {
- tokens.push({ type: "UNKNOWN", value: accumulateUnknown });
- }
-
- return tokens;
- } catch {
- return [
- {
- type: "SQL",
- value: sql,
- },
- ];
- }
-}
-
interface ArrayBasedTransformProps {
headers: HeaderType[];
rows: unknown[][];
diff --git a/packages/local-explorer-ui/src/utils/studio/sql.ts b/packages/local-explorer-ui/src/utils/studio/sql.ts
new file mode 100644
index 000000000000..0dc012c5d3e3
--- /dev/null
+++ b/packages/local-explorer-ui/src/utils/studio/sql.ts
@@ -0,0 +1,126 @@
+import type { StudioDialect, StudioSQLToken } from "../../types/studio";
+
+const TOKEN_WHITESPACE = /^\s+/;
+const TOKEN_IDENTIFIER =
+ /^(`([^`\n]|``)+`|"([^"\n]|"")+"|\[[^\]\n]+\]|[a-zA-Z_][a-zA-Z0-9_]*)/;
+const TOKEN_STRING_LITERAL = /^(?:'(?:[^'\n]|'')*'|"(?:[^"\n]|"")*")/;
+const TOKEN_NUMBER_LITERAL = /^\d+(\.\d+)?/;
+const TOKEN_PLACEHOLDER = /^:[a-zA-Z_][a-zA-Z0-9_]*/;
+
+const TOKEN_COMMENT = /^(--.*|\/\*[\s\S]*?\*\/)/; // Supporting -- and /* comments */
+
+const TOKEN_OPERATOR = /^(::|<>|!=|<=|>=|=|<|>|\+|-|\*|\/)/;
+const TOKEN_PUNCTUATION = /^[`,;().]/;
+
+/**
+ * Definitions for each SQL token type, with matching logic.
+ * Each rule tries to match from the start of the remaining SQL string.
+ * IMPORTANT: Order matters and the first matching token will be used
+ */
+const TOKEN_DEFINITIONS = [
+ {
+ matchToken: (input) => TOKEN_WHITESPACE.exec(input)?.[0] ?? null,
+ type: "WHITESPACE",
+ },
+ {
+ matchToken: (input) => TOKEN_IDENTIFIER.exec(input)?.[0] ?? null,
+ type: "IDENTIFIER",
+ },
+ {
+ matchToken: (input) => TOKEN_STRING_LITERAL.exec(input)?.[0] ?? null,
+ type: "STRING",
+ },
+
+ {
+ matchToken: (input) => TOKEN_NUMBER_LITERAL.exec(input)?.[0] ?? null,
+ type: "NUMBER",
+ },
+ {
+ matchToken: (input) => TOKEN_PLACEHOLDER.exec(input)?.[0] ?? null,
+ type: "PLACEHOLDER",
+ },
+ {
+ matchToken: (input) => TOKEN_COMMENT.exec(input)?.[0] ?? null,
+ type: "COMMENT",
+ },
+ {
+ matchToken: (input) => TOKEN_OPERATOR.exec(input)?.[0] ?? null,
+ type: "OPERATOR",
+ },
+ {
+ matchToken: (input) => TOKEN_PUNCTUATION.exec(input)?.[0] ?? null,
+ type: "PUNCTUATION",
+ },
+] satisfies {
+ matchToken: (input: string, dialect?: StudioDialect) => string | null;
+ type: StudioSQLToken["type"];
+}[];
+
+/**
+ * Tokenizes a SQL statement into an array of tokens.
+ *
+ * This function breaks down a raw SQL string into meaningful parts such as
+ * keywords, identifiers, strings, operators, symbols, and comments.
+ * It does not perform full SQL parsing, but provides enough structure
+ * for simple analysis, syntax highlighting, or building a custom parser.
+ *
+ * @param sql - The SQL statement to tokenize
+ * @param dialect - The studio dialect type
+ *
+ * @returns A list of SQL tokens
+ */
+
+export function tokenizeSQL(
+ sql: string,
+ _dialect: StudioDialect
+): StudioSQLToken[] {
+ try {
+ const tokens = new Array();
+ const length = sql.length;
+
+ let cursor = 0;
+ let accumulateUnknown = "";
+ while (cursor < length) {
+ let matched = false;
+
+ // This creates a new substring on each loop iteration.
+ // Performance could be improved by passing the original string with an offset,
+ // but JavaScript RegExp does not support matching from a specific index.
+ // For now, this approach is simple and fast enough for our use case.
+ const remainingSQL = sql.substring(cursor);
+
+ for (const { type, matchToken } of TOKEN_DEFINITIONS) {
+ const match = matchToken(remainingSQL);
+ if (match) {
+ if (accumulateUnknown !== "") {
+ tokens.push({ type: "UNKNOWN", value: accumulateUnknown });
+ accumulateUnknown = "";
+ }
+
+ tokens.push({ type, value: match });
+ cursor += match.length;
+ matched = true;
+ break;
+ }
+ }
+
+ if (!matched) {
+ accumulateUnknown += remainingSQL[0];
+ cursor++;
+ }
+ }
+
+ if (accumulateUnknown !== "") {
+ tokens.push({ type: "UNKNOWN", value: accumulateUnknown });
+ }
+
+ return tokens;
+ } catch {
+ return [
+ {
+ type: "SQL",
+ value: sql,
+ },
+ ];
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d9a4a1626a6a..0051b104ca4d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2105,6 +2105,24 @@ importers:
'@cloudflare/kumo':
specifier: ^1.5.0
version: 1.5.0(@phosphor-icons/react@2.1.10(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@types/react@19.2.10)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ '@cloudflare/workers-editor-shared':
+ specifier: ^0.1.1
+ version: 0.1.1(@cloudflare/style-const@5.7.3(react@19.2.1))(@cloudflare/style-container@7.12.2(@cloudflare/style-const@5.7.3(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ '@codemirror/autocomplete':
+ specifier: ^6.20.0
+ version: 6.20.0
+ '@codemirror/lang-sql':
+ specifier: ^6.10.0
+ version: 6.10.0
+ '@codemirror/language':
+ specifier: ^6.12.1
+ version: 6.12.1
+ '@codemirror/state':
+ specifier: ^6.5.4
+ version: 6.5.4
+ '@codemirror/view':
+ specifier: ^6.39.14
+ version: 6.39.14
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -5305,9 +5323,32 @@ packages:
cpu: [x64]
os: [win32]
+ '@cloudflare/workers-editor-shared@0.1.1':
+ resolution: {integrity: sha512-tk7dZ3rj61o8oPL2JIfJbjAldBOJZNHHIWlkfY5X5ftw7QhAIccACz4zW7LFcMyw6HHexmHhIPlcdENqqnp28w==}
+ peerDependencies:
+ '@cloudflare/style-const': ^5.7.2
+ '@cloudflare/style-container': ^7.12.1
+ react: ^17.0.2 || ^18.2.21
+ react-dom: ^17.0.2 || ^18.2.21
+
'@cloudflare/workers-types@4.20260219.0':
resolution: {integrity: sha512-jL2BNnDqbKXDrxhtKx+wVmQpv/P6w8J4WVFiuT9OMEPsw8V2TfTozoWTcCZ2AhE09yK406xQFE4mBq9IIgobuw==}
+ '@codemirror/autocomplete@6.20.0':
+ resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
+
+ '@codemirror/lang-sql@6.10.0':
+ resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
+
+ '@codemirror/language@6.12.1':
+ resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
+
+ '@codemirror/state@6.5.4':
+ resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
+
+ '@codemirror/view@6.39.14':
+ resolution: {integrity: sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==}
+
'@colors/colors@1.5.0':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
@@ -6876,6 +6917,15 @@ packages:
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
+ '@lezer/common@1.5.1':
+ resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
+
+ '@lezer/highlight@1.2.3':
+ resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
+
+ '@lezer/lr@1.4.8':
+ resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
+
'@manypkg/cli@0.23.0':
resolution: {integrity: sha512-9N0GuhUZhrDbOS2rer1/ZWaO8RvPOUI+kKTwlq74iQXomL+725E9Vfvl9U64FYwnLkQCxCmPZ9nBs/u8JwFnSw==}
engines: {node: '>=18.0.0'}
@@ -6899,6 +6949,9 @@ packages:
resolution: {integrity: sha512-SkAyKAByB9l93Slyg8AUHGuM2kjvWioUTCckT/03J09jYnfEzMO/wSXmEhnKGYs6qx9De8TH4yJCl0Y9lRgnyQ==}
engines: {node: '>=14.18.0'}
+ '@marijn/find-cluster-break@1.0.2':
+ resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
+
'@microlabs/otel-cf-workers@1.0.0-rc.45':
resolution: {integrity: sha512-PDHHTlgH1rwDGKe77DoNYjMvUlqE4uu2536/s2CqVBZFjSeKV5WK3/aeg6J6bd6h7ENJ6tndDprreiBHlimUsw==}
peerDependencies:
@@ -9748,6 +9801,9 @@ packages:
typescript:
optional: true
+ crelt@1.0.6:
+ resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+
cross-env@7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
@@ -13866,6 +13922,9 @@ packages:
strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
+ style-mod@4.1.3:
+ resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+
styled-system@5.1.5:
resolution: {integrity: sha512-7VoD0o2R3RKzOzPK0jYrVnS8iJdfkKsQJNiLRDjikOpQVqQHns/DXWaPZOH4tIKkhAT7I6wIsy9FWTWh2X3q+A==}
@@ -14740,6 +14799,9 @@ packages:
peerDependencies:
typescript: '>=5.0.0'
+ w3c-keyname@2.2.8:
+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
@@ -16238,6 +16300,12 @@ snapshots:
polished: 4.2.2
react: 18.3.1
+ '@cloudflare/style-const@5.7.3(react@19.2.1)':
+ dependencies:
+ '@cloudflare/types': 6.23.6(react@19.2.1)
+ polished: 4.2.2
+ react: 19.2.1
+
'@cloudflare/style-container@7.12.2(@cloudflare/style-const@5.7.3(react@18.3.1))(react@18.3.1)':
dependencies:
'@cloudflare/style-const': 5.7.3(react@18.3.1)
@@ -16250,6 +16318,18 @@ snapshots:
react-display-name: 0.2.5
react-fela: 11.7.0(fela@11.7.0)(react@18.3.1)
+ '@cloudflare/style-container@7.12.2(@cloudflare/style-const@5.7.3(react@19.2.1))(react@19.2.1)':
+ dependencies:
+ '@cloudflare/style-const': 5.7.3(react@19.2.1)
+ '@cloudflare/types': 6.23.6(react@19.2.1)
+ fela: 11.7.0
+ fela-tools: 11.7.0(fela@11.7.0)
+ lodash: 4.17.21
+ prop-types: 15.8.1
+ react: 19.2.1
+ react-display-name: 0.2.5
+ react-fela: 11.7.0(fela@11.7.0)(react@19.2.1)
+
'@cloudflare/style-provider@3.1.1(@cloudflare/style-const@5.7.3(react@18.3.1))(@cloudflare/style-container@7.12.2(@cloudflare/style-const@5.7.3(react@18.3.1))(react@18.3.1))(react@18.3.1)':
dependencies:
'@cloudflare/style-const': 5.7.3(react@18.3.1)
@@ -16281,6 +16361,12 @@ snapshots:
'@cloudflare/util-en-garde': 8.0.10
react: 18.3.1
+ '@cloudflare/types@6.23.6(react@19.2.1)':
+ dependencies:
+ '@cloudflare/intl-types': 1.5.1(react@19.2.1)
+ '@cloudflare/util-en-garde': 8.0.10
+ react: 19.2.1
+
'@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0)':
dependencies:
unenv: 2.0.0-rc.24
@@ -16381,8 +16467,52 @@ snapshots:
'@cloudflare/workerd-windows-64@1.20260219.0':
optional: true
+ '@cloudflare/workers-editor-shared@0.1.1(@cloudflare/style-const@5.7.3(react@19.2.1))(@cloudflare/style-container@7.12.2(@cloudflare/style-const@5.7.3(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
+ dependencies:
+ '@cloudflare/style-const': 5.7.3(react@19.2.1)
+ '@cloudflare/style-container': 7.12.2(@cloudflare/style-const@5.7.3(react@19.2.1))(react@19.2.1)
+ react: 19.2.1
+ react-dom: 19.2.1(react@19.2.1)
+ react-split-pane: 0.1.92(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+
'@cloudflare/workers-types@4.20260219.0': {}
+ '@codemirror/autocomplete@6.20.0':
+ dependencies:
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.14
+ '@lezer/common': 1.5.1
+
+ '@codemirror/lang-sql@6.10.0':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.12.1
+ '@codemirror/state': 6.5.4
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+
+ '@codemirror/language@6.12.1':
+ dependencies:
+ '@codemirror/state': 6.5.4
+ '@codemirror/view': 6.39.14
+ '@lezer/common': 1.5.1
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.8
+ style-mod: 4.1.3
+
+ '@codemirror/state@6.5.4':
+ dependencies:
+ '@marijn/find-cluster-break': 1.0.2
+
+ '@codemirror/view@6.39.14':
+ dependencies:
+ '@codemirror/state': 6.5.4
+ crelt: 1.0.6
+ style-mod: 4.1.3
+ w3c-keyname: 2.2.8
+
'@colors/colors@1.5.0':
optional: true
@@ -17434,6 +17564,16 @@ snapshots:
'@jsdevtools/ono@7.1.3': {}
+ '@lezer/common@1.5.1': {}
+
+ '@lezer/highlight@1.2.3':
+ dependencies:
+ '@lezer/common': 1.5.1
+
+ '@lezer/lr@1.4.8':
+ dependencies:
+ '@lezer/common': 1.5.1
+
'@manypkg/cli@0.23.0':
dependencies:
'@manypkg/get-packages': 2.2.1
@@ -17482,6 +17622,8 @@ snapshots:
jju: 1.4.0
read-yaml-file: 1.1.0
+ '@marijn/find-cluster-break@1.0.2': {}
+
'@microlabs/otel-cf-workers@1.0.0-rc.45(@opentelemetry/api@1.7.0)':
dependencies:
'@opentelemetry/api': 1.7.0
@@ -20747,6 +20889,8 @@ snapshots:
optionalDependencies:
typescript: 5.8.3
+ crelt@1.0.6: {}
+
cross-env@7.0.3:
dependencies:
cross-spawn: 7.0.6
@@ -24503,6 +24647,14 @@ snapshots:
prop-types: 15.8.1
react: 18.3.1
+ react-fela@11.7.0(fela@11.7.0)(react@19.2.1):
+ dependencies:
+ fela: 11.7.0
+ fela-bindings: 11.7.0(fela@11.7.0)
+ fela-dom: 11.7.0
+ prop-types: 15.8.1
+ react: 19.2.1
+
react-is@16.13.1: {}
react-is@17.0.2: {}
@@ -24531,6 +24683,14 @@ snapshots:
react-lifecycles-compat: 3.0.4
react-style-proptype: 3.2.2
+ react-split-pane@0.1.92(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
+ dependencies:
+ prop-types: 15.8.1
+ react: 19.2.1
+ react-dom: 19.2.1(react@19.2.1)
+ react-lifecycles-compat: 3.0.4
+ react-style-proptype: 3.2.2
+
react-style-proptype@3.2.2:
dependencies:
prop-types: 15.8.1
@@ -25454,6 +25614,8 @@ snapshots:
strnum@1.0.5: {}
+ style-mod@4.1.3: {}
+
styled-system@5.1.5:
dependencies:
'@styled-system/background': 5.1.2
@@ -26636,6 +26798,8 @@ snapshots:
semver: 7.7.3
typescript: 5.8.3
+ w3c-keyname@2.2.8: {}
+
warning@4.0.3:
dependencies:
loose-envify: 1.4.0