diff --git a/.gitignore b/.gitignore index 5067d0ca48..093fcad7e1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ fixname.sh # IDEs **/.vscode **/.idea +**/.cursor **/*.code-workspace # Helper Scripts @@ -17,7 +18,13 @@ requests.txt .prettierrc # Local build tools installed via Taskfiles -build +/build +/configs -.cursor +# Go workspace (local dev only) +go.work +go.work.sum + +# Claude Code local state .claude/worktrees/ +.claude/settings.local.json diff --git a/backend/pkg/console/endpoint_compatibility.go b/backend/pkg/console/endpoint_compatibility.go index 6e770d258f..c541eb9d12 100644 --- a/backend/pkg/console/endpoint_compatibility.go +++ b/backend/pkg/console/endpoint_compatibility.go @@ -231,6 +231,11 @@ func (s *Service) GetEndpointCompatibility(ctx context.Context) (EndpointCompati Method: "POST", IsSupported: false, }, + EndpointCompatibilityEndpoint{ + Endpoint: dataplanev1alpha3connect.SQLServiceName, + Method: "POST", + IsSupported: false, + }, ) return EndpointCompatibility{ diff --git a/frontend/bun.lock b/frontend/bun.lock index 681ea1c38d..9512187d68 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "dependencies": { @@ -9,7 +8,7 @@ "@buf/redpandadata_cloud.connectrpc_query-es": "^2.2.0-20251128173054-b9f9fc6e5a70.1", "@buf/redpandadata_common.bufbuild_es": "^2.11.0-20260316210807-5d899910f714.1", "@bufbuild/cel": "^0.4.0", - "@bufbuild/protobuf": "^2.11.0", + "@bufbuild/protobuf": "^2.12.0", "@bufbuild/protoc-gen-es": "^2.10.0", "@builder.io/sdk-react": "^4.2.4", "@chakra-ui/object-utils": "^2.1", @@ -17,12 +16,18 @@ "@chakra-ui/portal": "^2.1", "@chakra-ui/react-use-disclosure": "^2.1", "@chakra-ui/system": "^2.1", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-query": "^2.2.0", "@connectrpc/connect-web": "^2.1.0", "@emotion/css": "^11.13.5", "@hookform/resolvers": "^5.2.2", "@icons-pack/react-simple-icons": "^13.8.0", + "@lezer/highlight": "^1.2.3", "@milkdown/kit": "^7.18.0", "@milkdown/react": "^7.18.0", "@modelcontextprotocol/sdk": "^1.29.0", @@ -40,13 +45,14 @@ "@tanstack/react-virtual": "^3.13.12", "@tanstack/zod-adapter": "^1.167.0", "@types/prismjs": "^1.26.5", + "@uiw/react-codemirror": "^4.25.10", "@xyflow/react": "^12.9.2", "ai": "^6.0.168", "array-move": "^4.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "date-fns": "^4.1.0", + "date-fns": "^4.3.0", "dexie": "^4.2.1", "dotenv": "^17.2.3", "es-cookie": "^1.5.0", @@ -69,11 +75,12 @@ "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", "react-compiler-runtime": "^1.0.0", + "react-data-grid": "7.0.0-beta.47", "react-day-picker": "^9.14.0", "react-dom": "^18.3.1", "react-dropzone": "^15.0.0", "react-highlight-words": "^0.21.0", - "react-hook-form": "^7.72.0", + "react-hook-form": "^7.76.1", "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "react-simple-code-editor": "^0.14.1", @@ -83,6 +90,7 @@ "remark-gfm": "^4.0.1", "shiki": "^3.23.0", "sonner": "^2.0.7", + "sql-formatter": "^15.8.1", "stacktrace-js": "^2.0.2", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", @@ -355,9 +363,9 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], - "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], - "@codemirror/commands": ["@codemirror/commands@6.10.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q=="], + "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], "@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="], @@ -401,21 +409,21 @@ "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw=="], - "@codemirror/language": ["@codemirror/language@6.12.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ=="], + "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="], "@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="], "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.2", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q=="], - "@codemirror/lint": ["@codemirror/lint@6.9.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw=="], + "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], - "@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="], + "@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="], - "@codemirror/state": ["@codemirror/state@6.5.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw=="], + "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], - "@codemirror/view": ["@codemirror/view@6.39.12", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ=="], + "@codemirror/view": ["@codemirror/view@6.43.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw=="], "@connectrpc/connect": ["@connectrpc/connect@2.1.0", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-xhiwnYlJNHzmFsRw+iSPIwXR/xweTvTw8x5HiwWp10sbVtd4OpOXbRgE7V58xs1EC17fzusF1f5uOAy24OkBuA=="], @@ -1395,6 +1403,10 @@ "@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="], + "@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.10", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ=="], + + "@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.10", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.10", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -1873,7 +1885,7 @@ "data-urls": ["data-urls@2.0.0", "", { "dependencies": { "abab": "^2.0.3", "whatwg-mimetype": "^2.3.0", "whatwg-url": "^8.0.0" } }, "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ=="], - "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -1925,6 +1937,8 @@ "diffie-hellman": ["diffie-hellman@5.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "miller-rabin": "^4.0.0", "randombytes": "^2.0.0" } }, "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg=="], + "discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="], + "docker-compose": ["docker-compose@1.4.2", "", { "dependencies": { "yaml": "^2.2.2" } }, "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww=="], "docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="], @@ -2679,6 +2693,8 @@ "monaco-yaml": ["monaco-yaml@5.5.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.0.0", "monaco-languageserver-types": "^0.4.0", "monaco-marker-data-provider": "^1.0.0", "monaco-types": "^0.1.0", "monaco-worker-manager": "^2.0.0", "path-browserify": "^1.0.0", "prettier": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.0", "vscode-languageserver-types": "^3.0.0", "vscode-uri": "^3.0.0", "yaml": "^2.0.0" }, "peerDependencies": { "monaco-editor": ">=0.36" } }, "sha512-FEJezTYwzL3VFCWnA98mPp0AmPERoiQNN54ycllF0LorUeTXPN2YzJzu9jfzwVn2CxX1qajEornAr4CrZffuMQ=="], + "moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="], + "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], @@ -2699,6 +2715,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "nearley": ["nearley@2.20.1", "", { "dependencies": { "commander": "^2.19.0", "moo": "^0.5.0", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6" }, "bin": { "nearleyc": "bin/nearleyc.js", "nearley-test": "bin/nearley-test.js", "nearley-unparse": "bin/nearley-unparse.js", "nearley-railroad": "bin/nearley-railroad.js" } }, "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], @@ -2923,6 +2941,10 @@ "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], + "railroad-diagrams": ["railroad-diagrams@1.0.0", "", {}, "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A=="], + + "randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="], + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], "randomfill": ["randomfill@1.0.4", "", { "dependencies": { "randombytes": "^2.0.5", "safe-buffer": "^5.1.0" } }, "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw=="], @@ -2941,6 +2963,8 @@ "react-compiler-runtime": ["react-compiler-runtime@1.0.0", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w=="], + "react-data-grid": ["react-data-grid@7.0.0-beta.47", "", { "dependencies": { "clsx": "^2.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0", "react-dom": "^18.0 || ^19.0" } }, "sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ=="], + "react-datepicker": ["react-datepicker@4.25.0", "", { "dependencies": { "@popperjs/core": "^2.11.8", "classnames": "^2.2.6", "date-fns": "^2.30.0", "prop-types": "^15.7.2", "react-onclickoutside": "^6.13.0", "react-popper": "^2.3.0" }, "peerDependencies": { "react": "^16.9.0 || ^17 || ^18", "react-dom": "^16.9.0 || ^17 || ^18" } }, "sha512-zB7CSi44SJ0sqo8hUQ3BF1saE/knn7u25qEMTO1CQGofY1VAKahO8k9drZtp0cfW1DMfoYLR3uSY1/uMvbEzbg=="], "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], @@ -2957,7 +2981,7 @@ "react-highlight-words": ["react-highlight-words@0.21.0", "", { "dependencies": { "highlight-words-core": "^1.2.0", "memoize-one": "^4.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-SdWEeU9fIINArEPO1rO5OxPyuhdEKZQhHzZZP1ie6UeXQf+CjycT1kWaB+9bwGcVbR0NowuHK3RqgqNg6bgBDQ=="], - "react-hook-form": ["react-hook-form@7.73.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA=="], + "react-hook-form": ["react-hook-form@7.78.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA=="], "react-icons": ["react-icons@4.12.0", "", { "peerDependencies": { "react": "*" } }, "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw=="], @@ -3063,6 +3087,8 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -3215,6 +3241,8 @@ "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="], + "sql-formatter": ["sql-formatter@15.8.1", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-nT2r90kTEYBuse9fe4r1Rp78v1mOBD35KsGc07Vo9eQSVa1TcTSnCS0zouf6BCmdzvmqBsBW+cYuBoYkHO/OWg=="], + "ssh-remote-port-forward": ["ssh-remote-port-forward@1.0.4", "", { "dependencies": { "@types/ssh2": "^0.5.48", "ssh2": "^1.4.0" } }, "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ=="], "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], @@ -3867,6 +3895,8 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@uiw/react-codemirror/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@vitejs/plugin-react/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="], "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -4041,6 +4071,8 @@ "motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "nearley/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "no-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "node-abi/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -4065,6 +4097,8 @@ "react-datepicker/date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="], + "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "react-highlight-words/memoize-one": ["memoize-one@4.1.0", "", {}, "sha512-2GApq0yI/b22J2j9rhbrAlsHb0Qcz+7yWxeLG8h+95sl1XPUgeLimQSOdur4Vw7cUhrBHwaUZxWFZueojqNRzA=="], "react-redux/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], diff --git a/frontend/package.json b/frontend/package.json index 236a80f163..fb4847e6e1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,7 +53,7 @@ "@buf/redpandadata_cloud.connectrpc_query-es": "^2.2.0-20251128173054-b9f9fc6e5a70.1", "@buf/redpandadata_common.bufbuild_es": "^2.11.0-20260316210807-5d899910f714.1", "@bufbuild/cel": "^0.4.0", - "@bufbuild/protobuf": "^2.11.0", + "@bufbuild/protobuf": "^2.12.0", "@bufbuild/protoc-gen-es": "^2.10.0", "@builder.io/sdk-react": "^4.2.4", "@chakra-ui/object-utils": "^2.1", @@ -61,12 +61,18 @@ "@chakra-ui/portal": "^2.1", "@chakra-ui/react-use-disclosure": "^2.1", "@chakra-ui/system": "^2.1", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-query": "^2.2.0", "@connectrpc/connect-web": "^2.1.0", "@emotion/css": "^11.13.5", "@hookform/resolvers": "^5.2.2", "@icons-pack/react-simple-icons": "^13.8.0", + "@lezer/highlight": "^1.2.3", "@milkdown/kit": "^7.18.0", "@milkdown/react": "^7.18.0", "@modelcontextprotocol/sdk": "^1.29.0", @@ -84,13 +90,14 @@ "@tanstack/react-virtual": "^3.13.12", "@tanstack/zod-adapter": "^1.167.0", "@types/prismjs": "^1.26.5", + "@uiw/react-codemirror": "^4.25.10", "@xyflow/react": "^12.9.2", "ai": "^6.0.168", "array-move": "^4.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "date-fns": "^4.1.0", + "date-fns": "^4.3.0", "dexie": "^4.2.1", "dotenv": "^17.2.3", "es-cookie": "^1.5.0", @@ -113,11 +120,12 @@ "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", "react-compiler-runtime": "^1.0.0", + "react-data-grid": "7.0.0-beta.47", "react-day-picker": "^9.14.0", "react-dom": "^18.3.1", "react-dropzone": "^15.0.0", "react-highlight-words": "^0.21.0", - "react-hook-form": "^7.72.0", + "react-hook-form": "^7.76.1", "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "react-simple-code-editor": "^0.14.1", @@ -127,6 +135,7 @@ "remark-gfm": "^4.0.1", "shiki": "^3.23.0", "sonner": "^2.0.7", + "sql-formatter": "^15.8.1", "stacktrace-js": "^2.0.2", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 447a2253dc..c3d956ec64 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -42,6 +42,7 @@ import { builderCustomComponents } from 'components/builder-io/builder-custom-co import { BUILDER_API_KEY } from 'components/constants'; import { CustomFeatureFlagProvider } from 'custom-feature-flag-provider'; import useDeveloperView from 'hooks/use-developer-view'; +import type { LucideIcon } from 'lucide-react'; import { protobufRegistry } from 'protobuf-registry'; import queryClient from 'query-client'; import { useEffect } from 'react'; @@ -98,6 +99,16 @@ declare module '@tanstack/react-router' { content?: string; score?: number; } + + // biome-ignore lint/style/useConsistentTypeDefinitions: Required for TanStack Router module augmentation + interface StaticDataRouteOption { + /** Route title shown in the page header/breadcrumbs. */ + title?: string; + /** Lucide icon for the route's sidebar entry. */ + icon?: LucideIcon; + /** Render the route with minimal chrome (no page header/footer/padding). */ + fullscreen?: boolean; + } } const EMPTY_SETUP_ARGS = {}; diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index db5af09e45..9dc9e15b4e 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -10,7 +10,7 @@ */ import { Button, ColorModeSwitch, CopyButton } from '@redpanda-data/ui'; -import { Link, useLocation, useMatchRoute } from '@tanstack/react-router'; +import { Link, useLocation, useMatches, useMatchRoute } from '@tanstack/react-router'; import { Heading } from 'components/redpanda-ui/components/typography'; import { cn } from 'components/redpanda-ui/lib/utils'; import { ChevronLeft } from 'lucide-react'; @@ -69,8 +69,14 @@ function BreadcrumbHeaderRow({ useNewSidebar, breadcrumbItems }: BreadcrumbHeade ); } -function AppPageHeader() { +function AppPageHeader({ breadcrumbOnly = false }: { breadcrumbOnly?: boolean }) { useApiStoreHook((s) => s.userData); // re-render when userData changes + // Fullscreen routes (e.g. the SQL studio) carry their own title bar/toolbar, so + // they never want the title+actions row — only the breadcrumb. Robust to which + // layout branch renders the header (standalone vs embedded misdetection). + const matches = useMatches(); + const isFullscreenRoute = matches.some((m) => m.staticData.fullscreen); + const hideTitleRow = breadcrumbOnly || isFullscreenRoute; const showRefresh = useShouldShowRefresh(); const shouldHideHeader = useShouldHideHeader(); const useNewSidebar = !isEmbedded(); @@ -104,49 +110,55 @@ function AppPageHeader() {
-
-
- {backLink && ( - - - - {backLink.title} + {/* Title + actions row. Hidden for breadcrumb-only headers (e.g. the SQL + studio, which carries its own title bar and toolbar). */} + {!hideTitleRow && ( +
+
+ {backLink && ( + + + + {backLink.title} + + + )} +
+ {pageTitle ? ( + + {pageTitle} + + ) : null} + {lastBreadcrumb?.options?.canBeCopied ? ( + + ) : null} + {Boolean(showRefresh) && } +
+
+
+ {!isEmbedded() && api.isRedpanda && ( + + - - )} -
- {pageTitle ? ( - - {pageTitle} - - ) : null} - {lastBreadcrumb?.options?.canBeCopied ? ( - - ) : null} - {Boolean(showRefresh) && } + )} + + {IsDev && !isEmbedded() && }
-
- {!isEmbedded() && api.isRedpanda && ( - - - - )} - - {IsDev && !isEmbedded() && } -
-
+ )}
); } diff --git a/frontend/src/components/pages/sql/catalog-tree.test.tsx b/frontend/src/components/pages/sql/catalog-tree.test.tsx new file mode 100644 index 0000000000..30e719752c --- /dev/null +++ b/frontend/src/components/pages/sql/catalog-tree.test.tsx @@ -0,0 +1,179 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import userEvent from '@testing-library/user-event'; +import { useDescribeTableQuery, useListTablesQuery, useTopicIcebergQuery } from 'react-query/api/sql'; +import { render, screen } from 'test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { CatalogTree } from './catalog-tree'; +import type { Catalog, TableRef } from './sql-types'; + +vi.mock('react-query/api/sql', () => ({ + useListTablesQuery: vi.fn(), + useDescribeTableQuery: vi.fn(), + useTopicIcebergQuery: vi.fn(), +})); + +const tableRef = (name: string, overrides: Partial = {}): TableRef => ({ + id: `rp.public.${name}`, + name, + namespaceName: 'public', + catalogName: 'rp', + ...overrides, +}); + +const catalog = (overrides: Partial = {}): Catalog => ({ + name: 'rp', + displayLabel: 'Redpanda Catalog', + engine: 'redpanda', + namespaces: [{ id: 'rp.public', name: 'public', tables: [tableRef('orders'), tableRef('users')] }], + ...overrides, +}); + +const noTables = { data: undefined, isLoading: false }; +const REDPANDA_CATALOG_RE = /Redpanda Catalog/; +const ORDERS_RE = /orders/; +const PUBLIC_RE = /public/; +const USERS_RE = /users/; +const CUSTOMER_RE = /customer/; +const SECRET_RE = /secret/; +const PAGED_TABLE_RE = /t\d\d/; +const LOAD_MORE_RE = /Load more · 5 remaining/; + +beforeEach(() => { + vi.mocked(useListTablesQuery).mockReturnValue(noTables as never); + vi.mocked(useDescribeTableQuery).mockReturnValue({ data: undefined, isLoading: false } as never); + vi.mocked(useTopicIcebergQuery).mockReturnValue({ isIceberg: false } as never); +}); + +describe('CatalogTree', () => { + test('renders an ARIA tree with catalogs, namespaces and tables expanded by default', () => { + render(); + + expect(screen.getByRole('tree', { name: 'Catalogs' })).toBeInTheDocument(); + const items = screen.getAllByRole('treeitem'); + expect(items.map((i) => i.getAttribute('aria-level'))).toEqual(['1', '2', '3', '3']); + expect(screen.getByRole('treeitem', { name: REDPANDA_CATALOG_RE })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('treeitem', { name: ORDERS_RE })).toBeInTheDocument(); + }); + + test('collapsing a catalog hides its namespaces and tables', async () => { + render(); + + await userEvent.click(screen.getByRole('treeitem', { name: REDPANDA_CATALOG_RE })); + + expect(screen.getByRole('treeitem', { name: REDPANDA_CATALOG_RE })).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('treeitem', { name: PUBLIC_RE })).toBeNull(); + expect(screen.queryByRole('treeitem', { name: ORDERS_RE })).toBeNull(); + }); + + test('search filters tables', async () => { + render(); + + await userEvent.type(screen.getByPlaceholderText('Search tables'), 'ord'); + + expect(screen.getByRole('treeitem', { name: ORDERS_RE })).toBeInTheDocument(); + expect(screen.queryByRole('treeitem', { name: USERS_RE })).toBeNull(); + }); + + test('clicking the query action calls onQueryTable with the catalog and table', async () => { + const onQueryTable = vi.fn(); + render(); + + await userEvent.click(screen.getAllByRole('button', { name: 'Query this table' })[0]); + + expect(onQueryTable).toHaveBeenCalledWith( + expect.objectContaining({ name: 'rp' }), + expect.objectContaining({ name: 'orders' }) + ); + }); + + test('expanding a table lists its columns with type labels', async () => { + // Types arrive lower-cased from the backend; composite columns as "json" + // with their nested fields parsed server-side. + vi.mocked(useDescribeTableQuery).mockReturnValue({ + data: { + columns: [ + { name: 'id', type: 'bigint' }, + { name: 'payload', type: 'jsonb' }, + { name: 'tags', type: 'text[]' }, + { name: 'customer', type: 'json', fields: [{ name: 'street', type: 'text' }] }, + ], + }, + isLoading: false, + } as never); + render(); + + await userEvent.click(screen.getByRole('treeitem', { name: ORDERS_RE })); + + expect(screen.getByText('payload')).toBeInTheDocument(); + expect(screen.getByText('jsonb')).toBeInTheDocument(); + expect(screen.getByText('text[]')).toBeInTheDocument(); + + // Composite column shows "json" and expands into its nested fields. + const customerRow = screen.getByRole('button', { name: CUSTOMER_RE }); + expect(customerRow).toBeInTheDocument(); + expect(screen.queryByText('street')).not.toBeInTheDocument(); + await userEvent.click(customerRow); + expect(screen.getByText('street')).toBeInTheDocument(); + }); + + test('locked tables are disabled and show no query action', () => { + const locked = catalog({ + namespaces: [{ id: 'rp.public', name: 'public', tables: [tableRef('secret', { allowed: false })] }], + }); + render(); + + expect(screen.getByRole('treeitem', { name: SECRET_RE })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Query this table' })).toBeNull(); + }); + + test('admin sees the catalog-level add action; viewer does not', () => { + const onAddTable = vi.fn(); + const { rerender } = render( + + ); + expect(screen.getByRole('button', { name: 'Add a topic to this catalog' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add a topic' })).toBeInTheDocument(); + + rerender(); + expect(screen.queryByRole('button', { name: 'Add a topic to this catalog' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Add a topic' })).toBeNull(); + }); + + test('namespaces past the page limit paginate with a load-more row', async () => { + const tables = Array.from({ length: 25 }, (_, i) => tableRef(`t${String(i).padStart(2, '0')}`)); + const big = catalog({ namespaces: [{ id: 'rp.public', name: 'public', tables }] }); + render(); + + expect(screen.getAllByRole('treeitem', { name: PAGED_TABLE_RE })).toHaveLength(20); + await userEvent.click(screen.getByRole('button', { name: LOAD_MORE_RE })); + expect(screen.getAllByRole('treeitem', { name: PAGED_TABLE_RE })).toHaveLength(25); + }); + + test('arrow keys move focus through visible rows; left collapses', async () => { + render(); + const catalogRow = screen.getByRole('treeitem', { name: REDPANDA_CATALOG_RE }); + const namespaceRow = screen.getByRole('treeitem', { name: PUBLIC_RE }); + + catalogRow.focus(); + await userEvent.keyboard('{ArrowDown}'); + expect(namespaceRow).toHaveFocus(); + + await userEvent.keyboard('{ArrowUp}'); + expect(catalogRow).toHaveFocus(); + + await userEvent.keyboard('{ArrowLeft}'); + expect(catalogRow).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('treeitem', { name: PUBLIC_RE })).toBeNull(); + }); +}); diff --git a/frontend/src/components/pages/sql/catalog-tree.tsx b/frontend/src/components/pages/sql/catalog-tree.tsx new file mode 100644 index 0000000000..0ea7eb5698 --- /dev/null +++ b/frontend/src/components/pages/sql/catalog-tree.tsx @@ -0,0 +1,603 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Input, InputStart } from 'components/redpanda-ui/components/input'; +import { Spinner } from 'components/redpanda-ui/components/spinner'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { + Box, + ChevronDown, + ChevronRight, + GitBranch, + Layers, + Lock, + Table as LucideTable, + Play, + Plus, + Search, +} from 'lucide-react'; +import type { Column } from 'protogen/redpanda/api/dataplane/v1alpha3/sql_pb'; +import { + createContext, + type FocusEvent, + Fragment, + type KeyboardEvent, + type ReactNode, + useContext, + useState, +} from 'react'; +import { useDescribeTableQuery, useListTablesQuery, useTopicIcebergQuery } from 'react-query/api/sql'; + +import type { Catalog, CatalogEngine, Namespace, SqlRole, TableRef } from './sql-types'; + +export type CatalogTreeProps = { + /** Catalogs to render. Empty array while loading. */ + catalogs: Catalog[]; + /** Effective role of the caller. Drives admin-only affordances (Add a topic). */ + sqlRole: SqlRole; + /** True while the initial ListCatalogs fetch is in flight. */ + isLoading?: boolean; + /** id of the table whose query tab is currently active, if any. */ + activeTableId?: string | null; + /** Open `SELECT * FROM . LIMIT 100;` in a new editor tab. */ + onQueryTable: (catalog: Catalog, table: TableRef) => void; + /** Admin entry point for the add-topic wizard (scoped to the Redpanda catalog). */ + onAddTable?: () => void; +}; + +// Promote search past this many tables in a namespace. +const CAT_LIMIT = 20; + +// Shared row layout: flex, gap, full-width, left-aligned, padded, rounded, with a +// subtle hover background. Used by namespace rows and the "Add a topic" row. +const ROW_BASE = + 'flex w-full cursor-pointer items-center gap-1.5 rounded border-0 bg-transparent px-2 py-1.5 text-left text-sm text-strong hover:bg-accent-subtle'; + +// Truncating label that fills the remaining row width. +const LABEL = 'flex-1 overflow-hidden text-left text-ellipsis whitespace-nowrap'; + +// Tree-wide state and callbacks, provided once by CatalogTree so the node +// components stay lean instead of threading a dozen props per level. +type CatalogTreeContextValue = { + role: SqlRole; + query: string; + activeTableId?: string | null; + /** Expand state per node id. Undefined => default open (`!== false`). */ + open: Record; + /** Expand state per table id. Undefined => closed. */ + openTables: Record; + /** Pagination window per namespace id. */ + shown: Record; + toggle: (id: string) => void; + toggleTable: (id: string) => void; + loadMore: (namespaceId: string) => void; + onQueryTable: (catalog: Catalog, table: TableRef) => void; + onAddTable?: () => void; + /** tabIndex for a treeitem so the tree is a single tab stop (roving). */ + rowTabIndex: (id: string) => 0 | -1; +}; + +const CatalogTreeContext = createContext(null); + +function useCatalogTree(): CatalogTreeContextValue { + const ctx = useContext(CatalogTreeContext); + if (!ctx) { + throw new Error('useCatalogTree must be used within CatalogTree'); + } + return ctx; +} + +function engineMark(engine: CatalogEngine) { + if (engine === 'redpanda') { + return ( + + + + ); + } + return ( + + + + ); +} + +function LoadingRow({ label }: { label: string }) { + return ( +
+ + + {label} + +
+ ); +} + +function EmptyNote({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +// Merge tables seeded on the namespace with tables fetched from ListTables for +// the catalog. Fetched tables win; seeded names fill in before the fetch lands. +function tablesForNamespace(namespace: Namespace, fetched: TableRef[]): TableRef[] { + const byId = new Map(); + for (const t of namespace.tables) { + byId.set(t.id, t); + } + for (const t of fetched) { + if (t.namespaceName === namespace.name) { + byId.set(t.id, t); + } + } + return [...byId.values()]; +} + +// Roving keyboard navigation for the tree, per the WAI-ARIA tree pattern: +// Up/Down move between visible rows, Home/End jump, Right expands (or moves +// into) a node, Left collapses. Operates on the rendered treeitem buttons so +// it always matches what's visible. +function handleTreeKeyDown(e: KeyboardEvent) { + const handled = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Home', 'End']; + if (!handled.includes(e.key)) { + return; + } + const current = (e.target as HTMLElement).closest('[role="treeitem"]'); + if (!current) { + return; + } + const rows = Array.from(e.currentTarget.querySelectorAll('[role="treeitem"]:not(:disabled)')); + const idx = rows.indexOf(current); + if (idx === -1) { + return; + } + e.preventDefault(); + + const focusRow = (i: number) => rows[i]?.focus(); + const expanded = current.getAttribute('aria-expanded'); + + switch (e.key) { + case 'ArrowDown': + focusRow(idx + 1); + break; + case 'ArrowUp': + focusRow(idx - 1); + break; + case 'Home': + focusRow(0); + break; + case 'End': + focusRow(rows.length - 1); + break; + case 'ArrowRight': + if (expanded === 'false') { + current.click(); + } else { + focusRow(idx + 1); + } + break; + case 'ArrowLeft': + if (expanded === 'true') { + current.click(); + } + break; + default: + break; + } +} + +type ColumnListProps = { + catalogName: string; + tableName: string; +}; + +// Recursively renders column fields. Struct (json) fields get an expand chevron +// that reveals their nested fields, indented one level per depth. +function FieldRows({ + fields, + depth, + pathPrefix, + open, + toggle, +}: { + fields: Column[]; + depth: number; + pathPrefix: string; + open: Record; + toggle: (path: string) => void; +}) { + return ( + <> + {fields.map((field) => { + const path = `${pathPrefix}/${field.name}`; + const nested = field.fields ?? []; + const hasNested = nested.length > 0; + const isOpen = Boolean(open[path]); + const FieldChevron = isOpen ? ChevronDown : ChevronRight; + const rowClass = 'flex w-full items-center gap-1.5 px-2 py-0.75 text-foreground text-xs'; + const indent = { paddingLeft: `${depth * 12}px` }; + const body = ( + <> + {hasNested ? ( + + ) : ( +