From 4447f2924662011f2d19a298a4a5bb23cc7b8c53 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 11:31:45 +0000 Subject: [PATCH 01/26] Added `@cloudflare/workers-editor-shared` dependency --- packages/local-explorer-ui/package.json | 1 + pnpm-lock.yaml | 63 ++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/local-explorer-ui/package.json b/packages/local-explorer-ui/package.json index 898778cdef33..25d874ecf621 100644 --- a/packages/local-explorer-ui/package.json +++ b/packages/local-explorer-ui/package.json @@ -20,6 +20,7 @@ "dependencies": { "@base-ui/react": "^1.1.0", "@cloudflare/kumo": "^1.5.0", + "@cloudflare/workers-editor-shared": "^0.1.1", "@phosphor-icons/react": "^2.1.10", "@tailwindcss/vite": "^4.0.15", "@tanstack/react-router": "^1.158.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3be88f7621d..b2dd4eedc187 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2105,6 +2105,9 @@ 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) '@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,6 +5308,14 @@ 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.20260218.0': resolution: {integrity: sha512-E28uJNJb9J9pca3RaxjXm1JxAjp8td9/cudkY+IT8rio71NlshN7NKMe2Cr/6GN+RufbSnp+N3ZKP74xgUaL0A==} @@ -10878,12 +10889,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -16246,6 +16257,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) @@ -16258,6 +16275,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) @@ -16289,6 +16318,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 @@ -16389,6 +16424,14 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260218.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.20260218.0': {} '@colors/colors@1.5.0': @@ -24520,6 +24563,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: {} @@ -24548,6 +24599,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 From 9cc8967192ae04a7c937c52e5849a7f3f8b0f61d Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 12:26:52 +0000 Subject: [PATCH 02/26] Minor `tokenizeSQL` refactoring --- .../src/__tests__/utils/studio.test.ts | 2 +- .../src/drivers/sqlite/index.ts | 2 +- .../src/drivers/sqlite/parsers.ts | 2 +- .../src/utils/studio/index.ts | 124 ----------------- .../local-explorer-ui/src/utils/studio/sql.ts | 126 ++++++++++++++++++ 5 files changed, 129 insertions(+), 127 deletions(-) create mode 100644 packages/local-explorer-ui/src/utils/studio/sql.ts 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/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/utils/studio/index.ts b/packages/local-explorer-ui/src/utils/studio/index.ts index 8e187b83f96f..acb7b4b49896 100644 --- a/packages/local-explorer-ui/src/utils/studio/index.ts +++ b/packages/local-explorer-ui/src/utils/studio/index.ts @@ -44,130 +44,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, + }, + ]; + } +} From 887aca1a82446b5a53b82daa64755ecca0cd8d48 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 12:27:45 +0000 Subject: [PATCH 03/26] Added all codemirror & lezer dependencies --- packages/local-explorer-ui/package.json | 8 ++ pnpm-lock.yaml | 124 ++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/packages/local-explorer-ui/package.json b/packages/local-explorer-ui/package.json index 25d874ecf621..bde4b1ff4466 100644 --- a/packages/local-explorer-ui/package.json +++ b/packages/local-explorer-ui/package.json @@ -21,6 +21,14 @@ "@base-ui/react": "^1.1.0", "@cloudflare/kumo": "^1.5.0", "@cloudflare/workers-editor-shared": "^0.1.1", + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/commands": "^6.10.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.1", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.14", + "@lezer/common": "^1.5.1", + "@lezer/highlight": "^1.2.3", "@phosphor-icons/react": "^2.1.10", "@tailwindcss/vite": "^4.0.15", "@tanstack/react-router": "^1.158.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2dd4eedc187..a4e2366ae031 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2108,6 +2108,30 @@ importers: '@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/commands': + specifier: ^6.10.2 + version: 6.10.2 + '@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 + '@lezer/common': + specifier: ^1.5.1 + version: 1.5.1 + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -5319,6 +5343,24 @@ packages: '@cloudflare/workers-types@4.20260218.0': resolution: {integrity: sha512-E28uJNJb9J9pca3RaxjXm1JxAjp8td9/cudkY+IT8rio71NlshN7NKMe2Cr/6GN+RufbSnp+N3ZKP74xgUaL0A==} + '@codemirror/autocomplete@6.20.0': + resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} + + '@codemirror/commands@6.10.2': + resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} + + '@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'} @@ -6887,6 +6929,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'} @@ -6910,6 +6961,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: @@ -9759,6 +9813,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'} @@ -13885,6 +13942,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==} @@ -14759,6 +14819,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==} @@ -16434,6 +16497,49 @@ snapshots: '@cloudflare/workers-types@4.20260218.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/commands@6.10.2': + 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 @@ -17485,6 +17591,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 @@ -17533,6 +17649,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 @@ -20798,6 +20916,8 @@ snapshots: optionalDependencies: typescript: 5.8.3 + crelt@1.0.6: {} + cross-env@7.0.3: dependencies: cross-spawn: 7.0.6 @@ -25530,6 +25650,8 @@ snapshots: strnum@1.0.5: {} + style-mod@4.1.3: {} + styled-system@5.1.5: dependencies: '@styled-system/background': 5.1.2 @@ -26712,6 +26834,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 From f29806fcdc492ebc3ac0986951a013a106b20572 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 13:33:43 +0000 Subject: [PATCH 04/26] Moved `isEqual` to shared utilities --- .../src/drivers/sqlite/generate.ts | 53 +------------------ .../local-explorer-ui/src/utils/is-equal.ts | 51 ++++++++++++++++++ 2 files changed, 52 insertions(+), 52 deletions(-) create mode 100644 packages/local-explorer-ui/src/utils/is-equal.ts 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/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; +} From f8c0dcdbe59ea7a7b3111baa4d15d5687cb7092e Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 14:11:13 +0000 Subject: [PATCH 05/26] Added shared studio commit utilities --- .../src/utils/studio/commit.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 packages/local-explorer-ui/src/utils/studio/commit.ts 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..5082ac3747c9 --- /dev/null +++ b/packages/local-explorer-ui/src/utils/studio/commit.ts @@ -0,0 +1,119 @@ +import type { + StudioTableState, + StudioTableStateRow, +} from "../../components/studio/Table/State"; +import type { StudioResultHeaderMetadata } from "../../components/studio/Table/StateHelpers"; +import type { + IStudioDriver, + StudioTableRowMutationRequest, + StudioTableSchema, +} from "../../types/studio"; + +interface StudioExecutePlan { + row: StudioTableStateRow; + plan: StudioTableRowMutationRequest; +} + +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; +} From c558b9d89039cabdc5827b526e0ea03bc025a1f1 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 16:22:02 +0000 Subject: [PATCH 06/26] Added WIP table explorer tab components --- .../src/components/studio/Code/Block.tsx | 21 + .../src/components/studio/Code/Mirror.tsx | 180 ++++ .../studio/Modal/CommitConfirmation.tsx | 88 ++ .../studio/Modal/DeleteConfirmation.tsx | 104 +++ .../components/studio/Query/ResultStats.tsx | 85 ++ .../studio/SQLEditor/SQLThemePlugin.tsx | 104 +++ .../src/components/studio/TabRegister.tsx | 27 +- .../studio/Table/Result/EditableCell.tsx | 1 + .../components/studio/Table/State/Helpers.tsx | 358 ++++++++ .../components/studio/Table/State/index.tsx | 867 ++++++++++++++++++ .../components/studio/Tabs/TableExplorer.tsx | 510 +++++++++++ .../src/components/studio/Where/Editor.tsx | 84 ++ .../components/studio/Where/FilterInput.tsx | 162 ++++ .../local-explorer-ui/src/types/studio.ts | 4 +- .../src/utils/studio/where-parser.ts | 384 ++++++++ 15 files changed, 2967 insertions(+), 12 deletions(-) create mode 100644 packages/local-explorer-ui/src/components/studio/Code/Block.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Code/Mirror.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Modal/DeleteConfirmation.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/SQLEditor/SQLThemePlugin.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Table/Result/EditableCell.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Table/State/Helpers.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Table/State/index.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Where/Editor.tsx create mode 100644 packages/local-explorer-ui/src/components/studio/Where/FilterInput.tsx create mode 100644 packages/local-explorer-ui/src/utils/studio/where-parser.ts 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..d9b23cf298ea --- /dev/null +++ b/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx @@ -0,0 +1,88 @@ +import { Button, Dialog } from "@cloudflare/kumo"; +import { PlayIcon, SpinnerIcon } from "@phosphor-icons/react"; +import { useCallback, useState } from "react"; +import { CodeBlock } from "../Code/Block"; + +interface Props { + closeModal: () => void; + isOpen: boolean; + onConfirm: () => Promise; + statements: string[]; +} + +export function StudioCommitConfirmation(props: Props) { + const [errorMessage, setErrorMessage] = useState(""); + const [isRequesting, setIsRequesting] = useState(false); + + const onConfirm = useCallback(async () => { + setIsRequesting(true); + try { + await props.onConfirm(); + props.closeModal(); + } finally { + setIsRequesting(false); + } + }, [props]); + + return ( + { + if (!open) { + props.closeModal(); + } + }} + > + + 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..14d008756ef1 --- /dev/null +++ b/packages/local-explorer-ui/src/components/studio/Modal/DeleteConfirmation.tsx @@ -0,0 +1,104 @@ +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} + > + + {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..43b2fb6d6128 --- /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 default function StudioQueryResultStats({ + stats, +}: StudioQueryResultStatsProps): JSX.Element { + const statsComponents = useMemo(() => { + const content: ReactElement[] = []; + + 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..02cbba21639a 100644 --- a/packages/local-explorer-ui/src/components/studio/TabRegister.tsx +++ b/packages/local-explorer-ui/src/components/studio/TabRegister.tsx @@ -1,16 +1,23 @@ +import { 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 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 RegisteredTabDefinition = [TableTab]; 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..56b139334f3c --- /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; + +export 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..1f2b0dbd18af --- /dev/null +++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx @@ -0,0 +1,510 @@ +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 { StudioResultTable } from "../Table/Result"; +import { createStudioTableStateFromResult } from "../Table/State/Helpers"; +import { StudioWhereFilterInput } from "../Where/FilterInput"; +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(); + + 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]); + + 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]); + + 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]); + + const onOrderByColumnChange = useCallback( + (columName: string, direction: StudioSortDirection) => { + guardUnsavedChanges(() => { + setOrderBy({ columName, direction }); + }); + }, + [guardUnsavedChanges] + ); + + return ( +
+
+ + + {!readOnlyMode && ( + <> + + + + )} + +
+ +
+ + {changeNumber > 0 && ( + <> + + + + )} +
+
+ {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/components/studio/Where/Editor.tsx b/packages/local-explorer-ui/src/components/studio/Where/Editor.tsx new file mode 100644 index 000000000000..ec396614f8cc --- /dev/null +++ b/packages/local-explorer-ui/src/components/studio/Where/Editor.tsx @@ -0,0 +1,84 @@ +import { autocompletion } from "@codemirror/autocomplete"; +import { SQLDialect } from "@codemirror/lang-sql"; +import { syntaxHighlighting } from "@codemirror/language"; +import { keymap } from "@codemirror/view"; +import { classHighlighter } from "@lezer/highlight"; +import { forwardRef, useMemo } from "react"; +import { StudioCodeMirror } from "../Code/Mirror"; +import { + StudioSQLBaseTheme, + StudioSQLTheme, +} from "../SQLEditor/SQLThemePlugin"; +import type { + StudioCodeMirrorProps, + StudioCodeMirrorReference, +} from "../Code/Mirror"; +import type { Extension } from "@codemirror/state"; + +interface SutdioSQLWhereEditor + extends Omit { + columnNames?: string[]; + functionNames?: string[]; + onEnterPressed?: () => void; +} + +export const StudioSQLWhereEditor = forwardRef< + StudioCodeMirrorReference, + SutdioSQLWhereEditor +>(function StudioSQLWhereEditor(props, ref) { + const { columnNames, functionNames, onEnterPressed } = props; + + const whereEditorExtensions = useMemo((): Extension[] => { + const extensions = [ + keymap.of([ + { + key: "Enter", + run: () => { + onEnterPressed?.(); + return true; + }, + }, + ]), + SQLDialect.define({ + keywords: ( + "and or like between " + + (functionNames ?? []).map((fn) => fn.toLocaleLowerCase()).join(" ") + ).trim(), + }), + + // This is for syntax highlight + syntaxHighlighting(classHighlighter), + StudioSQLBaseTheme, + StudioSQLTheme, + ] satisfies Extension[]; + + if (columnNames && columnNames.length > 0) { + extensions.push( + autocompletion({ + override: [ + (context) => { + const word = context.matchBefore(/\w*/); + if (!word || (word.from === word.to && !context.explicit)) { + return null; + } + + return { + from: word.from, + options: columnNames.map((keyword) => ({ + label: keyword, + type: "property", + })), + }; + }, + ], + }) + ); + } + + return extensions; + }, [columnNames, functionNames, onEnterPressed]); + + return ( + + ); +}); diff --git a/packages/local-explorer-ui/src/components/studio/Where/FilterInput.tsx b/packages/local-explorer-ui/src/components/studio/Where/FilterInput.tsx new file mode 100644 index 000000000000..81dbf419fb47 --- /dev/null +++ b/packages/local-explorer-ui/src/components/studio/Where/FilterInput.tsx @@ -0,0 +1,162 @@ +import { Button, Tooltip } from "@cloudflare/kumo"; +import { SpinnerIcon } from "@phosphor-icons/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { tokenizeSQL } from "../../../utils/studio/sql"; +import { StudioWhereParser } from "../../../utils/studio/where-parser"; +import { StudioSQLWhereEditor } from "./Editor"; +import type { IStudioDriver } from "../../../types/studio"; +import type { StudioCodeMirrorReference } from "../Code/Mirror"; + +const SQLiteScalarFunctions = [ + "abs", + "hex", + "length", + "lower", + "ltrim", + "max", + "min", + "random", + "rtrim", + "sign", + "soundex", + "substr", + "unicode", + "upper", +] satisfies string[]; + +interface StudioWhereFilterInputProps { + columnNameList: string[]; + driver: IStudioDriver; + loading?: boolean; + onApply: (whereRaw: string) => void; + value: string; +} + +export function StudioWhereFilterInput({ + columnNameList, + loading, + onApply, + value, +}: StudioWhereFilterInputProps): JSX.Element { + const editorRef = useRef(null); + + const [currentValue, setCurrentValue] = useState(""); + const [parsingError, setParsingError] = useState(""); + + const availableFunctionList = useMemo( + () => SQLiteScalarFunctions, + [] + ); + + useEffect(() => { + if (currentValue.trim() === "") { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronous state reset before setting up debounce timer is intentional cleanup logic + setParsingError(""); + return; + } + + const timeoutId = setTimeout(() => { + try { + // Try to parse if it is valid where clause + new StudioWhereParser({ + functionNames: availableFunctionList, + identifiers: columnNameList, + tokens: tokenizeSQL(currentValue, "sqlite"), + }).parse(); + + setParsingError(""); + } catch (err) { + setParsingError(String(err)); + } + }, 1_000); + + return () => clearTimeout(timeoutId); + }, [currentValue, columnNameList, availableFunctionList]); + + const onExternalApply = useCallback((): void => { + if (editorRef.current) { + // Parse again before apply + try { + const appliedValue = editorRef.current.getValue(); + + if (appliedValue === "") { + return onApply(""); + } + + new StudioWhereParser({ + functionNames: availableFunctionList, + identifiers: columnNameList, + tokens: tokenizeSQL(appliedValue, "sqlite"), + }).parse(); + + setParsingError(""); + onApply(appliedValue); + } catch (err) { + alert(String(err)); + setParsingError(String(err)); + } + } + }, [onApply, editorRef, columnNameList, availableFunctionList]); + + const onValueChange = useCallback((): void => { + if (editorRef.current) { + setCurrentValue(editorRef.current.getValue()); + } + }, [editorRef]); + + const applyButtonContent = useMemo(() => { + if (loading) { + return ( + <> + + Applying + + ); + } + + if (parsingError) { + return ( + <> + + Apply + + ); + } + + if (currentValue === value) { + return Applied; + } + + return ( + <> + + Apply + + ); + }, [loading, currentValue, value, parsingError]); + + return ( +
+ + + + +
+ ); +} 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/studio/where-parser.ts b/packages/local-explorer-ui/src/utils/studio/where-parser.ts new file mode 100644 index 000000000000..3b4a8a1f53b0 --- /dev/null +++ b/packages/local-explorer-ui/src/utils/studio/where-parser.ts @@ -0,0 +1,384 @@ +/* +Recursive descent parser implementation for validating simplified SQL WHERE clauses. + +This parser checks for grammatical correctness and prevents injectable expressions. +It does **not** fully support the entire SQL WHERE grammar, +but covers a practical subset for typical user needs. + +⚠️ If you modify the grammar, ensure there is no left recursion. +Recursive descent parsers do not support left-recursive rules and +will enter infinite loops. + +Supported grammar: + + ::= + ::= ( "OR" )* + ::= ( "AND" )* + ::= [ "NOT" ] + ::= + | "BETWEEN" "AND" + | "LIKE" + | "IS" [ "NOT" ] "NULL" + | "(" ")" + + ::= ( ("+" | "-") )* + ::= ( ("*" | "/") )* + ::= [ "-" ] + ::= | | | "(" ")" + + ::= "(" [ ] ")" + ::= ( "," )* + + ::= "=" | "!=" | "<>" | "<" | ">" | "<=" | ">=" + ::= | + ::= [a-zA-Z_][a-zA-Z0-9_]* + ::= [0-9]+ ( "." [0-9]+ )? + ::= "'" [^']* "'" | '"' [^"]* '"' +*/ + +import type { StudioSQLToken } from "../../types/studio"; + +export class StudioWhereParser { + private functionNames: string[]; + private identifiers: string[]; + private position = 0; + private tokens: StudioSQLToken[]; + + constructor(options: { + functionNames: string[]; + identifiers: string[]; + tokens: StudioSQLToken[]; + }) { + this.tokens = options.tokens + .filter((t) => t.type !== "WHITESPACE") + .map((t) => { + if ( + t.type === "COMMENT" || + t.type === "STRING" || + t.type === "NUMBER" + ) { + return t; + } + + return { + ...t, + value: t.value.toUpperCase(), + }; + }); + + // Reserved keywords that must be escaped if used as identifiers + const reservedKeywords = [ + "GROUP", + "LIMIT", + "ORDER", + "BY", + ] satisfies string[]; + + for (const token of this.tokens) { + if (reservedKeywords.includes(token.value)) { + throw new Error( + `"${token.value}" is a reserved SQL keyword. If you're using it as a column name, please escape it with double quotes.` + ); + } + } + + this.functionNames = options.functionNames.map((fn) => fn.toUpperCase()); + + this.identifiers = options.identifiers + .map((fn): string[] => [ + fn.toUpperCase(), + this.escapeId(fn.toUpperCase()), + ]) + .flat(); + } + + public parse(): unknown { + const expr = this.parseExpression(); + if (this.position < this.tokens.length) { + throw new Error("Invalid"); + } + + return expr; + } + + // ::= + parseExpression(): unknown { + return this.parseOrExpr(); + } + + // ::= ( "OR" )* + private parseOrExpr(): unknown { + let node = this.parseAndExpr(); + while (this.match("OR")) { + const right = this.parseAndExpr(); + node = { type: "or", left: node, right }; + } + return node; + } + + // ::= ( "AND" )* + private parseAndExpr(): unknown { + let node = this.parseNotExpr(); + while (this.match("AND")) { + const right = this.parseNotExpr(); + node = { + left: node, + right, + type: "and", + }; + } + return node; + } + + // ::= [ "NOT" ] + private parseNotExpr(): unknown { + if (this.match("NOT")) { + return { + expr: this.parseComparison(), + type: "not", + }; + } + + return this.parseComparison(); + } + + /** + ::= + | "BETWEEN" "AND" + | "LIKE" + | "IS" [ "NOT" ] "NULL" + | "(" ")" + */ + private parseComparison(): unknown { + const left = this.parseArithmetic(); + + if (this.match("BETWEEN")) { + const low = this.parseArithmetic(); + this.expect("AND", "Expected 'AND' after 'BETWEEN'"); + const high = this.parseArithmetic(); + return { + expr: left, + high, + low, + type: "between", + }; + } + + if (this.match("LIKE")) { + const pattern = this.parseArithmetic(); + return { + expr: left, + pattern, + type: "like", + }; + } + + if (this.match("IS")) { + const isNot = this.match("NOT"); + const isValue = this.expect( + ["TRUE", "FALSE", "NULL"], + `Expected NULL, TRUE, or FALSE after IS${isNot ? " NOT" : ""}` + ); + + return { + expr: left, + not: isNot, + type: "is", + value: isValue, + }; + } + + if (this.peek().type === "OPERATOR") { + const op = this.consume().value; + const right = this.parseArithmetic(); + return { + left, + op, + right, + type: "binary", + }; + } + + return left; + } + + // ::= ( ("+" | "-") )* + private parseArithmetic(): unknown { + let expr = this.parseTerm(); + + while (this.match(["+", "-"])) { + const op = this.prev().value; + expr = { + type: "arith", + operator: op, + left: expr, + right: this.parseTerm(), + }; + } + + return expr; + } + + // ::= ( ("*" | "/") )* + private parseTerm(): unknown { + let expr = this.parseFactor(); + while (this.match(["*", "/"])) { + const op = this.prev().value; + expr = { + left: expr, + operator: op, + right: this.parseFactor(), + type: "arith", + }; + } + + return expr; + } + + // ::= [ "-" ] + private parseFactor(): unknown { + if (this.match("-")) { + return { + expr: this.parsePrimary(), + type: "neg", + }; + } + + return this.parsePrimary(); + } + + // ::= | | | "(" ")" + private parsePrimary(): unknown { + const token = this.peek(); + + if (this.match("(")) { + const expr = this.parseExpression(); + this.expect(")", "Expected closing ')' to match opening '('"); + return expr; + } + + if (token.type === "NUMBER" || token.type === "STRING") { + return this.consume(); + } + + if (token.type === "IDENTIFIER") { + const next = this.tokens[this.position + 1]; + + if (next && next.value === "(") { + return this.parseFunctionCall(); + } + + if (!this.identifiers.includes(token.value)) { + // Users often mistake double quotes (") for string literals. + // In SQL, string literals must use single quotes ('). + // Provide a clear error message to help them avoid this confusion. + if (token.value.startsWith('"') && token.value.endsWith('"')) { + throw new Error( + `${token.value} is not a valid column name in this table. It looks like you meant to use a string literal — wrap strings in single quotes (') instead of double quotes (").` + ); + } + + throw new Error( + `${token.value} is not a valid column name in this table.` + ); + } + + return this.consume(); + } + + throw new Error(`Unexpected token ${token.type}`); + } + + // ::= "(" [ ] ")" + private parseFunctionCall(): { + args: unknown[]; + name: string; + type: string; + } { + const name = this.expect( + this.functionNames, + "Unsupported function name" + ).value; + + this.expect("(", "Expected '(' to start function call"); + + const args: unknown[] = []; + while (this.peek().value !== ")") { + args.push(this.parseExpression()); + if (this.peek().value === ",") { + this.consume(); + } else { + break; + } + } + + this.expect(")", "Expected closing ')' to end function call"); + + return { + args, + name, + type: "call", + }; + } + + // Utility methods + private prev(): StudioSQLToken { + return this.tokens[this.position - 1] as StudioSQLToken; + } + + private escapeId(id: string): string { + return `"${id.replace(/"/g, `""`)}"`; + } + + private peek(): StudioSQLToken { + return ( + this.tokens[this.position] ?? { + type: "UNKNOWN", + value: "", + } + ); + } + + private consume(): StudioSQLToken { + return ( + this.tokens[this.position++] ?? { + type: "UNKNOWN", + value: "", + } + ); + } + + private expect( + keywords: string[] | string, + errorMessage?: string + ): StudioSQLToken { + const token = this.consume(); + + if (typeof keywords === "string" && keywords !== token.value) { + throw new Error( + errorMessage ?? + "Expecting " + keywords.toString() + " but got " + token.value + ); + } else if (Array.isArray(keywords) && !keywords.includes(token.value)) { + throw new Error(errorMessage ?? "Unable to parse"); + } + + return token; + } + + private match(keywords: string | string[]): boolean { + const token = this.peek(); + + if (typeof keywords === "string" && keywords === token.value) { + this.consume(); + return true; + } + + if (Array.isArray(keywords) && keywords.includes(token.value)) { + this.consume(); + return true; + } + + return false; + } +} From 15b27671b9547577af65d9590a4759e42ee31eff Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 16:24:04 +0000 Subject: [PATCH 07/26] Purged unused dependencies --- packages/local-explorer-ui/package.json | 3 -- pnpm-lock.yaml | 51 ++++++++++++++----------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/local-explorer-ui/package.json b/packages/local-explorer-ui/package.json index bde4b1ff4466..d216401dad20 100644 --- a/packages/local-explorer-ui/package.json +++ b/packages/local-explorer-ui/package.json @@ -22,13 +22,10 @@ "@cloudflare/kumo": "^1.5.0", "@cloudflare/workers-editor-shared": "^0.1.1", "@codemirror/autocomplete": "^6.20.0", - "@codemirror/commands": "^6.10.2", "@codemirror/lang-sql": "^6.10.0", "@codemirror/language": "^6.12.1", "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.39.14", - "@lezer/common": "^1.5.1", - "@lezer/highlight": "^1.2.3", "@phosphor-icons/react": "^2.1.10", "@tailwindcss/vite": "^4.0.15", "@tanstack/react-router": "^1.158.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4e2366ae031..0278a841d334 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2104,16 +2104,13 @@ importers: version: 1.1.0(@types/react@19.2.10)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@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) + version: 1.6.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/commands': - specifier: ^6.10.2 - version: 6.10.2 '@codemirror/lang-sql': specifier: ^6.10.0 version: 6.10.0 @@ -2126,12 +2123,6 @@ importers: '@codemirror/view': specifier: ^6.39.14 version: 6.39.14 - '@lezer/common': - specifier: ^1.5.1 - version: 1.5.1 - '@lezer/highlight': - specifier: ^1.2.3 - version: 1.2.3 '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -5161,8 +5152,8 @@ packages: peerDependencies: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 - '@cloudflare/kumo@1.5.0': - resolution: {integrity: sha512-Y2fE72C3KwniG94SYROVtMwD5wNx/IXQ3CPbZv+ayD37nEXx1He3D5qFwh5PgPGtBQCrHiAL4J2b9v2FkgEvkQ==} + '@cloudflare/kumo@1.6.0': + resolution: {integrity: sha512-1Sy8kgfHNkze+NEfu/6cNzwOb0hemGm1mUNGU9GVmAnHemLOaXixosslM/o38TbUTEEs48yTRrDy0WFGgSTFWg==} hasBin: true peerDependencies: '@phosphor-icons/react': ^2.1.10 @@ -5346,9 +5337,6 @@ packages: '@codemirror/autocomplete@6.20.0': resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} - '@codemirror/commands@6.10.2': - resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} - '@codemirror/lang-sql@6.10.0': resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} @@ -5373,6 +5361,9 @@ packages: resolution: {integrity: sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==} engines: {node: '>= 6'} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@electric-sql/pglite-socket@0.0.6': resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==} hasBin: true @@ -9932,6 +9923,9 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -13190,6 +13184,12 @@ packages: react-addons-shallow-compare@15.6.3: resolution: {integrity: sha512-EDJbgKTtGRLhr3wiGDXK/+AEJ59yqGS+tKE6mue0aNXT6ZMR7VJbbzIiT6akotmHg1BLj46ElJSb+NBMp80XBg==} + react-day-picker@9.13.2: + resolution: {integrity: sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-display-name@0.2.5: resolution: {integrity: sha512-I+vcaK9t4+kypiSgaiVWAipqHRXYmZIuAiS8vzFvXHHXVigg/sMKwlRgLy6LH2i3rmP+0Vzfl5lFsFRwF1r3pg==} @@ -16281,12 +16281,13 @@ snapshots: dependencies: react: 19.2.1 - '@cloudflare/kumo@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/kumo@1.6.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)': dependencies: '@base-ui/react': 1.1.0(@types/react@19.2.10)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@phosphor-icons/react': 2.1.10(react-dom@19.2.1(react@19.2.1))(react@19.2.1) clsx: 2.1.1 react: 19.2.1 + react-day-picker: 9.13.2(react@19.2.1) react-dom: 19.2.1(react@19.2.1) tailwind-merge: 3.4.0 transitivePeerDependencies: @@ -16504,13 +16505,6 @@ snapshots: '@codemirror/view': 6.39.14 '@lezer/common': 1.5.1 - '@codemirror/commands@6.10.2': - 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 @@ -16568,6 +16562,8 @@ snapshots: tunnel-agent: 0.6.0 uuid: 8.3.2 + '@date-fns/tz@1.4.1': {} + '@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)': dependencies: '@electric-sql/pglite': 0.3.2 @@ -21072,6 +21068,8 @@ snapshots: dataloader@1.4.0: {} + date-fns-jalali@4.1.0-0: {} + date-fns@2.30.0: dependencies: '@babel/runtime': 7.22.5 @@ -24662,6 +24660,13 @@ snapshots: dependencies: object-assign: 4.1.1 + react-day-picker@9.13.2(react@19.2.1): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.1 + react-display-name@0.2.5: {} react-dom@18.3.1(react@18.3.1): From efb03a133ecb723834fbef74318d025af05e0394 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 16:29:40 +0000 Subject: [PATCH 08/26] Added placeholder tab registry definitions --- .../src/components/studio/TabRegister.tsx | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/local-explorer-ui/src/components/studio/TabRegister.tsx b/packages/local-explorer-ui/src/components/studio/TabRegister.tsx index 02cbba21639a..55c15389e254 100644 --- a/packages/local-explorer-ui/src/components/studio/TabRegister.tsx +++ b/packages/local-explorer-ui/src/components/studio/TabRegister.tsx @@ -1,8 +1,16 @@ -import { TableIcon } from "@phosphor-icons/react"; +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 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; @@ -17,7 +25,29 @@ const TableTab: TabDefinition<{ type: "table", }; -const RegisteredTabDefinition = [TableTab]; +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; From 3ec6b9e703589976eec1bb5735a979887b097efd Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 16:30:08 +0000 Subject: [PATCH 09/26] Added changeset --- .changeset/light-clocks-enter.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/light-clocks-enter.md 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. From c2f6234490c43ccfd72201610f2188ac8e331b1b Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 16:40:09 +0000 Subject: [PATCH 10/26] Temp: Pruned where filter input + editor --- .../components/studio/Query/ResultStats.tsx | 2 +- .../components/studio/Tabs/TableExplorer.tsx | 12 +- .../src/components/studio/Where/Editor.tsx | 84 --------- .../components/studio/Where/FilterInput.tsx | 162 ------------------ 4 files changed, 7 insertions(+), 253 deletions(-) delete mode 100644 packages/local-explorer-ui/src/components/studio/Where/Editor.tsx delete mode 100644 packages/local-explorer-ui/src/components/studio/Where/FilterInput.tsx diff --git a/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx b/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx index 43b2fb6d6128..e4170d425e86 100644 --- a/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx +++ b/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx @@ -20,7 +20,7 @@ interface StudioQueryResultStatsProps { stats: StudioResultStat; } -export default function StudioQueryResultStats({ +export function StudioQueryResultStats({ stats, }: StudioQueryResultStatsProps): JSX.Element { const statsComponents = useMemo(() => { diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx index 1f2b0dbd18af..bbf9fa6395aa 100644 --- a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx +++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx @@ -14,10 +14,9 @@ import { useStudioContext } from "../Context"; import { useModal } from "../Modal"; import { StudioCommitConfirmation } from "../Modal/CommitConfirmation"; import { StudioDeleteConfirmationModal } from "../Modal/DeleteConfirmation"; -import StudioQueryResultStats from "../Query/ResultStats"; +import { StudioQueryResultStats } from "../Query/ResultStats"; import { StudioResultTable } from "../Table/Result"; import { createStudioTableStateFromResult } from "../Table/State/Helpers"; -import { StudioWhereFilterInput } from "../Where/FilterInput"; import { useStudioCurrentWindowTab } from "../WindowTab/Context"; import type { StudioResultStat, @@ -62,7 +61,7 @@ export function StudioTableExplorerTab({ const { openModal } = useModal(); - const filterAutoCompleteColumns = useMemo(() => { + const _filterAutoCompleteColumns = useMemo(() => { if (!schema) { return []; } @@ -299,7 +298,7 @@ export function StudioTableExplorerTab({ }); }, [pageOffset, pageLimit, guardUnsavedChanges]); - const onWhereRawApplied = useCallback( + const _onWhereRawApplied = useCallback( (newWhereRaw: string): void => { guardUnsavedChanges(() => { setWhereRaw(newWhereRaw); @@ -401,13 +400,14 @@ export function StudioTableExplorerTab({ )}
- + /> */}
{changeNumber > 0 && ( diff --git a/packages/local-explorer-ui/src/components/studio/Where/Editor.tsx b/packages/local-explorer-ui/src/components/studio/Where/Editor.tsx deleted file mode 100644 index ec396614f8cc..000000000000 --- a/packages/local-explorer-ui/src/components/studio/Where/Editor.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { autocompletion } from "@codemirror/autocomplete"; -import { SQLDialect } from "@codemirror/lang-sql"; -import { syntaxHighlighting } from "@codemirror/language"; -import { keymap } from "@codemirror/view"; -import { classHighlighter } from "@lezer/highlight"; -import { forwardRef, useMemo } from "react"; -import { StudioCodeMirror } from "../Code/Mirror"; -import { - StudioSQLBaseTheme, - StudioSQLTheme, -} from "../SQLEditor/SQLThemePlugin"; -import type { - StudioCodeMirrorProps, - StudioCodeMirrorReference, -} from "../Code/Mirror"; -import type { Extension } from "@codemirror/state"; - -interface SutdioSQLWhereEditor - extends Omit { - columnNames?: string[]; - functionNames?: string[]; - onEnterPressed?: () => void; -} - -export const StudioSQLWhereEditor = forwardRef< - StudioCodeMirrorReference, - SutdioSQLWhereEditor ->(function StudioSQLWhereEditor(props, ref) { - const { columnNames, functionNames, onEnterPressed } = props; - - const whereEditorExtensions = useMemo((): Extension[] => { - const extensions = [ - keymap.of([ - { - key: "Enter", - run: () => { - onEnterPressed?.(); - return true; - }, - }, - ]), - SQLDialect.define({ - keywords: ( - "and or like between " + - (functionNames ?? []).map((fn) => fn.toLocaleLowerCase()).join(" ") - ).trim(), - }), - - // This is for syntax highlight - syntaxHighlighting(classHighlighter), - StudioSQLBaseTheme, - StudioSQLTheme, - ] satisfies Extension[]; - - if (columnNames && columnNames.length > 0) { - extensions.push( - autocompletion({ - override: [ - (context) => { - const word = context.matchBefore(/\w*/); - if (!word || (word.from === word.to && !context.explicit)) { - return null; - } - - return { - from: word.from, - options: columnNames.map((keyword) => ({ - label: keyword, - type: "property", - })), - }; - }, - ], - }) - ); - } - - return extensions; - }, [columnNames, functionNames, onEnterPressed]); - - return ( - - ); -}); diff --git a/packages/local-explorer-ui/src/components/studio/Where/FilterInput.tsx b/packages/local-explorer-ui/src/components/studio/Where/FilterInput.tsx deleted file mode 100644 index 81dbf419fb47..000000000000 --- a/packages/local-explorer-ui/src/components/studio/Where/FilterInput.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { Button, Tooltip } from "@cloudflare/kumo"; -import { SpinnerIcon } from "@phosphor-icons/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { tokenizeSQL } from "../../../utils/studio/sql"; -import { StudioWhereParser } from "../../../utils/studio/where-parser"; -import { StudioSQLWhereEditor } from "./Editor"; -import type { IStudioDriver } from "../../../types/studio"; -import type { StudioCodeMirrorReference } from "../Code/Mirror"; - -const SQLiteScalarFunctions = [ - "abs", - "hex", - "length", - "lower", - "ltrim", - "max", - "min", - "random", - "rtrim", - "sign", - "soundex", - "substr", - "unicode", - "upper", -] satisfies string[]; - -interface StudioWhereFilterInputProps { - columnNameList: string[]; - driver: IStudioDriver; - loading?: boolean; - onApply: (whereRaw: string) => void; - value: string; -} - -export function StudioWhereFilterInput({ - columnNameList, - loading, - onApply, - value, -}: StudioWhereFilterInputProps): JSX.Element { - const editorRef = useRef(null); - - const [currentValue, setCurrentValue] = useState(""); - const [parsingError, setParsingError] = useState(""); - - const availableFunctionList = useMemo( - () => SQLiteScalarFunctions, - [] - ); - - useEffect(() => { - if (currentValue.trim() === "") { - // eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronous state reset before setting up debounce timer is intentional cleanup logic - setParsingError(""); - return; - } - - const timeoutId = setTimeout(() => { - try { - // Try to parse if it is valid where clause - new StudioWhereParser({ - functionNames: availableFunctionList, - identifiers: columnNameList, - tokens: tokenizeSQL(currentValue, "sqlite"), - }).parse(); - - setParsingError(""); - } catch (err) { - setParsingError(String(err)); - } - }, 1_000); - - return () => clearTimeout(timeoutId); - }, [currentValue, columnNameList, availableFunctionList]); - - const onExternalApply = useCallback((): void => { - if (editorRef.current) { - // Parse again before apply - try { - const appliedValue = editorRef.current.getValue(); - - if (appliedValue === "") { - return onApply(""); - } - - new StudioWhereParser({ - functionNames: availableFunctionList, - identifiers: columnNameList, - tokens: tokenizeSQL(appliedValue, "sqlite"), - }).parse(); - - setParsingError(""); - onApply(appliedValue); - } catch (err) { - alert(String(err)); - setParsingError(String(err)); - } - } - }, [onApply, editorRef, columnNameList, availableFunctionList]); - - const onValueChange = useCallback((): void => { - if (editorRef.current) { - setCurrentValue(editorRef.current.getValue()); - } - }, [editorRef]); - - const applyButtonContent = useMemo(() => { - if (loading) { - return ( - <> - - Applying - - ); - } - - if (parsingError) { - return ( - <> - - Apply - - ); - } - - if (currentValue === value) { - return Applied; - } - - return ( - <> - - Apply - - ); - }, [loading, currentValue, value, parsingError]); - - return ( -
- - - - -
- ); -} From 926e75fd7ef03e30d72f74a8edb52814b996ef71 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 16:41:17 +0000 Subject: [PATCH 11/26] Temp: Disabled WIP `StudioResultTable` component --- .../src/components/studio/Tabs/TableExplorer.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx index bbf9fa6395aa..f9c2b79945dd 100644 --- a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx +++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx @@ -15,7 +15,6 @@ import { useModal } from "../Modal"; import { StudioCommitConfirmation } from "../Modal/CommitConfirmation"; import { StudioDeleteConfirmationModal } from "../Modal/DeleteConfirmation"; import { StudioQueryResultStats } from "../Query/ResultStats"; -import { StudioResultTable } from "../Table/Result"; import { createStudioTableStateFromResult } from "../Table/State/Helpers"; import { useStudioCurrentWindowTab } from "../WindowTab/Context"; import type { @@ -184,7 +183,7 @@ export function StudioTableExplorerTab({ return state.getHeaders().every((header) => header.setting.readonly); }, [state]); - const headerIndexList = useMemo((): number[] => { + const _headerIndexList = useMemo((): number[] => { if (!schema) { return []; } @@ -372,7 +371,7 @@ export function StudioTableExplorerTab({ } }, [driver, tableName, schema, state, openModal]); - const onOrderByColumnChange = useCallback( + const _onOrderByColumnChange = useCallback( (columName: string, direction: StudioSortDirection) => { guardUnsavedChanges(() => { setOrderBy({ columName, direction }); @@ -438,7 +437,8 @@ export function StudioTableExplorerTab({ )}
- {schema && state && !error && ( + {/* TODO: Re-add in a later PR */} + {/* {schema && state && !error && ( - )} + )} */} {error &&
{error}
} From 3dc5b784db8083564d936b25cae6089e6614ba78 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 16:50:10 +0000 Subject: [PATCH 12/26] Add TypeScript expect error comments --- .../src/components/studio/Modal/CommitConfirmation.tsx | 1 + .../src/components/studio/Modal/DeleteConfirmation.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx b/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx index d9b23cf298ea..3c1d125181c5 100644 --- a/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx +++ b/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx @@ -34,6 +34,7 @@ export function StudioCommitConfirmation(props: Props) { }} > + {/* @ts-expect-error `@cloudflare/kumo` currently has a type def bug here */} Review and Confirm Changes
diff --git a/packages/local-explorer-ui/src/components/studio/Modal/DeleteConfirmation.tsx b/packages/local-explorer-ui/src/components/studio/Modal/DeleteConfirmation.tsx index 14d008756ef1..fd8a7bda2f0d 100644 --- a/packages/local-explorer-ui/src/components/studio/Modal/DeleteConfirmation.tsx +++ b/packages/local-explorer-ui/src/components/studio/Modal/DeleteConfirmation.tsx @@ -59,6 +59,7 @@ export const StudioDeleteConfirmationModal = ({ open={isOpen} > + {/* @ts-expect-error `@cloudflare/kumo` currently has a type def bug here */} {title}
From 138bdd174e97a900c054406721e30fc7e747920e Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 17:07:36 +0000 Subject: [PATCH 13/26] Minor code cleanup --- .../src/components/studio/Query/ResultStats.tsx | 4 ++-- .../src/components/studio/Table/State/index.tsx | 2 +- packages/local-explorer-ui/src/utils/studio/commit.ts | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx b/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx index e4170d425e86..2c743322b0da 100644 --- a/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx +++ b/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx @@ -23,8 +23,8 @@ interface StudioQueryResultStatsProps { export function StudioQueryResultStats({ stats, }: StudioQueryResultStatsProps): JSX.Element { - const statsComponents = useMemo(() => { - const content: ReactElement[] = []; + const statsComponents = useMemo((): ReactElement[] => { + const content = new Array(); if (stats.queryDurationMs !== null) { content.push( 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 index 56b139334f3c..3490bab39e24 100644 --- a/packages/local-explorer-ui/src/components/studio/Table/State/index.tsx +++ b/packages/local-explorer-ui/src/components/studio/Table/State/index.tsx @@ -841,7 +841,7 @@ export interface StudioTableStateRow { type TableStateChangeListener = (state: StudioTableState) => void; -export interface TableSelectionRange { +interface TableSelectionRange { x1: number; x2: number; y1: number; diff --git a/packages/local-explorer-ui/src/utils/studio/commit.ts b/packages/local-explorer-ui/src/utils/studio/commit.ts index 5082ac3747c9..9ac99b2e5eec 100644 --- a/packages/local-explorer-ui/src/utils/studio/commit.ts +++ b/packages/local-explorer-ui/src/utils/studio/commit.ts @@ -2,7 +2,6 @@ import type { StudioTableState, StudioTableStateRow, } from "../../components/studio/Table/State"; -import type { StudioResultHeaderMetadata } from "../../components/studio/Table/StateHelpers"; import type { IStudioDriver, StudioTableRowMutationRequest, @@ -14,6 +13,9 @@ interface StudioExecutePlan { plan: StudioTableRowMutationRequest; } +// TODO: Re-add in a later PR from `components/studio/Table/StateHelpers` +type StudioResultHeaderMetadata = object; + export async function commitStudioTableChanges({ data, driver, From 845b16c1315e2b9f5fa98f8ebd547c52e8b4019f Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 17:08:06 +0000 Subject: [PATCH 14/26] Temp: Removed `where-parser` utility --- .../src/utils/studio/where-parser.ts | 384 ------------------ 1 file changed, 384 deletions(-) delete mode 100644 packages/local-explorer-ui/src/utils/studio/where-parser.ts diff --git a/packages/local-explorer-ui/src/utils/studio/where-parser.ts b/packages/local-explorer-ui/src/utils/studio/where-parser.ts deleted file mode 100644 index 3b4a8a1f53b0..000000000000 --- a/packages/local-explorer-ui/src/utils/studio/where-parser.ts +++ /dev/null @@ -1,384 +0,0 @@ -/* -Recursive descent parser implementation for validating simplified SQL WHERE clauses. - -This parser checks for grammatical correctness and prevents injectable expressions. -It does **not** fully support the entire SQL WHERE grammar, -but covers a practical subset for typical user needs. - -⚠️ If you modify the grammar, ensure there is no left recursion. -Recursive descent parsers do not support left-recursive rules and -will enter infinite loops. - -Supported grammar: - - ::= - ::= ( "OR" )* - ::= ( "AND" )* - ::= [ "NOT" ] - ::= - | "BETWEEN" "AND" - | "LIKE" - | "IS" [ "NOT" ] "NULL" - | "(" ")" - - ::= ( ("+" | "-") )* - ::= ( ("*" | "/") )* - ::= [ "-" ] - ::= | | | "(" ")" - - ::= "(" [ ] ")" - ::= ( "," )* - - ::= "=" | "!=" | "<>" | "<" | ">" | "<=" | ">=" - ::= | - ::= [a-zA-Z_][a-zA-Z0-9_]* - ::= [0-9]+ ( "." [0-9]+ )? - ::= "'" [^']* "'" | '"' [^"]* '"' -*/ - -import type { StudioSQLToken } from "../../types/studio"; - -export class StudioWhereParser { - private functionNames: string[]; - private identifiers: string[]; - private position = 0; - private tokens: StudioSQLToken[]; - - constructor(options: { - functionNames: string[]; - identifiers: string[]; - tokens: StudioSQLToken[]; - }) { - this.tokens = options.tokens - .filter((t) => t.type !== "WHITESPACE") - .map((t) => { - if ( - t.type === "COMMENT" || - t.type === "STRING" || - t.type === "NUMBER" - ) { - return t; - } - - return { - ...t, - value: t.value.toUpperCase(), - }; - }); - - // Reserved keywords that must be escaped if used as identifiers - const reservedKeywords = [ - "GROUP", - "LIMIT", - "ORDER", - "BY", - ] satisfies string[]; - - for (const token of this.tokens) { - if (reservedKeywords.includes(token.value)) { - throw new Error( - `"${token.value}" is a reserved SQL keyword. If you're using it as a column name, please escape it with double quotes.` - ); - } - } - - this.functionNames = options.functionNames.map((fn) => fn.toUpperCase()); - - this.identifiers = options.identifiers - .map((fn): string[] => [ - fn.toUpperCase(), - this.escapeId(fn.toUpperCase()), - ]) - .flat(); - } - - public parse(): unknown { - const expr = this.parseExpression(); - if (this.position < this.tokens.length) { - throw new Error("Invalid"); - } - - return expr; - } - - // ::= - parseExpression(): unknown { - return this.parseOrExpr(); - } - - // ::= ( "OR" )* - private parseOrExpr(): unknown { - let node = this.parseAndExpr(); - while (this.match("OR")) { - const right = this.parseAndExpr(); - node = { type: "or", left: node, right }; - } - return node; - } - - // ::= ( "AND" )* - private parseAndExpr(): unknown { - let node = this.parseNotExpr(); - while (this.match("AND")) { - const right = this.parseNotExpr(); - node = { - left: node, - right, - type: "and", - }; - } - return node; - } - - // ::= [ "NOT" ] - private parseNotExpr(): unknown { - if (this.match("NOT")) { - return { - expr: this.parseComparison(), - type: "not", - }; - } - - return this.parseComparison(); - } - - /** - ::= - | "BETWEEN" "AND" - | "LIKE" - | "IS" [ "NOT" ] "NULL" - | "(" ")" - */ - private parseComparison(): unknown { - const left = this.parseArithmetic(); - - if (this.match("BETWEEN")) { - const low = this.parseArithmetic(); - this.expect("AND", "Expected 'AND' after 'BETWEEN'"); - const high = this.parseArithmetic(); - return { - expr: left, - high, - low, - type: "between", - }; - } - - if (this.match("LIKE")) { - const pattern = this.parseArithmetic(); - return { - expr: left, - pattern, - type: "like", - }; - } - - if (this.match("IS")) { - const isNot = this.match("NOT"); - const isValue = this.expect( - ["TRUE", "FALSE", "NULL"], - `Expected NULL, TRUE, or FALSE after IS${isNot ? " NOT" : ""}` - ); - - return { - expr: left, - not: isNot, - type: "is", - value: isValue, - }; - } - - if (this.peek().type === "OPERATOR") { - const op = this.consume().value; - const right = this.parseArithmetic(); - return { - left, - op, - right, - type: "binary", - }; - } - - return left; - } - - // ::= ( ("+" | "-") )* - private parseArithmetic(): unknown { - let expr = this.parseTerm(); - - while (this.match(["+", "-"])) { - const op = this.prev().value; - expr = { - type: "arith", - operator: op, - left: expr, - right: this.parseTerm(), - }; - } - - return expr; - } - - // ::= ( ("*" | "/") )* - private parseTerm(): unknown { - let expr = this.parseFactor(); - while (this.match(["*", "/"])) { - const op = this.prev().value; - expr = { - left: expr, - operator: op, - right: this.parseFactor(), - type: "arith", - }; - } - - return expr; - } - - // ::= [ "-" ] - private parseFactor(): unknown { - if (this.match("-")) { - return { - expr: this.parsePrimary(), - type: "neg", - }; - } - - return this.parsePrimary(); - } - - // ::= | | | "(" ")" - private parsePrimary(): unknown { - const token = this.peek(); - - if (this.match("(")) { - const expr = this.parseExpression(); - this.expect(")", "Expected closing ')' to match opening '('"); - return expr; - } - - if (token.type === "NUMBER" || token.type === "STRING") { - return this.consume(); - } - - if (token.type === "IDENTIFIER") { - const next = this.tokens[this.position + 1]; - - if (next && next.value === "(") { - return this.parseFunctionCall(); - } - - if (!this.identifiers.includes(token.value)) { - // Users often mistake double quotes (") for string literals. - // In SQL, string literals must use single quotes ('). - // Provide a clear error message to help them avoid this confusion. - if (token.value.startsWith('"') && token.value.endsWith('"')) { - throw new Error( - `${token.value} is not a valid column name in this table. It looks like you meant to use a string literal — wrap strings in single quotes (') instead of double quotes (").` - ); - } - - throw new Error( - `${token.value} is not a valid column name in this table.` - ); - } - - return this.consume(); - } - - throw new Error(`Unexpected token ${token.type}`); - } - - // ::= "(" [ ] ")" - private parseFunctionCall(): { - args: unknown[]; - name: string; - type: string; - } { - const name = this.expect( - this.functionNames, - "Unsupported function name" - ).value; - - this.expect("(", "Expected '(' to start function call"); - - const args: unknown[] = []; - while (this.peek().value !== ")") { - args.push(this.parseExpression()); - if (this.peek().value === ",") { - this.consume(); - } else { - break; - } - } - - this.expect(")", "Expected closing ')' to end function call"); - - return { - args, - name, - type: "call", - }; - } - - // Utility methods - private prev(): StudioSQLToken { - return this.tokens[this.position - 1] as StudioSQLToken; - } - - private escapeId(id: string): string { - return `"${id.replace(/"/g, `""`)}"`; - } - - private peek(): StudioSQLToken { - return ( - this.tokens[this.position] ?? { - type: "UNKNOWN", - value: "", - } - ); - } - - private consume(): StudioSQLToken { - return ( - this.tokens[this.position++] ?? { - type: "UNKNOWN", - value: "", - } - ); - } - - private expect( - keywords: string[] | string, - errorMessage?: string - ): StudioSQLToken { - const token = this.consume(); - - if (typeof keywords === "string" && keywords !== token.value) { - throw new Error( - errorMessage ?? - "Expecting " + keywords.toString() + " but got " + token.value - ); - } else if (Array.isArray(keywords) && !keywords.includes(token.value)) { - throw new Error(errorMessage ?? "Unable to parse"); - } - - return token; - } - - private match(keywords: string | string[]): boolean { - const token = this.peek(); - - if (typeof keywords === "string" && keywords === token.value) { - this.consume(); - return true; - } - - if (Array.isArray(keywords) && keywords.includes(token.value)) { - this.consume(); - return true; - } - - return false; - } -} From f611b0779cb555d45003981dcb645b1e1659f186 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 17:29:20 +0000 Subject: [PATCH 15/26] Minor commit confirmation modal refactoring --- .../studio/Modal/CommitConfirmation.tsx | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx b/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx index 3c1d125181c5..dc8dc8fa527f 100644 --- a/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx +++ b/packages/local-explorer-ui/src/components/studio/Modal/CommitConfirmation.tsx @@ -1,35 +1,48 @@ import { Button, Dialog } from "@cloudflare/kumo"; import { PlayIcon, SpinnerIcon } from "@phosphor-icons/react"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { CodeBlock } from "../Code/Block"; -interface Props { +interface StudioCommitConfirmationProps { closeModal: () => void; isOpen: boolean; onConfirm: () => Promise; statements: string[]; } -export function StudioCommitConfirmation(props: Props) { - const [errorMessage, setErrorMessage] = useState(""); - const [isRequesting, setIsRequesting] = useState(false); +export function StudioCommitConfirmation({ + closeModal, + isOpen, + onConfirm, + statements, +}: StudioCommitConfirmationProps) { + const [errorMessage, setErrorMessage] = useState(""); + const [isRequesting, setIsRequesting] = useState(false); - const onConfirm = useCallback(async () => { + const handleConfirm = async (): Promise => { setIsRequesting(true); + setErrorMessage(""); + try { - await props.onConfirm(); - props.closeModal(); + await onConfirm(); + closeModal(); + } catch (err) { + if (err instanceof Error) { + setErrorMessage(err.message); + } else { + setErrorMessage(String(err)); + } } finally { setIsRequesting(false); } - }, [props]); + }; return ( { if (!open) { - props.closeModal(); + closeModal(); } }} > @@ -39,7 +52,7 @@ export function StudioCommitConfirmation(props: Props) {
{!!errorMessage && ( -
{errorMessage}
+
{errorMessage}
)}
@@ -48,37 +61,22 @@ export function StudioCommitConfirmation(props: Props) {
-
+
From d9369ae43c4e55eb3d56c70c0b81018997316e98 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 17:31:56 +0000 Subject: [PATCH 16/26] Removed unused imports --- packages/local-explorer-ui/src/utils/studio/index.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/local-explorer-ui/src/utils/studio/index.ts b/packages/local-explorer-ui/src/utils/studio/index.ts index acb7b4b49896..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, `''`)}'`; From ae4df7f35a47afad722ad3890f67f2fb9b37f901 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 17:34:35 +0000 Subject: [PATCH 17/26] Added TODO's for disabled variables --- .../src/components/studio/Tabs/TableExplorer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx index f9c2b79945dd..1f9c1e53ff6f 100644 --- a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx +++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx @@ -60,6 +60,7 @@ export function StudioTableExplorerTab({ const { openModal } = useModal(); + // @ts-expect-error TODO: Re-enable in a later PR const _filterAutoCompleteColumns = useMemo(() => { if (!schema) { return []; @@ -183,6 +184,7 @@ export function StudioTableExplorerTab({ 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 []; @@ -297,6 +299,7 @@ export function StudioTableExplorerTab({ }); }, [pageOffset, pageLimit, guardUnsavedChanges]); + // @ts-expect-error TODO: Re-enable in a later PR const _onWhereRawApplied = useCallback( (newWhereRaw: string): void => { guardUnsavedChanges(() => { @@ -371,6 +374,7 @@ export function StudioTableExplorerTab({ } }, [driver, tableName, schema, state, openModal]); + // @ts-expect-error TODO: Re-enable in a later PR const _onOrderByColumnChange = useCallback( (columName: string, direction: StudioSortDirection) => { guardUnsavedChanges(() => { From 9b05bb290a865ce83de2d4fadb08cd96500911a9 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 20:05:41 +0000 Subject: [PATCH 18/26] Import Kumo Tailwind CSS styles --- packages/local-explorer-ui/src/styles/tailwind.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/local-explorer-ui/src/styles/tailwind.css b/packages/local-explorer-ui/src/styles/tailwind.css index 0bfd05b4f4ad..e236b23fccfd 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)); From eb70210f090bfedda7710ef51b4129bfb6593862 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 21:16:54 +0000 Subject: [PATCH 19/26] Added custom Kumo style overrides --- .../local-explorer-ui/src/styles/tailwind.css | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/local-explorer-ui/src/styles/tailwind.css b/packages/local-explorer-ui/src/styles/tailwind.css index e236b23fccfd..a97743b36a5b 100644 --- a/packages/local-explorer-ui/src/styles/tailwind.css +++ b/packages/local-explorer-ui/src/styles/tailwind.css @@ -19,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); @@ -27,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 { @@ -42,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); } } From 7d38d5839a4e00456080fd1ce32a052d651be7e0 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 21:17:06 +0000 Subject: [PATCH 20/26] Minor tab bar / footer style tweaks --- .../components/studio/Tabs/TableExplorer.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx index 1f9c1e53ff6f..323e169da67f 100644 --- a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx +++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx @@ -387,16 +387,27 @@ export function StudioTableExplorerTab({ return (
- {!readOnlyMode && ( <> - - @@ -466,7 +477,7 @@ export function StudioTableExplorerTab({ )}
-
+
{queryStats && }
@@ -481,17 +492,17 @@ export function StudioTableExplorerTab({ > -
+
setPageLimitInput(e.currentTarget.value)} value={pageLimitInput} /> setPageOffsetInput(e.currentTarget.value)} value={pageOffsetInput} From ef84629391f915804a968b406f13118b529d582b Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Wed, 18 Feb 2026 22:42:49 +0000 Subject: [PATCH 21/26] Updated lockfile --- pnpm-lock.yaml | 36 ++++++------------------------------ 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9e0c6202a4c..44029cf00b57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2104,7 +2104,7 @@ importers: version: 1.1.0(@types/react@19.2.10)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@cloudflare/kumo': specifier: ^1.5.0 - version: 1.6.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) + 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) @@ -5152,8 +5152,8 @@ packages: peerDependencies: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 - '@cloudflare/kumo@1.6.0': - resolution: {integrity: sha512-1Sy8kgfHNkze+NEfu/6cNzwOb0hemGm1mUNGU9GVmAnHemLOaXixosslM/o38TbUTEEs48yTRrDy0WFGgSTFWg==} + '@cloudflare/kumo@1.5.0': + resolution: {integrity: sha512-Y2fE72C3KwniG94SYROVtMwD5wNx/IXQ3CPbZv+ayD37nEXx1He3D5qFwh5PgPGtBQCrHiAL4J2b9v2FkgEvkQ==} hasBin: true peerDependencies: '@phosphor-icons/react': ^2.1.10 @@ -5331,9 +5331,6 @@ packages: resolution: {integrity: sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==} engines: {node: '>= 6'} - '@date-fns/tz@1.4.1': - resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} - '@electric-sql/pglite-socket@0.0.6': resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==} hasBin: true @@ -9893,9 +9890,6 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} - date-fns-jalali@4.1.0-0: - resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} - date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -10910,12 +10904,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -13154,12 +13148,6 @@ packages: react-addons-shallow-compare@15.6.3: resolution: {integrity: sha512-EDJbgKTtGRLhr3wiGDXK/+AEJ59yqGS+tKE6mue0aNXT6ZMR7VJbbzIiT6akotmHg1BLj46ElJSb+NBMp80XBg==} - react-day-picker@9.13.2: - resolution: {integrity: sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==} - engines: {node: '>=18'} - peerDependencies: - react: '>=16.8.0' - react-display-name@0.2.5: resolution: {integrity: sha512-I+vcaK9t4+kypiSgaiVWAipqHRXYmZIuAiS8vzFvXHHXVigg/sMKwlRgLy6LH2i3rmP+0Vzfl5lFsFRwF1r3pg==} @@ -16246,13 +16234,12 @@ snapshots: dependencies: react: 19.2.1 - '@cloudflare/kumo@1.6.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/kumo@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)': dependencies: '@base-ui/react': 1.1.0(@types/react@19.2.10)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@phosphor-icons/react': 2.1.10(react-dom@19.2.1(react@19.2.1))(react@19.2.1) clsx: 2.1.1 react: 19.2.1 - react-day-picker: 9.13.2(react@19.2.1) react-dom: 19.2.1(react@19.2.1) tailwind-merge: 3.4.0 transitivePeerDependencies: @@ -16512,8 +16499,6 @@ snapshots: tunnel-agent: 0.6.0 uuid: 8.3.2 - '@date-fns/tz@1.4.1': {} - '@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)': dependencies: '@electric-sql/pglite': 0.3.2 @@ -21018,8 +21003,6 @@ snapshots: dataloader@1.4.0: {} - date-fns-jalali@4.1.0-0: {} - date-fns@2.30.0: dependencies: '@babel/runtime': 7.22.5 @@ -24610,13 +24593,6 @@ snapshots: dependencies: object-assign: 4.1.1 - react-day-picker@9.13.2(react@19.2.1): - dependencies: - '@date-fns/tz': 1.4.1 - date-fns: 4.1.0 - date-fns-jalali: 4.1.0-0 - react: 19.2.1 - react-display-name@0.2.5: {} react-dom@18.3.1(react@18.3.1): From 0d2116b5ae4c0c1694c37c097c82e60fb9e54984 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 20 Feb 2026 13:07:20 +0000 Subject: [PATCH 22/26] Fixed Tailwind CSS source path --- packages/local-explorer-ui/src/styles/tailwind.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/local-explorer-ui/src/styles/tailwind.css b/packages/local-explorer-ui/src/styles/tailwind.css index a97743b36a5b..4539ae8f50f6 100644 --- a/packages/local-explorer-ui/src/styles/tailwind.css +++ b/packages/local-explorer-ui/src/styles/tailwind.css @@ -1,4 +1,4 @@ -@source "../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}"; +@source "../../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}"; @import "@cloudflare/kumo/styles/tailwind"; @import "tailwindcss"; From b8e504e36ee97aa0be0d3720a00015b1b17d0692 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 20 Feb 2026 13:15:01 +0000 Subject: [PATCH 23/26] Updated tab row buttons design --- .../src/components/studio/Tabs/TableExplorer.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx index 323e169da67f..70b1d1dd8389 100644 --- a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx +++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx @@ -3,7 +3,9 @@ import { ArrowsCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, + PlusIcon, SpinnerIcon, + TrashIcon, } from "@phosphor-icons/react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -392,6 +394,7 @@ export function StudioTableExplorerTab({ className="hover:bg-border! transition" onClick={onRefreshClicked} shape="square" + variant="ghost" > @@ -401,13 +404,17 @@ export function StudioTableExplorerTab({ From 590f3fbd4db0b7a2b25490458dab76ef4e30e757 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 20 Feb 2026 13:15:46 +0000 Subject: [PATCH 24/26] Removed read-only support for tab bar actions --- .../components/studio/Table/State/Helpers.tsx | 13 +++++- .../components/studio/Tabs/TableExplorer.tsx | 44 +++++++------------ 2 files changed, 28 insertions(+), 29 deletions(-) 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 index 945487ba4cdf..7d9822729b9e 100644 --- a/packages/local-explorer-ui/src/components/studio/Table/State/Helpers.tsx +++ b/packages/local-explorer-ui/src/components/studio/Table/State/Helpers.tsx @@ -62,7 +62,18 @@ function buildTableResultHeader( ): StudioTableHeaderInput[] { const { driver, result, tableSchema } = props; - const headers = result.headers.map((column) => { + // When the result has no headers (e.g., empty table with no rows), + // fall back to using the table schema columns to build headers + const sourceHeaders = + result.headers.length > 0 + ? result.headers + : (tableSchema?.columns ?? []).map((col) => ({ + columnType: col.type, + displayName: col.name, + name: col.name, + })); + + const headers = sourceHeaders.map((column) => { return { display: { text: column.displayName, diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx index 70b1d1dd8389..2a30fadaba97 100644 --- a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx +++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx @@ -178,14 +178,6 @@ export function StudioTableExplorerTab({ 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) { @@ -399,26 +391,22 @@ export function StudioTableExplorerTab({ - {!readOnlyMode && ( - <> - - - - )} + +
{/* TODO: Re-add in a later PR */} From 40b41b1c4e9d1f87f212791810386abbaed327fd Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 20 Feb 2026 13:17:23 +0000 Subject: [PATCH 25/26] Fixed rows read counter --- .../src/components/studio/Query/ResultStats.tsx | 2 +- packages/local-explorer-ui/src/drivers/d1.ts | 5 ++++- packages/local-explorer-ui/src/types/studio.ts | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx b/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx index 2c743322b0da..d8e6df7a27d7 100644 --- a/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx +++ b/packages/local-explorer-ui/src/components/studio/Query/ResultStats.tsx @@ -55,7 +55,7 @@ export function StudioQueryResultStats({ if (stats.rowsRead) { content.push(
- Rows Read: {stats.rowsRead} + Rows Read: {stats.rowCount}
); } diff --git a/packages/local-explorer-ui/src/drivers/d1.ts b/packages/local-explorer-ui/src/drivers/d1.ts index d1323d9c38ca..abcb7d7b8b26 100644 --- a/packages/local-explorer-ui/src/drivers/d1.ts +++ b/packages/local-explorer-ui/src/drivers/d1.ts @@ -77,10 +77,12 @@ export class LocalD1Connection implements IStudioConnection { * @returns A normalised result set for use by the studio UI. */ private transformResult(result: D1RawResultResponse): StudioResultSet { + const rows = (result.results?.rows ?? []) as unknown[][]; + return { ...transformStudioArrayBasedResult({ headers: result.results?.columns ?? [], - rows: (result.results?.rows ?? []) as unknown[][], + rows, transformHeader: (headerName) => ({ name: headerName, displayName: headerName, @@ -92,6 +94,7 @@ export class LocalD1Connection implements IStudioConnection { rowsAffected: result.meta?.changes ?? 0, rowsRead: result.meta?.rows_read ?? null, rowsWritten: result.meta?.rows_written ?? null, + rowCount: rows.length, }, }; } diff --git a/packages/local-explorer-ui/src/types/studio.ts b/packages/local-explorer-ui/src/types/studio.ts index 5dddd709ca16..1709bbb6aa20 100644 --- a/packages/local-explorer-ui/src/types/studio.ts +++ b/packages/local-explorer-ui/src/types/studio.ts @@ -202,6 +202,10 @@ export interface StudioResultStat { rowsAffected: number; rowsRead: number | null; rowsWritten: number | null; + /** + * Number of rows returned by the query + */ + rowCount: number; } export interface StudioSchemaItem { From abcedf7ddacddb01717efd548d1a5cd31a85ea38 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 20 Feb 2026 13:24:11 +0000 Subject: [PATCH 26/26] Fixed footer row limit UI --- .../components/studio/Tabs/TableExplorer.tsx | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx index 2a30fadaba97..fc6fc6be4aa6 100644 --- a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx +++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx @@ -3,6 +3,7 @@ import { ArrowsCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, + ListNumbersIcon, PlusIcon, SpinnerIcon, TrashIcon, @@ -477,6 +478,22 @@ export function StudioTableExplorerTab({ {queryStats && }
+
+ +
+ +
+ + setPageLimitInput(e.currentTarget.value)} + value={pageLimitInput} + /> +
+
+
-
- setPageLimitInput(e.currentTarget.value)} - value={pageLimitInput} - /> - setPageOffsetInput(e.currentTarget.value)} - value={pageOffsetInput} - /> -
+ + setPageOffsetInput(e.currentTarget.value)} + value={pageOffsetInput} + /> +