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(); + } + }} + > + + {/* @ts-expect-error `@cloudflare/kumo` currently has a type def bug here */} + Review and Confirm Changes + +
+ {!!errorMessage && ( +
{errorMessage}
+ )} + +
+ The following SQL statements will be executed to apply your changes. + Please review them carefully before committing. +
+ + +
+ +
+ +
+
+
+ ); +} 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} + > + + {/* @ts-expect-error `@cloudflare/kumo` currently has a type def bug here */} + {title} + +
+
+ {body} + {challenge && ( +
+ + Type {challenge} to confirm + + setChallengeInput(e.target.value)} + autoComplete="off" + autoFocus + /> +
+ )} + {deleteFailed && ( +
+ {failureText} +
+ )} +
+
+ + +
+
+
+
+ ); +}; 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 && } +
+ +
+ + + + +
+ setPageLimitInput(e.currentTarget.value)} + value={pageLimitInput} + /> + setPageOffsetInput(e.currentTarget.value)} + value={pageOffsetInput} + /> +
+ + + +
+
+
+
+ ); +} 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