From 72acb1a25184406743cc50c980659403aaa30bf2 Mon Sep 17 00:00:00 2001 From: cevheri Date: Sat, 27 Jun 2026 00:12:15 +0300 Subject: [PATCH 1/7] docs(toolchain): adoption plan and editorconfig (phase 0) Add the 5-tool toolchain adoption plan (Biome formatter, oxlint, typescript-eslint+eslint, knip, attw) ported from libredb-database, and the .editorconfig that matches the Biome formatter settings. --- .editorconfig | 15 ++++ docs/TOOLCHAIN.md | 197 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 .editorconfig create mode 100644 docs/TOOLCHAIN.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..01ab3f8a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig (https://editorconfig.org) - matches the Biome formatter settings +# so editors agree before Biome ever runs. +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +# Markdown hard line breaks are two trailing spaces; do not strip them. +trim_trailing_whitespace = false diff --git a/docs/TOOLCHAIN.md b/docs/TOOLCHAIN.md new file mode 100644 index 00000000..ec9edaf7 --- /dev/null +++ b/docs/TOOLCHAIN.md @@ -0,0 +1,197 @@ +# LibreDB Studio Toolchain - 2026 Adoption Plan + +> Status: PLANNED (no code changes yet). This is a per-tool adoption plan for five tools, ported from the +> researched-then-adversarially-verified decision record in `libredb-database/docs/TOOLCHAIN.md` and adapted to +> Studio's reality: a Next.js 16 + React 19 + TSX application that ALSO ships as the dual-format npm package +> `@libredb/studio` (consumed by `libredb-platform`). The database record is the rationale source of truth; +> this document records only what changes for Studio and why. + +## Scope + +Five tools, deliberately a subset of the database gate (no size-limit, commitlint, changesets, secretlint, +license, etc.): + +| Tool | Decision | Reason for Studio | +| --- | --- | --- | +| `@biomejs/biome` (format-only) | ADOPT | No formatter today - the one unambiguous gap. Same as database. | +| `oxlint` | ADOPT | Fast Rust syntactic linter; a sub-second fail-fast layer in front of ESLint. | +| `typescript-eslint` + `eslint` | KEEP (Strategy A) | `eslint-config-next` stays as-is and keeps owning React/Next/hooks rules; oxlint is layered on top. | +| `knip` | KEEP | Already wired into the CI gate. Verify, do not rebuild. | +| `@arethetypeswrong/cli` (attw) | ADOPT | Higher value here than in database: 5 subpath exports x dual CJS+ESM x both `.d.ts` and `.d.mts`. | + +## How Studio differs from database (and why the configs change) + +| Dimension | libredb-database | libredb-studio | +| --- | --- | --- | +| Type | Pure ESM TS library, synchronous core, ZERO runtime deps | Next.js 16 + React 19 + TSX (256 ts/tsx, 121 tsx), async-heavy (API routes, DB drivers) | +| Build | `tsc` + isolatedDeclarations, single entry | `tsup`, dual ESM+CJS, 5 subpath exports (`.`, `/providers`, `/types`, `/components`, `/workspace`) | +| Linting today | oxlint + type-aware-only ESLint | `eslint-config-next` (core-web-vitals + typescript + react-hooks) | +| Formatter today | Biome (present) | None (no prettier) | +| knip | present | present (in CI gate) | +| Tests | single `bun test` | process-isolated (`run-core.sh` / `run-components.sh`) to avoid `mock.module()` cross-contamination | + +Consequences: + +- **attw uses the DEFAULT profile, NOT `--profile esm-only`** - the package is intentionally dual CJS+ESM, + so attw must verify CJS resolution too. +- **ESLint is NOT reduced to type-aware-only** (the database move). `eslint-config-next` is the canonical + Next linter and Studio ships as a Next app; reducing it would drop curated Next/React coverage. +- **CSS is excluded from the Biome formatter** - the platform-integration rules + (`.claude/rules/platform-integration.md`) warn that `globals.css` can break silently when embedded in + platform. Keep CSS out of Biome's scope as a safe start. +- **attw needs `build:lib` (tsup), not `next build`** - do not mix the two in CI. + +## Why lineWidth = 120 (carried over from database) + +Not the Biome/Prettier default of 80. The 80 default is terminal/prose-era inertia; code is scanned, not +read like prose. Reformatting the database repo from 80 to 120 was a net -245 lines because width-80 +over-wrapped signatures and calls that fit cleanly on one line at 120. 120 is the JetBrains default and the +modern wide-but-still-review-friendly choice (140 strains side-by-side review). Biome's JS formatter is +configured: 2-space indent, double quotes, semicolons always. + +## Why Biome is formatter-only + +Biome's type-aware lint rules use a re-implemented inference engine its own authors say "cannot guarantee +full coverage or alignment with TS." Linting stays with oxlint (syntactic) + ESLint (`eslint-config-next`, +including the type-aware Next rules). Biome's `linter` and `assist` are disabled. + +## Phase 0 - Prep (shared) + +- Add `.editorconfig` (identical to database: 2-space, LF, UTF-8, final newline, trim trailing whitespace; + `*.md` exempted from trim since hard breaks use trailing spaces) so editors agree before Biome runs. +- Branch `feat/toolchain` off `main` (trunk-based). + +## Phase 1 - Biome (formatter only) + +Lowest-risk, path-clearing step. One-shot full-repo reformat. + +`biome.json`: + +```jsonc +{ + "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", + "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 120 }, + "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, + "css": { "formatter": { "enabled": false } }, + "linter": { "enabled": false }, + "assist": { "enabled": false } +} +``` + +Scripts: + +```jsonc +"format": "biome format src tests *.ts *.mjs", +"format:fix": "biome format --write src tests *.ts *.mjs" +``` + +Notes: + +- Style is double-quote + semicolons: consistent with database and with the existing `eslint.config.mjs`. + The repo is inconsistent today (`tsup.config.ts` is single-quote / no-semi); the reformat unifies it. +- `css.formatter.enabled: false` keeps `globals.css` and other CSS untouched (platform-integration risk). +- Deliverable: a single `chore(format): adopt Biome formatter` PR (~256 files). Afterwards run `build:lib` + and verify BOTH modes (standalone + embedded), per the repo's UI-change rule. Coordinate timing to avoid + clashing with open PRs. + +## Phase 2 - Oxlint + +Sub-second syntactic linter; a fail-fast layer in front of ESLint. + +`.oxlintrc.json`: + +```jsonc +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + "plugins": ["typescript", "oxc", "react", "react-hooks", "jsx-a11y", "nextjs", "import"], + "categories": { "correctness": "error", "suspicious": "error", "perf": "warn", "pedantic": "off", "style": "off" }, + "rules": {}, + "ignorePatterns": ["dist/**", ".next/**", "out/**", "build/**", "coverage/**", "node_modules/**", "next-env.d.ts"] +} +``` + +Notes: + +- The `unicorn` plugin is NOT added (taste noise, same call as database). +- The first run on a React codebase may surface noise; reaching green may require turning off one or two + rules. Any disabled rule gets a justifying comment (as `no-shadow` / `approx-constant` do in database). +- Scripts: add `"lint:oxc": "oxlint"`, and make `lint` run oxlint first: `"lint": "oxlint && eslint ."`. + +## Phase 3 - typescript-eslint + ESLint (Strategy A: keep Next, layer oxlint) + +`eslint-config-next` stays exactly as it is in `eslint.config.mjs` (it owns core-web-vitals, the typescript +config, and the react-hooks rules). Oxlint is layered on top for fast syntactic feedback; ESLint remains the +curated Next/React safety net. + +Optional follow-up (recommended, can be a later phase): add a narrow type-aware layer scoped to the +async-heavy code (`src/app/api/**`, `src/lib/db/**`) - `@typescript-eslint/no-floating-promises`, +`no-misused-promises`, `await-thenable`. These pay off more in Studio than in database (synchronous core). +Cost: enabling `parserOptions.projectService: true` makes that scope's lint slower. + +Rejected for Studio: the database-style reduction of ESLint to type-aware-only with React/Next rules moved +to oxlint. For a shipping Next app the risk of losing `eslint-config-next`'s curated coverage outweighs the +single-linter simplicity. + +## Phase 4 - attw (@arethetypeswrong/cli) + +High value here: the package has 5 subpath exports, dual CJS+ESM, and emits both `.d.ts` and `.d.mts` - the +exact surface where types-resolution and CJS/ESM-masquerading bugs hide. + +```jsonc +// scripts +"attw": "rm -rf .attw && bun pm pack --quiet --destination .attw && attw .attw/*.tgz", +"prepublishOnly": "tsup && bun run attw" +``` + +Notes: + +- DEFAULT profile (no `--profile esm-only`): the package is intentionally dual-format, so CJS resolution + must be checked. +- `rm -rf .attw` runs FIRST (not trailing): a trailing `&& rm` would mask attw's exit code, and pre-cleaning + drops a stale tarball from a previous version bump. +- attw needs `dist/` from `build:lib` (tsup), so `prepublishOnly` runs `tsup` before `attw`. In CI use + `build:lib`, never `next build`, before attw. +- Git-ignore `.attw/` and `*.tgz` (packaging scratch). +- Expectation: the `exports` map already orders `types` first within each `import`/`require` condition, so + attw is likely green - but verifying that across all 5 entries is exactly the point. + +## Phase 5 - knip (keep, verify) + +Each new tool (`biome`, `oxlint`, `attw`) gets a real package.json script, so knip resolves their binaries to +their packages and counts them as used - no `knip.json` change is expected (database's finding: scripts +suffice, even for `attw` whose binary name differs from `@arethetypeswrong/cli`). After adoption, run +`bun run knip`; if anything is flagged, add a single justified `ignoreBinaries` / `ignoreDependencies` entry. + +## CI and pre-commit integration + +- `.github/workflows/ci.yml`, the "Lint, Typecheck and Build" job: add two steps before the existing ESLint + step - Format check (`bun run format`) and oxlint (`bun run lint:oxc`). +- attw belongs in a packaging step/job that runs `bun run build:lib && bun run attw` (not `next build`). + Natural homes: `npm-publish.yml` and/or a small dedicated package-check job. +- Update `libredb-studio/CLAUDE.md`: the mandatory pre-commit four (lint, typecheck, test, build) becomes six + with `format` and `oxlint`. + +## Rollout order and per-phase gate + +1. Biome formatter + `.editorconfig` (one-shot reformat PR). +2. Oxlint (tune rules to green). +3. ESLint Strategy A wiring + optional type-aware layer. +4. attw + `.gitignore` + `prepublishOnly` + CI packaging step. +5. knip verification. + +Each phase must end green on the repo's checks - `bun run lint`, `bun run typecheck`, `bun run test`, +`bun run build` - PLUS `bun run build:lib` and a both-modes (standalone + embedded) verification for any +phase that can affect output. + +## Studio-specific risks + +1. Big-bang reformat diff churn - coordinate with open PRs / platform; one PR; verify both modes. +2. platform-integration rules - keep CSS out of Biome; verify the embedded mode after the reformat. +3. Oxlint React noise on the first run - expect minor rule tuning. +4. attw must use `build:lib`, not `next build`. +5. `mock.module()` test isolation is unaffected by these static tools. + +## Suggested package versions + +`@biomejs/biome@^2.5`, `oxlint@^1.71`, `@arethetypeswrong/cli@^0.18.4`. `eslint` / `eslint-config-next` / +`typescript-eslint` / `knip` stay at their current Studio versions. From fbf02fd4e6bc9777389d9e269e258ee9fe9d2725 Mon Sep 17 00:00:00 2001 From: cevheri Date: Sat, 27 Jun 2026 00:27:08 +0300 Subject: [PATCH 2/7] style(toolchain): adopt Biome formatter, reformat repo (phase 1) Add @biomejs/biome (formatter only; linter and assist disabled), biome.json (lineWidth 120, 2-space, double quotes, semicolons; CSS and JSON formatting disabled), and format/format:fix scripts. Apply a one-shot reformat across src, tests, e2e, scripts (461 files). CSS left untouched to avoid platform-integration style breakage. lint/typecheck/build/build:lib/test all green. --- biome.json | 23 + bun.lock | 19 + e2e/admin-dashboard.spec.ts | 60 +- e2e/connection-management.spec.ts | 36 +- e2e/export.spec.ts | 20 +- e2e/login.spec.ts | 86 +- e2e/query-execution.spec.ts | 36 +- e2e/tab-management.spec.ts | 60 +- eslint.config.mjs | 8 +- next.config.ts | 12 +- package.json | 3 + playwright.config.ts | 30 +- scripts/merge-lcov.mjs | 5 +- src/app/admin/error.tsx | 27 +- src/app/admin/page.tsx | 4 +- src/app/api/admin/audit/route.ts | 36 +- src/app/api/admin/fleet-health/route.ts | 43 +- src/app/api/ai/autopilot/route.ts | 43 +- src/app/api/ai/chat/route.ts | 30 +- src/app/api/ai/describe-schema/route.ts | 29 +- src/app/api/ai/explain/route.ts | 25 +- src/app/api/ai/impact/route.ts | 24 +- src/app/api/ai/index-advisor/route.ts | 28 +- src/app/api/ai/nl2sql/route.ts | 32 +- src/app/api/ai/query-safety/route.ts | 22 +- src/app/api/auth/login/route.ts | 35 +- src/app/api/auth/logout/route.ts | 14 +- src/app/api/auth/me/route.ts | 4 +- src/app/api/auth/oidc/callback/route.ts | 57 +- src/app/api/auth/oidc/login/route.ts | 30 +- src/app/api/connections/managed/route.ts | 18 +- src/app/api/db/cancel/route.ts | 25 +- src/app/api/db/disconnect/route.ts | 22 +- src/app/api/db/health/route.ts | 23 +- src/app/api/db/maintenance/route.ts | 50 +- src/app/api/db/monitoring/route.ts | 41 +- src/app/api/db/multi-query/route.ts | 40 +- src/app/api/db/pool-stats/route.ts | 22 +- src/app/api/db/profile/route.ts | 70 +- src/app/api/db/provider-meta/route.ts | 29 +- src/app/api/db/query/route.ts | 32 +- src/app/api/db/schema-snapshot/route.ts | 20 +- src/app/api/db/schema/list/route.ts | 8 +- src/app/api/db/schema/relations/route.ts | 8 +- src/app/api/db/schema/route.ts | 29 +- src/app/api/db/test-connection/route.ts | 29 +- src/app/api/db/transaction/route.ts | 59 +- src/app/api/storage/[collection]/route.ts | 45 +- src/app/api/storage/config/route.ts | 8 +- src/app/api/storage/migrate/route.ts | 24 +- src/app/api/storage/route.ts | 17 +- src/app/error.tsx | 23 +- src/app/global-error.tsx | 48 +- src/app/login/login-form.tsx | 96 +- src/app/login/page.tsx | 6 +- src/app/monitoring/page.tsx | 4 +- src/app/not-found.tsx | 6 +- src/app/page.tsx | 2 +- src/components/AIAutopilotPanel.tsx | 131 +- src/components/CodeGenerator.tsx | 255 +-- src/components/CommandPalette.tsx | 45 +- src/components/ConnectionModal.tsx | 544 ++++--- src/components/CreateTableModal.tsx | 191 ++- src/components/DataCharts.tsx | 572 +++---- src/components/DataImportModal.tsx | 273 ++-- src/components/DataProfiler.tsx | 158 +- src/components/DatabaseDocs.tsx | 152 +- src/components/MaskingSettings.tsx | 209 ++- src/components/MobileNav.tsx | 32 +- src/components/NL2SQLPanel.tsx | 104 +- src/components/PivotTable.tsx | 125 +- src/components/QueryEditor.tsx | 1147 +++++++------- src/components/QueryHistory.tsx | 260 ++-- src/components/QuerySafetyDialog.tsx | 126 +- src/components/ResultsGrid.tsx | 202 ++- src/components/SaveQueryModal.tsx | 52 +- src/components/SavedQueries.tsx | 53 +- src/components/SchemaDiagram.tsx | 351 +++-- src/components/SchemaDiff.tsx | 328 ++-- src/components/SnapshotTimeline.tsx | 59 +- src/components/Studio.tsx | 297 ++-- src/components/TestDataGenerator.tsx | 227 ++- src/components/VisualExplain.tsx | 405 ++--- src/components/admin/AdminDashboard.tsx | 54 +- src/components/admin/tabs/AuditTab.tsx | 198 +-- src/components/admin/tabs/MonitoringEmbed.tsx | 4 +- src/components/admin/tabs/OperationsTab.tsx | 339 ++-- src/components/admin/tabs/OverviewTab.tsx | 496 ++---- src/components/admin/tabs/SecurityTab.tsx | 105 +- src/components/community-section.tsx | 30 +- src/components/icons/db-icons.tsx | 98 +- src/components/libredb-logo.tsx | 4 +- .../monitoring/MonitoringDashboard.tsx | 125 +- .../monitoring/tabs/MetricChart.tsx | 25 +- .../monitoring/tabs/OverviewTab.tsx | 116 +- .../monitoring/tabs/PerformanceTab.tsx | 117 +- src/components/monitoring/tabs/PoolTab.tsx | 80 +- src/components/monitoring/tabs/QueriesTab.tsx | 98 +- .../monitoring/tabs/SessionsTab.tsx | 109 +- src/components/monitoring/tabs/StorageTab.tsx | 119 +- src/components/monitoring/tabs/TablesTab.tsx | 90 +- src/components/results-grid/ResultCard.tsx | 49 +- .../results-grid/RowDetailSheet.tsx | 82 +- src/components/results-grid/StatsBar.tsx | 62 +- src/components/results-grid/utils.ts | 24 +- src/components/schema-explorer/ColumnList.tsx | 19 +- .../schema-explorer/SchemaExplorer.tsx | 51 +- src/components/schema-explorer/TableItem.tsx | 88 +- src/components/schema-explorer/index.ts | 2 +- src/components/sidebar/ConnectionItem.tsx | 99 +- src/components/sidebar/ConnectionsList.tsx | 19 +- src/components/sidebar/Sidebar.tsx | 18 +- src/components/sidebar/index.ts | 4 +- src/components/studio/BottomPanel.tsx | 243 ++- src/components/studio/QueryToolbar.tsx | 24 +- src/components/studio/StudioDesktopHeader.tsx | 74 +- src/components/studio/StudioMobileHeader.tsx | 140 +- src/components/studio/StudioTabBar.tsx | 44 +- src/components/studio/index.ts | 10 +- src/components/ui/accordion.tsx | 31 +- src/components/ui/alert-dialog.tsx | 104 +- src/components/ui/alert.tsx | 43 +- src/components/ui/aspect-ratio.tsx | 12 +- src/components/ui/avatar.tsx | 47 +- src/components/ui/badge.tsx | 36 +- src/components/ui/breadcrumb.tsx | 53 +- src/components/ui/button-group.tsx | 36 +- src/components/ui/button.tsx | 32 +- src/components/ui/calendar.tsx | 141 +- src/components/ui/card.tsx | 64 +- src/components/ui/carousel.tsx | 165 +- src/components/ui/chart.tsx | 225 +-- src/components/ui/checkbox.tsx | 21 +- src/components/ui/collapsible.tsx | 34 +- src/components/ui/command.tsx | 119 +- src/components/ui/context-menu.tsx | 127 +- src/components/ui/dialog.tsx | 74 +- src/components/ui/drawer.tsx | 79 +- src/components/ui/dropdown-menu.tsx | 120 +- src/components/ui/empty.tsx | 49 +- src/components/ui/field.tsx | 132 +- src/components/ui/form.tsx | 113 +- src/components/ui/hover-card.tsx | 28 +- src/components/ui/input-group.tsx | 101 +- src/components/ui/input-otp.tsx | 41 +- src/components/ui/input.tsx | 10 +- src/components/ui/item.tsx | 95 +- src/components/ui/kbd.tsx | 16 +- src/components/ui/label.tsx | 19 +- src/components/ui/menubar.tsx | 134 +- src/components/ui/navigation-menu.tsx | 84 +- src/components/ui/pagination.tsx | 67 +- src/components/ui/popover.tsx | 32 +- src/components/ui/progress.tsx | 23 +- src/components/ui/radio-group.tsx | 34 +- src/components/ui/resizable.tsx | 36 +- src/components/ui/scroll-area.tsx | 34 +- src/components/ui/select.tsx | 85 +- src/components/ui/separator.tsx | 14 +- src/components/ui/sheet.tsx | 86 +- src/components/ui/sidebar.tsx | 321 ++-- src/components/ui/skeleton.tsx | 12 +- src/components/ui/slider.tsx | 31 +- src/components/ui/sonner.tsx | 22 +- src/components/ui/spinner.tsx | 15 +- src/components/ui/switch.tsx | 21 +- src/components/ui/table.tsx | 81 +- src/components/ui/tabs.tsx | 54 +- src/components/ui/textarea.tsx | 10 +- src/components/ui/toggle-group.tsx | 37 +- src/components/ui/toggle.tsx | 30 +- src/components/ui/tooltip.tsx | 39 +- src/exports/components.ts | 26 +- src/exports/index.js | 2 +- src/exports/index.ts | 6 +- src/exports/providers.ts | 10 +- src/exports/types.ts | 4 +- src/exports/workspace.ts | 6 +- src/hooks/use-ai-chat.ts | 234 +-- src/hooks/use-all-connections.ts | 14 +- src/hooks/use-auth.ts | 18 +- src/hooks/use-connection-form.ts | 354 +++-- src/hooks/use-connection-manager.ts | 132 +- src/hooks/use-connection-payload.ts | 2 +- src/hooks/use-inline-editing.ts | 45 +- src/hooks/use-mobile.ts | 22 +- src/hooks/use-monitoring-data.ts | 171 ++- src/hooks/use-provider-metadata.ts | 18 +- src/hooks/use-query-execution.ts | 711 +++++---- src/hooks/use-storage-sync.ts | 112 +- src/hooks/use-tab-manager.ts | 218 +-- src/hooks/use-transaction-control.ts | 76 +- src/instrumentation.ts | 10 +- src/lib/api/error-codes.ts | 32 +- src/lib/api/errors.ts | 121 +- src/lib/api/schema-route.ts | 27 +- src/lib/audit.ts | 22 +- src/lib/auth.ts | 46 +- src/lib/connection-string-parser.ts | 84 +- src/lib/data-masking.ts | 259 ++-- src/lib/db-ui-config.ts | 92 +- src/lib/db/base-provider.ts | 75 +- src/lib/db/errors.ts | 118 +- src/lib/db/factory.ts | 83 +- src/lib/db/index.ts | 6 +- src/lib/db/providers/document/mongodb.ts | 327 ++-- src/lib/db/providers/embedded/libredb.ts | 231 +-- src/lib/db/providers/keyvalue/redis.ts | 236 +-- src/lib/db/providers/sql/mssql.ts | 244 +-- src/lib/db/providers/sql/mysql.ts | 357 +++-- src/lib/db/providers/sql/oracle.ts | 350 +++-- src/lib/db/providers/sql/postgres.ts | 357 ++--- src/lib/db/providers/sql/sql-base.ts | 88 +- src/lib/db/providers/sql/sqlite.ts | 171 +-- src/lib/db/types.ts | 16 +- src/lib/db/utils/pool-manager.ts | 62 +- src/lib/db/utils/query-limiter.ts | 49 +- src/lib/editor/libredb-language.ts | 22 +- src/lib/editor/mongodb-completions.ts | 175 ++- src/lib/editor/sql-completions.ts | 181 ++- src/lib/llm/base-provider.ts | 34 +- src/lib/llm/factory.ts | 24 +- src/lib/llm/index.ts | 4 +- src/lib/llm/providers/custom.ts | 62 +- src/lib/llm/providers/gemini.ts | 55 +- src/lib/llm/providers/ollama.ts | 56 +- src/lib/llm/providers/openai.ts | 47 +- src/lib/llm/types.ts | 20 +- src/lib/llm/utils/config.ts | 74 +- src/lib/llm/utils/retry.ts | 17 +- src/lib/llm/utils/streaming.ts | 39 +- src/lib/logger.ts | 48 +- src/lib/monitoring-thresholds.ts | 46 +- src/lib/oidc.ts | 95 +- src/lib/query-generators.ts | 92 +- src/lib/schema-diff/diff-engine.ts | 78 +- src/lib/schema-diff/migration-generator.ts | 285 ++-- src/lib/schema-diff/types.ts | 2 +- src/lib/seed/config-loader.ts | 26 +- src/lib/seed/connection-filter.ts | 16 +- src/lib/seed/credential-resolver.ts | 22 +- src/lib/seed/index.ts | 34 +- src/lib/seed/libredb-sample.ts | 44 +- src/lib/seed/resolve-connection.ts | 27 +- src/lib/seed/types.ts | 62 +- src/lib/sql/alias-extractor.ts | 161 +- src/lib/sql/index.ts | 4 +- src/lib/sql/statement-splitter.ts | 30 +- src/lib/sql/types.ts | 2 +- src/lib/ssh/tunnel.ts | 58 +- src/lib/storage/factory.ts | 22 +- src/lib/storage/index.ts | 6 +- src/lib/storage/local-storage.ts | 12 +- src/lib/storage/providers/postgres.ts | 108 +- src/lib/storage/providers/sqlite.ts | 47 +- src/lib/storage/storage-facade.ts | 126 +- src/lib/storage/types.ts | 36 +- src/lib/time-series-buffer.ts | 2 +- src/lib/types.ts | 48 +- src/lib/utils.ts | 6 +- src/proxy.ts | 44 +- src/types/assets.d.ts | 2 +- src/types/db-drivers.d.ts | 4 +- src/types/html2canvas.d.ts | 3 +- src/workspace/StudioWorkspace.tsx | 325 ++-- src/workspace/hooks/use-connection-adapter.ts | 23 +- src/workspace/hooks/use-query-adapter.ts | 522 ++++--- src/workspace/types.ts | 16 +- tests/api/admin/audit.test.ts | 128 +- tests/api/admin/fleet-health.test.ts | 102 +- tests/api/ai/autopilot.test.ts | 109 +- tests/api/ai/chat.test.ts | 183 ++- tests/api/ai/describe-schema.test.ts | 191 ++- tests/api/ai/explain.test.ts | 137 +- tests/api/ai/impact.test.ts | 109 +- tests/api/ai/index-advisor.test.ts | 103 +- tests/api/ai/nl2sql.test.ts | 135 +- tests/api/ai/query-safety.test.ts | 129 +- tests/api/auth/login.test.ts | 116 +- tests/api/auth/logout.test.ts | 64 +- tests/api/auth/me.test.ts | 36 +- tests/api/auth/oidc-callback.test.ts | 147 +- tests/api/auth/oidc-login.test.ts | 80 +- tests/api/db/cancel.test.ts | 101 +- tests/api/db/disconnect.test.ts | 50 +- tests/api/db/health.test.ts | 121 +- tests/api/db/maintenance.test.ts | 159 +- tests/api/db/monitoring.test.ts | 113 +- tests/api/db/multi-query.test.ts | 149 +- tests/api/db/pool-stats.test.ts | 84 +- tests/api/db/profile.test.ts | 279 ++-- tests/api/db/provider-meta.test.ts | 111 +- tests/api/db/query.test.ts | 177 +-- tests/api/db/schema-list.test.ts | 123 +- tests/api/db/schema-relations.test.ts | 113 +- tests/api/db/schema-snapshot.test.ts | 99 +- tests/api/db/schema.test.ts | 111 +- tests/api/db/test-connection.test.ts | 107 +- tests/api/db/transaction.test.ts | 173 ++- tests/api/proxy.test.ts | 122 +- tests/api/seed/managed-route.test.ts | 74 +- tests/api/storage/config.test.ts | 22 +- tests/api/storage/storage-routes.test.ts | 232 +-- tests/components/AIAutopilotPanel.test.tsx | 516 ++++--- tests/components/AdminPage.test.tsx | 32 +- tests/components/CodeGenerator.test.tsx | 186 +-- tests/components/CommandPalette.test.tsx | 285 ++-- tests/components/CommunitySection.test.tsx | 74 +- .../ConnectionModal.mobile.test.tsx | 220 +-- tests/components/ConnectionModal.test.tsx | 368 ++--- tests/components/CreateTableModal.test.tsx | 366 +++-- tests/components/DataCharts.test.tsx | 535 ++++--- tests/components/DataImportModal.test.tsx | 520 +++---- tests/components/DataProfiler.test.tsx | 356 +++-- tests/components/DatabaseDocs.test.tsx | 285 ++-- tests/components/LoginPage.test.tsx | 124 +- tests/components/LoginPageOIDC.test.tsx | 64 +- tests/components/MaskingSettings.test.tsx | 380 +++-- tests/components/MobileNav.test.tsx | 44 +- tests/components/MonitoringPage.test.tsx | 33 +- tests/components/NL2SQLPanel.test.tsx | 310 ++-- tests/components/Page.test.tsx | 32 +- tests/components/PivotTable.test.tsx | 361 ++--- tests/components/QueryEditor.test.tsx | 1367 ++++++++++------- tests/components/QueryHistory.test.tsx | 318 ++-- tests/components/QuerySafetyDialog.test.tsx | 415 +++-- tests/components/ResultsGrid.test.tsx | 594 +++---- tests/components/RootLayout.test.tsx | 80 +- tests/components/SaveQueryModal.test.tsx | 40 +- tests/components/SavedQueries.test.tsx | 51 +- tests/components/SchemaDiagram.test.tsx | 535 +++---- tests/components/SchemaDiff.test.tsx | 686 +++++---- tests/components/SnapshotTimeline.test.tsx | 262 +++- tests/components/Studio.test.tsx | 551 +++---- tests/components/TestDataGenerator.test.tsx | 376 +++-- tests/components/VisualExplain.test.tsx | 593 +++---- .../components/admin/AdminDashboard.test.tsx | 112 +- tests/components/admin/AuditTab.test.tsx | 136 +- .../components/admin/MonitoringEmbed.test.tsx | 36 +- tests/components/admin/OperationsTab.test.tsx | 606 +++++--- tests/components/admin/OverviewTab.test.tsx | 164 +- tests/components/admin/SecurityTab.test.tsx | 214 ++- .../monitoring/MetricChart.test.tsx | 106 +- .../monitoring/MonitoringDashboard.test.tsx | 134 +- .../monitoring/OverviewTab.test.tsx | 75 +- .../monitoring/PerformanceTab.test.tsx | 82 +- tests/components/monitoring/PoolTab.test.tsx | 61 +- .../components/monitoring/QueriesTab.test.tsx | 74 +- .../monitoring/SessionsTab.test.tsx | 121 +- .../components/monitoring/StorageTab.test.tsx | 94 +- .../components/monitoring/TablesTab.test.tsx | 92 +- .../results-grid/ResultCard.test.tsx | 91 +- .../results-grid/RowDetailSheet.test.tsx | 216 ++- .../components/results-grid/StatsBar.test.tsx | 91 +- .../schema-explorer/ColumnList.test.tsx | 155 +- .../schema-explorer/SchemaExplorer.test.tsx | 256 +-- .../schema-explorer/TableItem.test.tsx | 468 +++--- .../sidebar/ConnectionItem.test.tsx | 118 +- .../sidebar/ConnectionsList.test.tsx | 107 +- tests/components/sidebar/Sidebar.test.tsx | 117 +- tests/components/studio/BottomPanel.test.tsx | 323 ++-- tests/components/studio/QueryToolbar.test.tsx | 192 +-- .../studio/StudioDesktopHeader.test.tsx | 345 ++--- .../studio/StudioMobileHeader.test.tsx | 178 ++- tests/components/studio/StudioTabBar.test.tsx | 201 +-- tests/fixtures/connections.ts | 118 +- tests/fixtures/masking-configs.ts | 54 +- tests/fixtures/query-results.ts | 32 +- tests/fixtures/schemas.ts | 66 +- tests/helpers/mock-fetch.ts | 20 +- tests/helpers/mock-monaco.ts | 86 +- tests/helpers/mock-navigation.ts | 6 +- tests/helpers/mock-next.ts | 12 +- tests/helpers/mock-provider.ts | 114 +- tests/helpers/mock-sonner.ts | 8 +- tests/helpers/render-with-providers.tsx | 9 +- tests/hooks/use-ai-chat.test.ts | 268 ++-- tests/hooks/use-auth.test.ts | 165 +- tests/hooks/use-connection-adapter.test.ts | 202 +-- tests/hooks/use-connection-form.test.ts | 514 +++---- tests/hooks/use-connection-manager.test.ts | 269 ++-- tests/hooks/use-inline-editing.test.ts | 163 +- tests/hooks/use-mobile.test.ts | 40 +- tests/hooks/use-monitoring-data.test.ts | 276 ++-- tests/hooks/use-provider-metadata.test.ts | 130 +- tests/hooks/use-query-adapter.test.ts | 101 +- tests/hooks/use-query-execution.test.ts | 545 ++++--- tests/hooks/use-tab-manager.test.ts | 436 +++--- tests/hooks/use-toast.test.ts | 44 +- tests/hooks/use-transaction-control.test.ts | 132 +- tests/integration/db/libredb-provider.test.ts | 268 ++-- tests/integration/db/mongodb-provider.test.ts | 322 ++-- tests/integration/db/mssql-provider.test.ts | 524 ++++--- tests/integration/db/mysql-provider.test.ts | 620 ++++---- tests/integration/db/oracle-provider.test.ts | 567 ++++--- .../integration/db/postgres-provider.test.ts | 1053 ++++++------- tests/integration/db/redis-provider.test.ts | 317 ++-- tests/integration/db/sqlite-provider.test.ts | 240 +-- .../seed/libredb-sample-managed.test.ts | 44 +- tests/integration/seed/libredb-sample.test.ts | 57 +- tests/integration/seed/seed-pipeline.test.ts | 108 +- .../storage/dismissed-seeds.test.ts | 36 +- tests/isolated/factory-singleton.test.ts | 60 +- tests/isolated/use-storage-sync.test.ts | 204 +-- tests/setup-dom.ts | 93 +- tests/setup.ts | 40 +- tests/unit/api-errors.test.ts | 130 +- tests/unit/code-generator-functions.test.ts | 326 ++-- tests/unit/components/column-list.test.tsx | 162 +- .../components/data-import-modal.test.tsx | 410 ++--- .../components/studio-mobile-header.test.tsx | 314 ++-- tests/unit/data-charts-functions.test.ts | 393 +++-- tests/unit/data-import-functions.test.ts | 294 ++-- tests/unit/data-import-utils.test.ts | 142 +- tests/unit/db/base-provider.test.ts | 214 ++- tests/unit/db/errors.test.ts | 346 ++--- tests/unit/db/factory.test.ts | 263 ++-- tests/unit/db/pool-manager.test.ts | 335 ++-- tests/unit/db/query-limiter.test.ts | 291 ++-- tests/unit/db/sql-base.test.ts | 449 +++--- tests/unit/lib/api/error-codes.test.ts | 48 +- tests/unit/lib/audit.test.ts | 296 ++-- tests/unit/lib/auth.test.ts | 80 +- .../unit/lib/connection-string-parser.test.ts | 449 +++--- tests/unit/lib/data-masking.test.ts | 506 +++--- tests/unit/lib/db-icons.test.tsx | 34 +- tests/unit/lib/monitoring-thresholds.test.ts | 104 +- tests/unit/lib/oidc.test.ts | 401 +++-- tests/unit/lib/query-generators.test.ts | 276 ++-- tests/unit/lib/storage.test.ts | 228 ++- tests/unit/lib/storage/factory.test.ts | 58 +- tests/unit/lib/storage/local-storage.test.ts | 94 +- .../lib/storage/providers/postgres.test.ts | 169 +- .../unit/lib/storage/providers/sqlite.test.ts | 119 +- .../storage/storage-facade-extended.test.ts | 253 +-- tests/unit/lib/storage/storage-facade.test.ts | 120 +- tests/unit/lib/time-series-buffer.test.ts | 78 +- tests/unit/lib/utils.test.ts | 26 +- tests/unit/llm/base-provider.test.ts | 136 +- tests/unit/llm/config.test.ts | 250 +-- tests/unit/llm/custom-provider.test.ts | 161 +- tests/unit/llm/gemini-provider.test.ts | 129 +- tests/unit/llm/llm-factory.test.ts | 92 +- tests/unit/llm/ollama-provider.test.ts | 146 +- tests/unit/llm/openai-provider.test.ts | 186 +-- tests/unit/llm/retry.test.ts | 58 +- tests/unit/llm/streaming.test.ts | 210 ++- tests/unit/logger.test.ts | 182 +-- tests/unit/mongodb-completions.test.ts | 230 +-- tests/unit/pivot-table-functions.test.ts | 78 +- tests/unit/query-cancelled-error.test.ts | 70 +- tests/unit/schema-diff/diff-engine.test.ts | 264 ++-- .../schema-diff/migration-generator.test.ts | 717 +++++---- tests/unit/seed/config-loader.test.ts | 48 +- tests/unit/seed/connection-filter.test.ts | 108 +- tests/unit/seed/credential-resolver.test.ts | 80 +- tests/unit/seed/index.test.ts | 67 +- tests/unit/seed/resolve-connection.test.ts | 68 +- tests/unit/seed/types.test.ts | 100 +- tests/unit/sql-completions.test.ts | 278 ++-- tests/unit/sql/alias-extractor.test.ts | 436 +++--- tests/unit/sql/statement-splitter.test.ts | 244 +-- tests/unit/ssh-tunnel.test.ts | 298 ++-- tsup.config.ts | 100 +- 464 files changed, 32894 insertions(+), 31475 deletions(-) create mode 100644 biome.json diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..48e5d79b --- /dev/null +++ b/biome.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": ["src/**", "tests/**", "e2e/**", "scripts/**", "*.ts", "*.mts", "*.mjs"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always" + } + }, + "css": { "formatter": { "enabled": false } }, + "json": { "formatter": { "enabled": false } }, + "linter": { "enabled": false }, + "assist": { "enabled": false } +} diff --git a/bun.lock b/bun.lock index 47d4bb5c..a70962fe 100644 --- a/bun.lock +++ b/bun.lock @@ -74,6 +74,7 @@ "zod": "^4.1.12", }, "devDependencies": { + "@biomejs/biome": "^2.5", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@testing-library/react": "^16.3.2", @@ -170,6 +171,24 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@biomejs/biome": ["@biomejs/biome@2.5.1", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.1", "@biomejs/cli-darwin-x64": "2.5.1", "@biomejs/cli-linux-arm64": "2.5.1", "@biomejs/cli-linux-arm64-musl": "2.5.1", "@biomejs/cli-linux-x64": "2.5.1", "@biomejs/cli-linux-x64-musl": "2.5.1", "@biomejs/cli-win32-arm64": "2.5.1", "@biomejs/cli-win32-x64": "2.5.1" }, "bin": { "biome": "bin/biome" } }, "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], diff --git a/e2e/admin-dashboard.spec.ts b/e2e/admin-dashboard.spec.ts index 0e848a64..1cdcd961 100644 --- a/e2e/admin-dashboard.spec.ts +++ b/e2e/admin-dashboard.spec.ts @@ -1,67 +1,67 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from "@playwright/test"; -test.describe('Admin Dashboard', () => { +test.describe("Admin Dashboard", () => { test.beforeEach(async ({ page }) => { // Login as admin - await page.goto('/login'); - await page.locator('input[type="email"]').fill('admin@libredb.org'); - await page.locator('input[type="password"]').fill('test-admin'); - await page.getByRole('button', { name: /sign in/i }).click(); - await page.waitForURL('**/admin**'); + await page.goto("/login"); + await page.locator('input[type="email"]').fill("admin@libredb.org"); + await page.locator('input[type="password"]').fill("test-admin"); + await page.getByRole("button", { name: /sign in/i }).click(); + await page.waitForURL("**/admin**"); }); - test('admin dashboard loads', async ({ page }) => { - await expect(page.locator('text=Admin Dashboard')).toBeVisible({ timeout: 10000 }); + test("admin dashboard loads", async ({ page }) => { + await expect(page.locator("text=Admin Dashboard")).toBeVisible({ timeout: 10000 }); }); - test('shows 5 tab triggers', async ({ page }) => { - await expect(page.getByRole('tab', { name: /Overview/i })).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('tab', { name: /Operations/i })).toBeVisible(); - await expect(page.getByRole('tab', { name: /Monitoring/i })).toBeVisible(); - await expect(page.getByRole('tab', { name: /Security/i })).toBeVisible(); - await expect(page.getByRole('tab', { name: /Audit/i })).toBeVisible(); + test("shows 5 tab triggers", async ({ page }) => { + await expect(page.getByRole("tab", { name: /Overview/i })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole("tab", { name: /Operations/i })).toBeVisible(); + await expect(page.getByRole("tab", { name: /Monitoring/i })).toBeVisible(); + await expect(page.getByRole("tab", { name: /Security/i })).toBeVisible(); + await expect(page.getByRole("tab", { name: /Audit/i })).toBeVisible(); }); - test('default tab is overview', async ({ page }) => { + test("default tab is overview", async ({ page }) => { // Overview content is mounted by default — assert on the content region, not // empty-state copy, so the test holds whether or not seed connections exist. - await expect(page.getByTestId('admin-content-overview')).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('tab', { name: /Overview/i })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId("admin-content-overview")).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole("tab", { name: /Overview/i })).toHaveAttribute("aria-selected", "true"); }); - test('can switch to operations tab', async ({ page }) => { + test("can switch to operations tab", async ({ page }) => { await page.locator('button:has-text("Operations"), [role="tab"]:has-text("Operations")').first().click(); // Operations content region mounts regardless of connection state (empty // state or populated dashboard), so this is stable across environments. - await expect(page.getByTestId('admin-content-operations')).toBeVisible({ timeout: 5000 }); - await expect(page.getByRole('tab', { name: /Operations/i })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId("admin-content-operations")).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole("tab", { name: /Operations/i })).toHaveAttribute("aria-selected", "true"); }); - test('can switch to security tab', async ({ page }) => { + test("can switch to security tab", async ({ page }) => { await page.locator('button:has-text("Security"), [role="tab"]:has-text("Security")').first().click(); await page.waitForTimeout(500); // Security tab should show Data Masking content - await expect(page.locator('text=Data Masking').first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator("text=Data Masking").first()).toBeVisible({ timeout: 5000 }); }); - test('can switch to audit tab', async ({ page }) => { + test("can switch to audit tab", async ({ page }) => { await page.locator('button:has-text("Audit"), [role="tab"]:has-text("Audit")').first().click(); await page.waitForTimeout(500); // Audit tab should show operations/queries - await expect(page.locator('text=Operations').first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator("text=Operations").first()).toBeVisible({ timeout: 5000 }); }); - test('editor button navigates to studio', async ({ page }) => { + test("editor button navigates to studio", async ({ page }) => { const editorBtn = page.locator('button:has-text("Editor"), a:has-text("Editor")').first(); await editorBtn.click(); - await page.waitForURL('/'); - await expect(page).toHaveURL('/'); + await page.waitForURL("/"); + await expect(page).toHaveURL("/"); }); - test('logout button redirects to login', async ({ page }) => { + test("logout button redirects to login", async ({ page }) => { const logoutBtn = page.locator('button:has-text("Logout")').first(); await logoutBtn.click(); - await page.waitForURL('**/login**'); + await page.waitForURL("**/login**"); await expect(page).toHaveURL(/\/login/); }); }); diff --git a/e2e/connection-management.spec.ts b/e2e/connection-management.spec.ts index ec56031c..30cb74c4 100644 --- a/e2e/connection-management.spec.ts +++ b/e2e/connection-management.spec.ts @@ -1,41 +1,41 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from "@playwright/test"; -test.describe('Connection Management', () => { +test.describe("Connection Management", () => { test.beforeEach(async ({ page }) => { // Login as user (simpler redirect, avoids admin → studio navigation issues) - await page.goto('/login'); - await page.locator('input[type="email"]').fill('user@libredb.org'); - await page.locator('input[type="password"]').fill('test-user'); - await page.getByRole('button', { name: 'Sign In' }).click(); - await page.waitForURL('/'); + await page.goto("/login"); + await page.locator('input[type="email"]').fill("user@libredb.org"); + await page.locator('input[type="password"]').fill("test-user"); + await page.getByRole("button", { name: "Sign In" }).click(); + await page.waitForURL("/"); // Wait for studio to fully load - await expect(page.locator('text=Query 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=Query 1").first()).toBeVisible({ timeout: 10000 }); }); - test('add connection button opens modal', async ({ page }) => { + test("add connection button opens modal", async ({ page }) => { // The sidebar header has buttons next to LibreDB Studio logo // The last button in that row is the add connection button - const sidebarButtons = page.locator('text=LibreDB Studio').locator('..').locator('..').locator('button'); + const sidebarButtons = page.locator("text=LibreDB Studio").locator("..").locator("..").locator("button"); await sidebarButtons.last().click(); // Connection modal should appear await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); }); - test('connection modal shows database type selector', async ({ page }) => { + test("connection modal shows database type selector", async ({ page }) => { // Open connection modal - const sidebarButtons = page.locator('text=LibreDB Studio').locator('..').locator('..').locator('button'); + const sidebarButtons = page.locator("text=LibreDB Studio").locator("..").locator("..").locator("button"); await sidebarButtons.last().click(); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Should show database type options inside the dialog - await expect(dialog.locator('text=PostgreSQL')).toBeVisible({ timeout: 5000 }); + await expect(dialog.locator("text=PostgreSQL")).toBeVisible({ timeout: 5000 }); }); - test('connection modal has required fields', async ({ page }) => { - const sidebarButtons = page.locator('text=LibreDB Studio').locator('..').locator('..').locator('button'); + test("connection modal has required fields", async ({ page }) => { + const sidebarButtons = page.locator("text=LibreDB Studio").locator("..").locator("..").locator("button"); await sidebarButtons.last().click(); await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); @@ -44,14 +44,14 @@ test.describe('Connection Management', () => { await expect(page.locator('input[value="localhost"]').first()).toBeVisible(); }); - test('connection modal can be closed', async ({ page }) => { - const sidebarButtons = page.locator('text=LibreDB Studio').locator('..').locator('..').locator('button'); + test("connection modal can be closed", async ({ page }) => { + const sidebarButtons = page.locator("text=LibreDB Studio").locator("..").locator("..").locator("button"); await sidebarButtons.last().click(); await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); // Press Escape to close - await page.keyboard.press('Escape'); + await page.keyboard.press("Escape"); await expect(page.locator('[role="dialog"]')).not.toBeVisible({ timeout: 3000 }); }); diff --git a/e2e/export.spec.ts b/e2e/export.spec.ts index e0c807d6..1c079c08 100644 --- a/e2e/export.spec.ts +++ b/e2e/export.spec.ts @@ -1,16 +1,16 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from "@playwright/test"; -test.describe('Export Functionality', () => { +test.describe("Export Functionality", () => { test.beforeEach(async ({ page }) => { // Login as user - await page.goto('/login'); - await page.locator('input[type="email"]').fill('user@libredb.org'); - await page.locator('input[type="password"]').fill('test-user'); - await page.getByRole('button', { name: /sign in/i }).click(); - await page.waitForURL('/'); + await page.goto("/login"); + await page.locator('input[type="email"]').fill("user@libredb.org"); + await page.locator('input[type="password"]').fill("test-user"); + await page.getByRole("button", { name: /sign in/i }).click(); + await page.waitForURL("/"); }); - test('export dropdown is not visible when no results', async ({ page }) => { + test("export dropdown is not visible when no results", async ({ page }) => { // Without query results, export dropdown should not be prominent // The export button appears in the results panel header await page.waitForTimeout(1000); @@ -20,7 +20,7 @@ test.describe('Export Functionality', () => { await expect(exportBtn).toHaveCount(0); }); - test('history tab has export functionality', async ({ page }) => { + test("history tab has export functionality", async ({ page }) => { // Switch to history tab const historyTab = page.locator('button:has-text("History")').first(); await historyTab.click(); @@ -29,6 +29,6 @@ test.describe('Export Functionality', () => { await page.waitForTimeout(500); // The history panel has export options (CSV/JSON) - await expect(page.locator('text=History').first()).toBeVisible(); + await expect(page.locator("text=History").first()).toBeVisible(); }); }); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 048bb1cd..dd4a546a 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,84 +1,84 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from "@playwright/test"; -test.describe('Login Flow', () => { +test.describe("Login Flow", () => { test.beforeEach(async ({ page }) => { - await page.goto('/login'); + await page.goto("/login"); }); - test('shows login page with email and password fields', async ({ page }) => { - await expect(page.locator('text=LibreDB Studio').first()).toBeVisible(); + test("shows login page with email and password fields", async ({ page }) => { + await expect(page.locator("text=LibreDB Studio").first()).toBeVisible(); await expect(page.locator('input[type="email"]').first()).toBeVisible(); await expect(page.locator('input[type="password"]').first()).toBeVisible(); await expect(page.locator('button:has-text("Sign In")').first()).toBeVisible(); }); - test('admin login redirects to /admin', async ({ page }) => { - await page.locator('input[type="email"]').fill('admin@libredb.org'); - await page.locator('input[type="password"]').fill('test-admin'); - await page.getByRole('button', { name: /sign in/i }).click(); - await page.waitForURL('**/admin**'); + test("admin login redirects to /admin", async ({ page }) => { + await page.locator('input[type="email"]').fill("admin@libredb.org"); + await page.locator('input[type="password"]').fill("test-admin"); + await page.getByRole("button", { name: /sign in/i }).click(); + await page.waitForURL("**/admin**"); await expect(page).toHaveURL(/\/admin/); }); - test('user login redirects to /', async ({ page }) => { - await page.locator('input[type="email"]').fill('user@libredb.org'); - await page.locator('input[type="password"]').fill('test-user'); - await page.getByRole('button', { name: /sign in/i }).click(); - await page.waitForURL('/'); - await expect(page).toHaveURL('/'); + test("user login redirects to /", async ({ page }) => { + await page.locator('input[type="email"]').fill("user@libredb.org"); + await page.locator('input[type="password"]').fill("test-user"); + await page.getByRole("button", { name: /sign in/i }).click(); + await page.waitForURL("/"); + await expect(page).toHaveURL("/"); }); - test('wrong password shows error', async ({ page }) => { - await page.locator('input[type="email"]').fill('admin@libredb.org'); - await page.locator('input[type="password"]').fill('wrong-password'); - await page.getByRole('button', { name: /sign in/i }).click(); + test("wrong password shows error", async ({ page }) => { + await page.locator('input[type="email"]').fill("admin@libredb.org"); + await page.locator('input[type="password"]').fill("wrong-password"); + await page.getByRole("button", { name: /sign in/i }).click(); // Should stay on login page await expect(page).toHaveURL(/\/login/); }); - test('empty fields shows validation error', async ({ page }) => { - await page.getByRole('button', { name: /sign in/i }).click(); + test("empty fields shows validation error", async ({ page }) => { + await page.getByRole("button", { name: /sign in/i }).click(); // Should stay on login page await expect(page).toHaveURL(/\/login/); }); - test('authenticated admin accessing /login redirects to /admin', async ({ page }) => { + test("authenticated admin accessing /login redirects to /admin", async ({ page }) => { // Login as admin first - await page.locator('input[type="email"]').fill('admin@libredb.org'); - await page.locator('input[type="password"]').fill('test-admin'); - await page.getByRole('button', { name: /sign in/i }).click(); - await page.waitForURL('**/admin**'); + await page.locator('input[type="email"]').fill("admin@libredb.org"); + await page.locator('input[type="password"]').fill("test-admin"); + await page.getByRole("button", { name: /sign in/i }).click(); + await page.waitForURL("**/admin**"); // Try navigating back to /login - await page.goto('/login'); + await page.goto("/login"); await expect(page).toHaveURL(/\/admin/); }); - test('authenticated user accessing /login redirects to /', async ({ page }) => { + test("authenticated user accessing /login redirects to /", async ({ page }) => { // Login as user first - await page.locator('input[type="email"]').fill('user@libredb.org'); - await page.locator('input[type="password"]').fill('test-user'); - await page.getByRole('button', { name: /sign in/i }).click(); - await page.waitForURL('/'); + await page.locator('input[type="email"]').fill("user@libredb.org"); + await page.locator('input[type="password"]').fill("test-user"); + await page.getByRole("button", { name: /sign in/i }).click(); + await page.waitForURL("/"); // Try navigating back to /login - await page.goto('/login'); - await expect(page).toHaveURL('/'); + await page.goto("/login"); + await expect(page).toHaveURL("/"); }); - test('unauthenticated user accessing / redirects to /login', async ({ page }) => { - await page.goto('/'); + test("unauthenticated user accessing / redirects to /login", async ({ page }) => { + await page.goto("/"); await expect(page).toHaveURL(/\/login/); }); - test('user role cannot access /admin', async ({ page }) => { - await page.locator('input[type="email"]').fill('user@libredb.org'); - await page.locator('input[type="password"]').fill('test-user'); - await page.getByRole('button', { name: /sign in/i }).click(); - await page.waitForURL('/'); + test("user role cannot access /admin", async ({ page }) => { + await page.locator('input[type="email"]').fill("user@libredb.org"); + await page.locator('input[type="password"]').fill("test-user"); + await page.getByRole("button", { name: /sign in/i }).click(); + await page.waitForURL("/"); // Try accessing admin page - await page.goto('/admin'); + await page.goto("/admin"); // Should redirect away from admin await expect(page).not.toHaveURL(/\/admin/); }); diff --git a/e2e/query-execution.spec.ts b/e2e/query-execution.spec.ts index e5dbef4c..db132ff2 100644 --- a/e2e/query-execution.spec.ts +++ b/e2e/query-execution.spec.ts @@ -1,35 +1,37 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from "@playwright/test"; -test.describe('Query Execution', () => { +test.describe("Query Execution", () => { test.beforeEach(async ({ page }) => { // Login as user - await page.goto('/login'); - await page.locator('input[type="email"]').fill('user@libredb.org'); - await page.locator('input[type="password"]').fill('test-user'); - await page.getByRole('button', { name: 'Sign In' }).click(); - await page.waitForURL('/'); + await page.goto("/login"); + await page.locator('input[type="email"]').fill("user@libredb.org"); + await page.locator('input[type="password"]').fill("test-user"); + await page.getByRole("button", { name: "Sign In" }).click(); + await page.waitForURL("/"); }); - test('query editor is visible after login', async ({ page }) => { + test("query editor is visible after login", async ({ page }) => { // The Monaco editor or its container should be visible - await expect(page.locator('.monaco-editor, [data-testid="query-editor"], textarea').first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.monaco-editor, [data-testid="query-editor"], textarea').first()).toBeVisible({ + timeout: 10000, + }); }); - test('run button is visible', async ({ page }) => { + test("run button is visible", async ({ page }) => { // Run button shows as "RUN" in the toolbar - await expect(page.getByRole('button', { name: 'RUN' })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole("button", { name: "RUN" })).toBeVisible({ timeout: 10000 }); }); - test('bottom panel shows results tab', async ({ page }) => { + test("bottom panel shows results tab", async ({ page }) => { // Results tab button should be visible in the bottom panel - await expect(page.getByRole('button', { name: 'Results' })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole("button", { name: "Results" })).toBeVisible({ timeout: 10000 }); }); - test('bottom panel has history tab', async ({ page }) => { - await expect(page.getByRole('button', { name: 'History' })).toBeVisible({ timeout: 10000 }); + test("bottom panel has history tab", async ({ page }) => { + await expect(page.getByRole("button", { name: "History" })).toBeVisible({ timeout: 10000 }); }); - test('bottom panel has charts tab', async ({ page }) => { - await expect(page.getByRole('button', { name: 'Charts' })).toBeVisible({ timeout: 10000 }); + test("bottom panel has charts tab", async ({ page }) => { + await expect(page.getByRole("button", { name: "Charts" })).toBeVisible({ timeout: 10000 }); }); }); diff --git a/e2e/tab-management.spec.ts b/e2e/tab-management.spec.ts index f326667f..dde9864a 100644 --- a/e2e/tab-management.spec.ts +++ b/e2e/tab-management.spec.ts @@ -1,64 +1,64 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from "@playwright/test"; -test.describe('Tab Management', () => { +test.describe("Tab Management", () => { test.beforeEach(async ({ page }) => { // Login as user - await page.goto('/login'); - await page.locator('input[type="email"]').fill('user@libredb.org'); - await page.locator('input[type="password"]').fill('test-user'); - await page.getByRole('button', { name: 'Sign In' }).click(); - await page.waitForURL('/'); + await page.goto("/login"); + await page.locator('input[type="email"]').fill("user@libredb.org"); + await page.locator('input[type="password"]').fill("test-user"); + await page.getByRole("button", { name: "Sign In" }).click(); + await page.waitForURL("/"); // Wait for studio to fully load - await expect(page.locator('text=Query 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=Query 1").first()).toBeVisible({ timeout: 10000 }); }); - test('default tab exists with name Query 1', async ({ page }) => { - await expect(page.locator('text=Query 1').first()).toBeVisible(); + test("default tab exists with name Query 1", async ({ page }) => { + await expect(page.locator("text=Query 1").first()).toBeVisible(); }); - test('can add a new tab', async ({ page }) => { + test("can add a new tab", async ({ page }) => { // The tab bar's plus icon is a sibling of the "Query 1" tab div // Navigate from Query 1 text → its parent tab div → the parent tab bar → find the direct child SVG plus - const query1Parent = page.locator('text=Query 1').first().locator('..'); - const tabBar = query1Parent.locator('..'); - const tabPlusIcon = tabBar.locator(':scope > svg').first(); + const query1Parent = page.locator("text=Query 1").first().locator(".."); + const tabBar = query1Parent.locator(".."); + const tabPlusIcon = tabBar.locator(":scope > svg").first(); await tabPlusIcon.click(); // New tab "Query 2" should appear - await expect(page.locator('text=Query 2')).toBeVisible({ timeout: 5000 }); + await expect(page.locator("text=Query 2")).toBeVisible({ timeout: 5000 }); }); - test('can switch between tabs', async ({ page }) => { + test("can switch between tabs", async ({ page }) => { // Add a second tab using the same strategy - const query1Parent = page.locator('text=Query 1').first().locator('..'); - const tabBar = query1Parent.locator('..'); - const tabPlusIcon = tabBar.locator(':scope > svg').first(); + const query1Parent = page.locator("text=Query 1").first().locator(".."); + const tabBar = query1Parent.locator(".."); + const tabPlusIcon = tabBar.locator(":scope > svg").first(); await tabPlusIcon.click(); - await expect(page.locator('text=Query 2')).toBeVisible({ timeout: 5000 }); + await expect(page.locator("text=Query 2")).toBeVisible({ timeout: 5000 }); // Click on Query 1 to switch back - await page.locator('text=Query 1').first().click(); + await page.locator("text=Query 1").first().click(); await page.waitForTimeout(300); }); - test('can close a tab when multiple exist', async ({ page }) => { + test("can close a tab when multiple exist", async ({ page }) => { // Add a second tab - const query1Parent = page.locator('text=Query 1').first().locator('..'); - const tabBar = query1Parent.locator('..'); - const tabPlusIcon = tabBar.locator(':scope > svg').first(); + const query1Parent = page.locator("text=Query 1").first().locator(".."); + const tabBar = query1Parent.locator(".."); + const tabPlusIcon = tabBar.locator(":scope > svg").first(); await tabPlusIcon.click(); - await expect(page.locator('text=Query 2')).toBeVisible({ timeout: 5000 }); + await expect(page.locator("text=Query 2")).toBeVisible({ timeout: 5000 }); // Close Query 2 — the X icon is inside the Query 2 tab div // Hover the tab to reveal the X icon, then click - const query2Parent = page.locator('text=Query 2').first().locator('..'); + const query2Parent = page.locator("text=Query 2").first().locator(".."); await query2Parent.hover(); - const closeIcon = query2Parent.locator('svg').last(); + const closeIcon = query2Parent.locator("svg").last(); await closeIcon.click(); // Query 2 should no longer exist - await expect(page.locator('text=Query 2')).not.toBeVisible({ timeout: 3000 }); + await expect(page.locator("text=Query 2")).not.toBeVisible({ timeout: 3000 }); // Query 1 should still exist - await expect(page.locator('text=Query 1').first()).toBeVisible(); + await expect(page.locator("text=Query 1").first()).toBeVisible(); }); }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 6412e124..c19bf069 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,13 +5,7 @@ import nextTypescript from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextCoreWebVitals, ...nextTypescript, - globalIgnores([ - ".next/**", - "out/**", - "build/**", - "dist/**", - "next-env.d.ts", - ]), + globalIgnores([".next/**", "out/**", "build/**", "dist/**", "next-env.d.ts"]), { rules: { "@typescript-eslint/no-explicit-any": "warn", diff --git a/next.config.ts b/next.config.ts index 84b21e1c..977bb237 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,20 +7,20 @@ const nextConfig: NextConfig = { }, // Use standalone output for Docker/Kubernetes deployments // For Vercel, this is automatically handled - output: process.env.DOCKER_BUILD === 'true' ? 'standalone' : undefined, + output: process.env.DOCKER_BUILD === "true" ? "standalone" : undefined, // Externalize native modules to reduce bundle size and memory usage // These packages will be loaded from node_modules at runtime - serverExternalPackages: ['pg', 'mysql2', 'mongodb', 'better-sqlite3', 'ssh2'], + serverExternalPackages: ["pg", "mysql2", "mongodb", "better-sqlite3", "ssh2"], images: { remotePatterns: [ { - protocol: 'https', - hostname: '**', + protocol: "https", + hostname: "**", }, { - protocol: 'http', - hostname: '**', + protocol: "http", + hostname: "**", }, ], }, diff --git a/package.json b/package.json index 3e0d1bfc..0096510d 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,8 @@ "build:lib": "tsup", "prepublishOnly": "tsup", "start": "next start", + "format": "biome format .", + "format:fix": "biome format --write .", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "bun test tests/unit tests/api tests/integration && bun test tests/hooks && bun run test:components", @@ -167,6 +169,7 @@ "zod": "^4.1.12" }, "devDependencies": { + "@biomejs/biome": "^2.5", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@testing-library/react": "^16.3.2", diff --git a/playwright.config.ts b/playwright.config.ts index 78362bfb..95e6c36d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,34 +1,34 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ - testDir: './e2e', + testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: process.env.CI ? 'html' : 'list', + reporter: process.env.CI ? "html" : "list", use: { - baseURL: 'http://localhost:3000', - trace: 'on-first-retry', - screenshot: 'only-on-failure', + baseURL: "http://localhost:3000", + trace: "on-first-retry", + screenshot: "only-on-failure", }, projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + name: "chromium", + use: { ...devices["Desktop Chrome"] }, }, ], webServer: { - command: 'bun run build && bun start', - url: 'http://localhost:3000', + command: "bun run build && bun start", + url: "http://localhost:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, env: { - JWT_SECRET: 'test-jwt-secret-for-e2e-tests-32ch', - ADMIN_EMAIL: 'admin@libredb.org', - ADMIN_PASSWORD: 'test-admin', - USER_EMAIL: 'user@libredb.org', - USER_PASSWORD: 'test-user', + JWT_SECRET: "test-jwt-secret-for-e2e-tests-32ch", + ADMIN_EMAIL: "admin@libredb.org", + ADMIN_PASSWORD: "test-admin", + USER_EMAIL: "user@libredb.org", + USER_PASSWORD: "test-user", }, }, }); diff --git a/scripts/merge-lcov.mjs b/scripts/merge-lcov.mjs index 2cb2a243..a9f1bc15 100644 --- a/scripts/merge-lcov.mjs +++ b/scripts/merge-lcov.mjs @@ -190,7 +190,10 @@ function serializeRecords(records) { } const fnf = sortedFunctions.length; - const fnh = sortedFunctions.reduce((acc, [fnName]) => acc + ((record.functionHits.get(fnName) || 0) > 0 ? 1 : 0), 0); + const fnh = sortedFunctions.reduce( + (acc, [fnName]) => acc + ((record.functionHits.get(fnName) || 0) > 0 ? 1 : 0), + 0, + ); lines.push(`FNF:${fnf}`); lines.push(`FNH:${fnh}`); diff --git a/src/app/admin/error.tsx b/src/app/admin/error.tsx index fd099f24..adaa9a77 100644 --- a/src/app/admin/error.tsx +++ b/src/app/admin/error.tsx @@ -1,16 +1,10 @@ -'use client'; +"use client"; -import { useEffect } from 'react'; +import { useEffect } from "react"; -export default function AdminError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { +export default function AdminError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { useEffect(() => { - console.error('[AdminErrorBoundary]', error.message, error.digest); + console.error("[AdminErrorBoundary]", error.message, error.digest); }, [error]); return ( @@ -18,14 +12,9 @@ export default function AdminError({

Admin Dashboard Error

- The admin dashboard encountered an error. You can try again or return - to the main studio. + The admin dashboard encountered an error. You can try again or return to the main studio.

- {error.digest && ( -

- Error ID: {error.digest} -

- )} + {error.digest &&

Error ID: {error.digest}

}
@@ -255,7 +273,7 @@ function LoginFormInner({ authProvider }: { authProvider: string }) { type="submit" disabled={isLoading} > - {isLoading ? 'Authenticating...' : 'Sign In'} + {isLoading ? "Authenticating..." : "Sign In"} @@ -276,7 +294,7 @@ function LoginFormInner({ authProvider }: { authProvider: string }) {
- {['PostgreSQL', 'MySQL', 'MongoDB', 'Oracle', 'SQL Server'].map((db) => ( + {["PostgreSQL", "MySQL", "MongoDB", "Oracle", "SQL Server"].map((db) => ( ; } diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 609f296a..aaec87ac 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { MonitoringDashboard } from '@/components/monitoring/MonitoringDashboard'; +import { MonitoringDashboard } from "@/components/monitoring/MonitoringDashboard"; export default function MonitoringPage() { // Middleware handles authentication, no need for client-side check diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 9db485f5..71cca961 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link'; +import Link from "next/link"; export default function NotFound() { return ( @@ -6,9 +6,7 @@ export default function NotFound() {
404

Page Not Found

-

- The page you are looking for does not exist or has been moved. -

+

The page you are looking for does not exist or has been moved.

; diff --git a/src/components/AIAutopilotPanel.tsx b/src/components/AIAutopilotPanel.tsx index 3d1b71aa..6964ef93 100644 --- a/src/components/AIAutopilotPanel.tsx +++ b/src/components/AIAutopilotPanel.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { useState, useRef } from 'react'; -import { Loader2, Sparkles, RefreshCw, Play, Copy, Check } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { DatabaseConnection } from '@/lib/types'; +import React, { useState, useRef } from "react"; +import { Loader2, Sparkles, RefreshCw, Play, Copy, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { DatabaseConnection } from "@/lib/types"; interface AIAutopilotPanelProps { connection: DatabaseConnection | null; @@ -13,7 +13,7 @@ interface AIAutopilotPanelProps { export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }: AIAutopilotPanelProps) { const [isLoading, setIsLoading] = useState(false); - const [report, setReport] = useState(''); + const [report, setReport] = useState(""); const [error, setError] = useState(null); const [copiedIndex, setCopiedIndex] = useState(null); const reportRef = useRef(null); @@ -22,14 +22,14 @@ export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }: if (!connection) return; setIsLoading(true); setError(null); - setReport(''); + setReport(""); try { // Fetch monitoring data in parallel const [monitoringRes] = await Promise.all([ - fetch('/api/db/monitoring', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + fetch("/api/db/monitoring", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ connection, options: { @@ -47,23 +47,30 @@ export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }: } // Build filtered schema - let filteredSchema = ''; + let filteredSchema = ""; if (schemaContext) { try { const tables = JSON.parse(schemaContext); - filteredSchema = tables.slice(0, 30).map((t: { name: string; rowCount?: number; columns?: { name: string; type: string }[] }) => { - const cols = t.columns?.slice(0, 6).map(c => `${c.name} (${c.type})`).join(', ') || ''; - return `${t.name} (${t.rowCount || 0} rows): ${cols}`; - }).join('\n'); + filteredSchema = tables + .slice(0, 30) + .map((t: { name: string; rowCount?: number; columns?: { name: string; type: string }[] }) => { + const cols = + t.columns + ?.slice(0, 6) + .map((c) => `${c.name} (${c.type})`) + .join(", ") || ""; + return `${t.name} (${t.rowCount || 0} rows): ${cols}`; + }) + .join("\n"); } catch { filteredSchema = schemaContext.substring(0, 2000); } } // Call autopilot AI - const response = await fetch('/api/ai/autopilot', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/ai/autopilot", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slowQueries: monitoringData?.slowQueries, indexStats: monitoringData?.indexes, @@ -77,13 +84,13 @@ export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }: if (!response.ok) { const errData = await response.json(); - throw new Error(errData.error || 'Autopilot analysis failed'); + throw new Error(errData.error || "Autopilot analysis failed"); } const reader = response.body?.getReader(); - if (!reader) throw new Error('No reader'); + if (!reader) throw new Error("No reader"); - let fullResponse = ''; + let fullResponse = ""; while (true) { const { done, value } = await reader.read(); if (done) break; @@ -91,7 +98,7 @@ export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }: setReport(fullResponse); } } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : "Unknown error"); } finally { setIsLoading(false); } @@ -105,16 +112,16 @@ export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }: // Simple markdown rendering (headers, bold, lists) const renderMarkdown = (text: string) => { - const lines = text.split('\n'); + const lines = text.split("\n"); const elements: React.ReactNode[] = []; let inCodeBlock = false; - let codeContent = ''; + let codeContent = ""; let codeBlockIdx = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; - if (line.startsWith('```')) { + if (line.startsWith("```")) { if (inCodeBlock) { // End of code block const blockIndex = codeBlockIdx++; @@ -130,7 +137,11 @@ export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }: className="p-1 rounded bg-white/10 hover:bg-white/20 text-zinc-400" title="Copy" > - {copiedIndex === blockIndex ? : } + {copiedIndex === blockIndex ? ( + + ) : ( + + )} {onExecuteQuery && (
-
+
, ); - codeContent = ''; + codeContent = ""; inCodeBlock = false; } else { inCodeBlock = true; @@ -153,24 +164,46 @@ export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }: } if (inCodeBlock) { - codeContent += line + '\n'; + codeContent += line + "\n"; continue; } // Headers - if (line.startsWith('## ')) { - elements.push(

{line.slice(3)}

); - } else if (line.startsWith('### ')) { - elements.push(

{line.slice(4)}

); - } else if (line.startsWith('- ')) { + if (line.startsWith("## ")) { + elements.push( +

+ {line.slice(3)} +

, + ); + } else if (line.startsWith("### ")) { + elements.push( +

+ {line.slice(4)} +

, + ); + } else if (line.startsWith("- ")) { const content = line.slice(2).replace(/\*\*(.*?)\*\*/g, '$1'); - elements.push(
  • ); + elements.push( +
  • , + ); } else if (line.match(/^\d+\.\s/)) { const content = line.replace(/\*\*(.*?)\*\*/g, '$1'); - elements.push(
  • ); + elements.push( +
  • , + ); } else if (line.trim()) { const content = line.replace(/\*\*(.*?)\*\*/g, '$1'); - elements.push(

    ); + elements.push( +

    , + ); } else { elements.push(

    ); } @@ -187,24 +220,24 @@ export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }:
    - - AI Performance Autopilot - + AI Performance Autopilot
  • @@ -222,16 +255,10 @@ export function AIAutopilotPanel({ connection, schemaContext, onExecuteQuery }: )} {error && ( -
    - {error} -
    +
    {error}
    )} - {report && ( -
    - {renderMarkdown(report)} -
    - )} + {report &&
    {renderMarkdown(report)}
    }
    ); diff --git a/src/components/CodeGenerator.tsx b/src/components/CodeGenerator.tsx index afc652ec..e577358f 100644 --- a/src/components/CodeGenerator.tsx +++ b/src/components/CodeGenerator.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { useState, useMemo } from 'react'; -import { Code, X, Copy, Check, ChevronDown } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { TableSchema } from '@/lib/types'; +import React, { useState, useMemo } from "react"; +import { Code, X, Copy, Check, ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { TableSchema } from "@/lib/types"; interface CodeGeneratorProps { isOpen: boolean; @@ -13,22 +13,22 @@ interface CodeGeneratorProps { databaseType?: string; } -type Language = 'typescript' | 'zod' | 'prisma' | 'go' | 'python' | 'java'; +type Language = "typescript" | "zod" | "prisma" | "go" | "python" | "java"; const LANGUAGES: { id: Language; label: string; ext: string }[] = [ - { id: 'typescript', label: 'TypeScript Interface', ext: 'ts' }, - { id: 'zod', label: 'Zod Schema', ext: 'ts' }, - { id: 'prisma', label: 'Prisma Model', ext: 'prisma' }, - { id: 'go', label: 'Go Struct', ext: 'go' }, - { id: 'python', label: 'Python Dataclass', ext: 'py' }, - { id: 'java', label: 'Java POJO', ext: 'java' }, + { id: "typescript", label: "TypeScript Interface", ext: "ts" }, + { id: "zod", label: "Zod Schema", ext: "ts" }, + { id: "prisma", label: "Prisma Model", ext: "prisma" }, + { id: "go", label: "Go Struct", ext: "go" }, + { id: "python", label: "Python Dataclass", ext: "py" }, + { id: "java", label: "Java POJO", ext: "java" }, ]; export function toPascalCase(str: string): string { return str .replace(/[_-](\w)/g, (_, c) => c.toUpperCase()) - .replace(/^\w/, c => c.toUpperCase()) - .replace(/s$/, ''); // Remove trailing 's' (pluralized table name) + .replace(/^\w/, (c) => c.toUpperCase()) + .replace(/s$/, ""); // Remove trailing 's' (pluralized table name) } export function toCamelCase(str: string): string { @@ -37,72 +37,107 @@ export function toCamelCase(str: string): string { } export function toSnakeCase(str: string): string { - return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, ''); + return str + .replace(/([A-Z])/g, "_$1") + .toLowerCase() + .replace(/^_/, ""); } export function mapSqlTypeToTS(sqlType: string): string { const t = sqlType.toLowerCase(); - if (t.includes('int') || t.includes('float') || t.includes('double') || t.includes('decimal') || t.includes('numeric') || t.includes('real') || t.includes('serial')) return 'number'; - if (t.includes('bool')) return 'boolean'; - if (t.includes('date') || t.includes('time')) return 'Date'; - if (t.includes('json')) return 'Record'; - if (t.includes('uuid')) return 'string'; - if (t.includes('array')) return 'unknown[]'; - return 'string'; + if ( + t.includes("int") || + t.includes("float") || + t.includes("double") || + t.includes("decimal") || + t.includes("numeric") || + t.includes("real") || + t.includes("serial") + ) + return "number"; + if (t.includes("bool")) return "boolean"; + if (t.includes("date") || t.includes("time")) return "Date"; + if (t.includes("json")) return "Record"; + if (t.includes("uuid")) return "string"; + if (t.includes("array")) return "unknown[]"; + return "string"; } export function mapSqlTypeToZod(sqlType: string): string { const t = sqlType.toLowerCase(); - if (t.includes('int') || t.includes('float') || t.includes('double') || t.includes('decimal') || t.includes('numeric') || t.includes('real') || t.includes('serial')) return 'z.number()'; - if (t.includes('bool')) return 'z.boolean()'; - if (t.includes('date') || t.includes('time')) return 'z.date()'; - if (t.includes('json')) return 'z.record(z.unknown())'; - if (t.includes('uuid')) return 'z.string().uuid()'; - return 'z.string()'; + if ( + t.includes("int") || + t.includes("float") || + t.includes("double") || + t.includes("decimal") || + t.includes("numeric") || + t.includes("real") || + t.includes("serial") + ) + return "z.number()"; + if (t.includes("bool")) return "z.boolean()"; + if (t.includes("date") || t.includes("time")) return "z.date()"; + if (t.includes("json")) return "z.record(z.unknown())"; + if (t.includes("uuid")) return "z.string().uuid()"; + return "z.string()"; } export function mapSqlTypeToPrisma(sqlType: string): string { const t = sqlType.toLowerCase(); - if (t.includes('serial') || t === 'integer' || t === 'int' || t === 'int4') return 'Int'; - if (t.includes('bigint') || t.includes('int8')) return 'BigInt'; - if (t.includes('float') || t.includes('double') || t.includes('decimal') || t.includes('numeric') || t.includes('real')) return 'Float'; - if (t.includes('bool')) return 'Boolean'; - if (t.includes('timestamp') || t.includes('datetime')) return 'DateTime'; - if (t.includes('date')) return 'DateTime'; - if (t.includes('json')) return 'Json'; - return 'String'; + if (t.includes("serial") || t === "integer" || t === "int" || t === "int4") return "Int"; + if (t.includes("bigint") || t.includes("int8")) return "BigInt"; + if ( + t.includes("float") || + t.includes("double") || + t.includes("decimal") || + t.includes("numeric") || + t.includes("real") + ) + return "Float"; + if (t.includes("bool")) return "Boolean"; + if (t.includes("timestamp") || t.includes("datetime")) return "DateTime"; + if (t.includes("date")) return "DateTime"; + if (t.includes("json")) return "Json"; + return "String"; } export function mapSqlTypeToGo(sqlType: string): string { const t = sqlType.toLowerCase(); - if (t.includes('serial') || t === 'integer' || t === 'int' || t === 'int4') return 'int'; - if (t.includes('bigint') || t.includes('int8')) return 'int64'; - if (t.includes('float') || t.includes('real')) return 'float32'; - if (t.includes('double') || t.includes('decimal') || t.includes('numeric')) return 'float64'; - if (t.includes('bool')) return 'bool'; - if (t.includes('date') || t.includes('time')) return 'time.Time'; - return 'string'; + if (t.includes("serial") || t === "integer" || t === "int" || t === "int4") return "int"; + if (t.includes("bigint") || t.includes("int8")) return "int64"; + if (t.includes("float") || t.includes("real")) return "float32"; + if (t.includes("double") || t.includes("decimal") || t.includes("numeric")) return "float64"; + if (t.includes("bool")) return "bool"; + if (t.includes("date") || t.includes("time")) return "time.Time"; + return "string"; } export function mapSqlTypeToPython(sqlType: string): string { const t = sqlType.toLowerCase(); - if (t.includes('int') || t.includes('serial')) return 'int'; - if (t.includes('float') || t.includes('double') || t.includes('decimal') || t.includes('numeric') || t.includes('real')) return 'float'; - if (t.includes('bool')) return 'bool'; - if (t.includes('date') || t.includes('time')) return 'datetime'; - if (t.includes('json')) return 'dict'; - return 'str'; + if (t.includes("int") || t.includes("serial")) return "int"; + if ( + t.includes("float") || + t.includes("double") || + t.includes("decimal") || + t.includes("numeric") || + t.includes("real") + ) + return "float"; + if (t.includes("bool")) return "bool"; + if (t.includes("date") || t.includes("time")) return "datetime"; + if (t.includes("json")) return "dict"; + return "str"; } export function mapSqlTypeToJava(sqlType: string): string { const t = sqlType.toLowerCase(); - if (t.includes('serial') || t === 'integer' || t === 'int' || t === 'int4') return 'Integer'; - if (t.includes('bigint') || t.includes('int8')) return 'Long'; - if (t.includes('float') || t.includes('real')) return 'Float'; - if (t.includes('double') || t.includes('decimal') || t.includes('numeric')) return 'Double'; - if (t.includes('bool')) return 'Boolean'; - if (t.includes('date') || t.includes('time')) return 'LocalDateTime'; - return 'String'; + if (t.includes("serial") || t === "integer" || t === "int" || t === "int4") return "Integer"; + if (t.includes("bigint") || t.includes("int8")) return "Long"; + if (t.includes("float") || t.includes("real")) return "Float"; + if (t.includes("double") || t.includes("decimal") || t.includes("numeric")) return "Double"; + if (t.includes("bool")) return "Boolean"; + if (t.includes("date") || t.includes("time")) return "LocalDateTime"; + return "String"; } export function generateCode(lang: Language, table: TableSchema): string { @@ -110,81 +145,81 @@ export function generateCode(lang: Language, table: TableSchema): string { const columns = table.columns || []; switch (lang) { - case 'typescript': { - const fields = columns.map(c => { + case "typescript": { + const fields = columns.map((c) => { const tsType = mapSqlTypeToTS(c.type); - const nullable = c.nullable ? ' | null' : ''; + const nullable = c.nullable ? " | null" : ""; return ` ${toCamelCase(c.name)}: ${tsType}${nullable};`; }); - return `export interface ${name} {\n${fields.join('\n')}\n}`; + return `export interface ${name} {\n${fields.join("\n")}\n}`; } - case 'zod': { - const fields = columns.map(c => { + case "zod": { + const fields = columns.map((c) => { let zodType = mapSqlTypeToZod(c.type); - if (c.nullable) zodType += '.nullable()'; + if (c.nullable) zodType += ".nullable()"; return ` ${toCamelCase(c.name)}: ${zodType},`; }); - return `import { z } from 'zod';\n\nexport const ${name}Schema = z.object({\n${fields.join('\n')}\n});\n\nexport type ${name} = z.infer;`; + return `import { z } from 'zod';\n\nexport const ${name}Schema = z.object({\n${fields.join("\n")}\n});\n\nexport type ${name} = z.infer;`; } - case 'prisma': { - const fields = columns.map(c => { + case "prisma": { + const fields = columns.map((c) => { const prismaType = mapSqlTypeToPrisma(c.type); - const nullable = c.nullable ? '?' : ''; - const pk = c.isPrimary ? ' @id' : ''; - const auto = c.type.toLowerCase().includes('serial') ? ' @default(autoincrement())' : ''; + const nullable = c.nullable ? "?" : ""; + const pk = c.isPrimary ? " @id" : ""; + const auto = c.type.toLowerCase().includes("serial") ? " @default(autoincrement())" : ""; return ` ${c.name} ${prismaType}${nullable}${pk}${auto}`; }); - return `model ${name} {\n${fields.join('\n')}\n\n @@map("${table.name}")\n}`; + return `model ${name} {\n${fields.join("\n")}\n\n @@map("${table.name}")\n}`; } - case 'go': { - const fields = columns.map(c => { + case "go": { + const fields = columns.map((c) => { const goType = mapSqlTypeToGo(c.type); - const nullable = c.nullable ? '*' : ''; + const nullable = c.nullable ? "*" : ""; const fieldName = toPascalCase(c.name); return `\t${fieldName} ${nullable}${goType} \`json:"${c.name}" db:"${c.name}"\``; }); - const needsTime = columns.some(c => c.type.toLowerCase().includes('date') || c.type.toLowerCase().includes('time')); - const imports = needsTime ? '\nimport "time"\n' : ''; - return `package models${imports}\n\ntype ${name} struct {\n${fields.join('\n')}\n}`; + const needsTime = columns.some( + (c) => c.type.toLowerCase().includes("date") || c.type.toLowerCase().includes("time"), + ); + const imports = needsTime ? '\nimport "time"\n' : ""; + return `package models${imports}\n\ntype ${name} struct {\n${fields.join("\n")}\n}`; } - case 'python': { - const fields = columns.map(c => { + case "python": { + const fields = columns.map((c) => { const pyType = mapSqlTypeToPython(c.type); const optional = c.nullable ? `Optional[${pyType}]` : pyType; return ` ${toSnakeCase(c.name)}: ${optional}`; }); - const needsOptional = columns.some(c => c.nullable); - const needsDatetime = columns.some(c => c.type.toLowerCase().includes('date') || c.type.toLowerCase().includes('time')); - const imports: string[] = ['from dataclasses import dataclass']; - if (needsOptional) imports.push('from typing import Optional'); - if (needsDatetime) imports.push('from datetime import datetime'); - return `${imports.join('\n')}\n\n\n@dataclass\nclass ${name}:\n${fields.join('\n')}`; + const needsOptional = columns.some((c) => c.nullable); + const needsDatetime = columns.some( + (c) => c.type.toLowerCase().includes("date") || c.type.toLowerCase().includes("time"), + ); + const imports: string[] = ["from dataclasses import dataclass"]; + if (needsOptional) imports.push("from typing import Optional"); + if (needsDatetime) imports.push("from datetime import datetime"); + return `${imports.join("\n")}\n\n\n@dataclass\nclass ${name}:\n${fields.join("\n")}`; } - case 'java': { - const fields = columns.map(c => { + case "java": { + const fields = columns.map((c) => { const javaType = mapSqlTypeToJava(c.type); return ` private ${javaType} ${toCamelCase(c.name)};`; }); - const needsLocalDateTime = columns.some(c => c.type.toLowerCase().includes('date') || c.type.toLowerCase().includes('time')); - const imports = needsLocalDateTime ? 'import java.time.LocalDateTime;\n\n' : ''; - return `${imports}public class ${name} {\n${fields.join('\n')}\n}`; + const needsLocalDateTime = columns.some( + (c) => c.type.toLowerCase().includes("date") || c.type.toLowerCase().includes("time"), + ); + const imports = needsLocalDateTime ? "import java.time.LocalDateTime;\n\n" : ""; + return `${imports}public class ${name} {\n${fields.join("\n")}\n}`; } } } -export function CodeGenerator({ - isOpen, - onClose, - tableName, - tableSchema, - databaseType, -}: CodeGeneratorProps) { - const [language, setLanguage] = useState('typescript'); +export function CodeGenerator({ isOpen, onClose, tableName, tableSchema, databaseType }: CodeGeneratorProps) { + const [language, setLanguage] = useState("typescript"); const [copied, setCopied] = useState(false); const [showLangDropdown, setShowLangDropdown] = useState(false); const code = useMemo(() => { - if (!tableSchema) return '// No schema available'; + if (!tableSchema) return "// No schema available"; return generateCode(language, tableSchema); }, [language, tableSchema]); @@ -196,7 +231,7 @@ export function CodeGenerator({ if (!isOpen) return null; - const currentLang = LANGUAGES.find(l => l.id === language)!; + const currentLang = LANGUAGES.find((l) => l.id === language)!; return (
    @@ -207,9 +242,7 @@ export function CodeGenerator({ Code Generator {tableName} - {databaseType && ( - {databaseType} - )} + {databaseType && {databaseType}}
    {showLangDropdown && (
    - {LANGUAGES.map(lang => ( + {LANGUAGES.map((lang) => (
    {/* Footer */}

    - Generated from {tableName} • {tableSchema?.columns?.length || 0} columns • {currentLang.ext} format + Generated from {tableName} • {tableSchema?.columns?.length || 0}{" "} + columns • {currentLang.ext} format

    diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 785d3707..4d925a3f 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState, useMemo } from "react"; import { CommandDialog, CommandInput, @@ -9,7 +9,7 @@ import { CommandGroup, CommandItem, CommandShortcut, -} from '@/components/ui/command'; +} from "@/components/ui/command"; import { Table2, Play, @@ -23,10 +23,10 @@ import { Sparkles, AlignLeft, Save, -} from 'lucide-react'; -import { DatabaseConnection, TableSchema, SavedQuery, QueryHistoryItem } from '@/lib/types'; -import { storage } from '@/lib/storage'; -import { getDBIcon } from '@/lib/db-ui-config'; +} from "lucide-react"; +import { DatabaseConnection, TableSchema, SavedQuery, QueryHistoryItem } from "@/lib/types"; +import { storage } from "@/lib/storage"; +import { getDBIcon } from "@/lib/db-ui-config"; interface CommandPaletteProps { connections: DatabaseConnection[]; @@ -70,13 +70,13 @@ export function CommandPalette({ // Register Cmd+K / Ctrl+K keyboard shortcut useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); - setOpen(prev => !prev); + setOpen((prev) => !prev); } }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); }, []); // Load saved queries and history @@ -98,10 +98,7 @@ export function CommandPalette({ className="sm:max-w-[560px] bg-[#0a0a0a] border-white/10" showCloseButton={false} > - + No results found. @@ -154,10 +151,7 @@ export function CommandPalette({ {connections.map((conn) => { const Icon = getDBIcon(conn.type); return ( - runAction(() => onSelectConnection(conn))} - > + runAction(() => onSelectConnection(conn))}> {conn.name} {activeConnection?.id === conn.id && ( @@ -173,10 +167,7 @@ export function CommandPalette({ {schema.length > 0 && ( {schema.map((table) => ( - runAction(() => onTableClick(table.name))} - > + runAction(() => onTableClick(table.name))}> {table.name} @@ -192,10 +183,7 @@ export function CommandPalette({ {savedQueries.length > 0 && ( {savedQueries.map((sq: SavedQuery) => ( - runAction(() => onLoadSavedQuery(sq.query))} - > + runAction(() => onLoadSavedQuery(sq.query))}> {sq.name} @@ -210,10 +198,7 @@ export function CommandPalette({ {historyItems.length > 0 && ( {historyItems.map((item: QueryHistoryItem) => ( - runAction(() => onLoadHistoryQuery(item.query))} - > + runAction(() => onLoadHistoryQuery(item.query))}> {item.query.substring(0, 60)} {item.executionTime}ms diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 740f25b5..b69d342c 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,17 +1,37 @@ "use client"; -import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; -import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { DatabaseConnection, ConnectionEnvironment, ENVIRONMENT_COLORS, ENVIRONMENT_LABELS, SSLMode } from '@/lib/types'; -import { Database, ShieldCheck, Zap, Globe, Key, Link, CheckCircle2, XCircle, ClipboardPaste, Lock, ChevronDown, Terminal, Settings2 } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { getDBConfig, isFileBased } from '@/lib/db-ui-config'; -import { motion, AnimatePresence } from 'framer-motion'; -import { useConnectionForm } from '@/hooks/use-connection-form'; -import { useIsMobile } from '@/hooks/use-mobile'; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from "@/components/ui/drawer"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + DatabaseConnection, + ConnectionEnvironment, + ENVIRONMENT_COLORS, + ENVIRONMENT_LABELS, + SSLMode, +} from "@/lib/types"; +import { + Database, + ShieldCheck, + Zap, + Globe, + Key, + Link, + CheckCircle2, + XCircle, + ClipboardPaste, + Lock, + ChevronDown, + Terminal, + Settings2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getDBConfig, isFileBased } from "@/lib/db-ui-config"; +import { motion, AnimatePresence } from "framer-motion"; +import { useConnectionForm } from "@/hooks/use-connection-form"; +import { useIsMobile } from "@/hooks/use-mobile"; interface ConnectionModalProps { isOpen: boolean; @@ -19,53 +39,91 @@ interface ConnectionModalProps { onConnect: (conn: DatabaseConnection) => void; editConnection?: DatabaseConnection | null; /** Optional API adapter: when provided, bypasses the built-in /api/db/test-connection fetch. */ - onTestConnection?: (connection: DatabaseConnection) => Promise<{ success: boolean; latency?: number; error?: string }>; + onTestConnection?: ( + connection: DatabaseConnection, + ) => Promise<{ success: boolean; latency?: number; error?: string }>; } -export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, onTestConnection }: ConnectionModalProps) { +export function ConnectionModal({ + isOpen, + onClose, + onConnect, + editConnection, + onTestConnection, +}: ConnectionModalProps) { const isMobile = useIsMobile(); const { // Connection fields - type, setType, - name, setName, - host, setHost, - port, setPort, - user, setUser, - password, setPassword, - database, setDatabase, - connectionString, setConnectionString, - mongoConnectionMode, setMongoConnectionMode, - environment, setEnvironment, + type, + setType, + name, + setName, + host, + setHost, + port, + setPort, + user, + setUser, + password, + setPassword, + database, + setDatabase, + connectionString, + setConnectionString, + mongoConnectionMode, + setMongoConnectionMode, + environment, + setEnvironment, // UI state isTesting, - testResult, setTestResult, - pasteInput, setPasteInput, - showPasteInput, setShowPasteInput, + testResult, + setTestResult, + pasteInput, + setPasteInput, + showPasteInput, + setShowPasteInput, isEditMode, // SSL/TLS - showSSL, setShowSSL, - sslMode, setSSLMode, - caCert, setCaCert, - clientCert, setClientCert, - clientKey, setClientKey, + showSSL, + setShowSSL, + sslMode, + setSSLMode, + caCert, + setCaCert, + clientCert, + setClientCert, + clientKey, + setClientKey, // Advanced (Oracle/MSSQL) - showAdvanced, setShowAdvanced, - serviceName, setServiceName, - instanceName, setInstanceName, + showAdvanced, + setShowAdvanced, + serviceName, + setServiceName, + instanceName, + setInstanceName, // SSH Tunnel - showSSH, setShowSSH, - sshEnabled, setSSHEnabled, - sshHost, setSSHHost, - sshPort, setSSHPort, - sshUsername, setSSHUsername, - sshAuthMethod, setSSHAuthMethod, - sshPassword, setSSHPassword, - sshPrivateKey, setSSHPrivateKey, - sshPassphrase, setSSHPassphrase, + showSSH, + setShowSSH, + sshEnabled, + setSSHEnabled, + sshHost, + setSSHHost, + sshPort, + setSSHPort, + sshUsername, + setSSHUsername, + sshAuthMethod, + setSSHAuthMethod, + sshPassword, + setSSHPassword, + sshPrivateKey, + setSSHPrivateKey, + sshPassphrase, + setSSHPassphrase, // Handlers handleTestConnection, @@ -82,7 +140,7 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on
    @@ -95,12 +153,14 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on

    - {isEditMode ? 'Edit Connection' : 'New Connection'} + {isEditMode ? "Edit Connection" : "New Connection"}

    - {isEditMode ? 'Update your database connection parameters.' : 'Configure your database connection parameters securely.'} + {isEditMode + ? "Update your database connection parameters." + : "Configure your database connection parameters securely."}

    {!isEditMode && ( ))}
    @@ -209,10 +266,15 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on type === db.value ? "bg-blue-600/10 border-blue-500/50 shadow-[0_0_20px_rgba(59,130,246,0.1)]" : "bg-zinc-900/50 border-white/5 hover:border-white/10 hover:bg-zinc-900", - isEditMode && type !== db.value && "opacity-30 cursor-not-allowed" + isEditMode && type !== db.value && "opacity-30 cursor-not-allowed", )} > - + {db.label} @@ -220,155 +282,169 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on ))} -
    - <> - {/* Connection string mode toggle */} - {getDBConfig(type).showConnectionStringToggle && ( -
    - - +
    + <> + {/* Connection string mode toggle */} + {getDBConfig(type).showConnectionStringToggle && ( +
    + + +
    + )} + + {getDBConfig(type).showConnectionStringToggle && mongoConnectionMode === "connectionString" ? ( + <> +
    +
    + +
    - )} + setConnectionString(e.target.value)} + placeholder="mongodb://localhost:27017/mydb or mongodb+srv://..." + className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" + /> +
    +
    +
    + + +
    + setDatabase(e.target.value)} + placeholder="Extracted from URI if not provided" + className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" + /> +
    + + ) : isFileBased(type) ? ( +
    +
    + + +
    + setDatabase(e.target.value)} + placeholder="/path/to/database file" + className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" + /> +
    + ) : ( + <> +
    +
    + + +
    +
    + setHost(e.target.value)} + placeholder="localhost" + className="md:col-span-3 h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs" + /> + setPort(e.target.value)} + className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" + /> +
    +
    - {getDBConfig(type).showConnectionStringToggle && mongoConnectionMode === 'connectionString' ? ( - <> -
    -
    - - -
    - setConnectionString(e.target.value)} - placeholder="mongodb://localhost:27017/mydb or mongodb+srv://..." - className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" - /> -
    -
    -
    - - -
    - setDatabase(e.target.value)} - placeholder="Extracted from URI if not provided" - className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" - /> -
    - - ) : isFileBased(type) ? ( +
    - - + +
    setDatabase(e.target.value)} - placeholder="/path/to/database file" - className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" + id="user" + value={user} + onChange={(e) => setUser(e.target.value)} + placeholder="postgres" + className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs" />
    - ) : ( - <> -
    -
    - - -
    -
    - setHost(e.target.value)} - placeholder="localhost" - className="md:col-span-3 h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs" - /> - setPort(e.target.value)} - className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" - /> -
    -
    - -
    -
    -
    - - -
    - setUser(e.target.value)} - placeholder="postgres" - className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs" - /> -
    -
    -
    - - -
    - setPassword(e.target.value)} - placeholder="••••••••" - className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs" - /> -
    +
    +
    + +
    + setPassword(e.target.value)} + placeholder="••••••••" + className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs" + /> +
    +
    -
    -
    - - -
    - setDatabase(e.target.value)} - placeholder="production_db" - className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" - /> -
    - - )} - -
    +
    +
    + + +
    + setDatabase(e.target.value)} + placeholder="production_db" + className="h-10 bg-zinc-900/50 border-white/5 focus:border-blue-500/50 transition-all text-xs font-mono" + /> +
    + + )} + +
    {/* Advanced Settings (Oracle/MSSQL) */} - {(type === 'oracle' || type === 'mssql') && ( + {(type === "oracle" || type === "mssql") && (
    - {sslMode !== 'disable' && ( + {sslMode !== "disable" && (
    @@ -487,7 +563,7 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on className="w-full rounded-md bg-zinc-900/50 border border-white/5 focus:border-emerald-500/50 text-xs font-mono text-zinc-300 p-2 resize-none placeholder:text-zinc-600" />
    - {(sslMode === 'verify-ca' || sslMode === 'verify-full') && ( + {(sslMode === "verify-ca" || sslMode === "verify-full") && ( <>
    @@ -537,7 +613,7 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on {showSSH && ( @@ -586,31 +662,31 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on
    - {sshAuthMethod === 'password' ? ( + {sshAuthMethod === "password" ? (
    -
    +
    {testResult.success ? ( ) : ( @@ -705,12 +783,17 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on Testing...
    ) : ( - 'Test Connection' + "Test Connection" )}
    - + setTableName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))} + onChange={(e) => setTableName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, "_"))} className="bg-zinc-900 border-white/5 focus-visible:ring-blue-500/50" />
    @@ -160,9 +146,9 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl
    - - diff --git a/src/components/DataCharts.tsx b/src/components/DataCharts.tsx index 672a27a1..0e977bdc 100644 --- a/src/components/DataCharts.tsx +++ b/src/components/DataCharts.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useMemo, useRef, useCallback } from 'react'; +import React, { useState, useMemo, useRef, useCallback } from "react"; import { BarChart, Bar, @@ -20,9 +20,9 @@ import { Legend, Cell, ResponsiveContainer, -} from 'recharts'; -import { QueryResult } from '@/lib/types'; -import { cn } from '@/lib/utils'; +} from "recharts"; +import { QueryResult } from "@/lib/types"; +import { cn } from "@/lib/utils"; import { BarChart3, LineChart as LineChartIcon, @@ -40,43 +40,37 @@ import { Save, FolderOpen, X, -} from 'lucide-react'; -import { Button } from '@/components/ui/button'; +} from "lucide-react"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { storage } from '@/lib/storage'; +} from "@/components/ui/dropdown-menu"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { storage } from "@/lib/storage"; // Chart colors matching CSS variables const CHART_COLORS = [ - 'hsl(217, 91%, 60%)', // Blue - 'hsl(142, 71%, 45%)', // Green - 'hsl(38, 92%, 50%)', // Amber - 'hsl(270, 91%, 65%)', // Purple - 'hsl(330, 81%, 60%)', // Pink - 'hsl(199, 89%, 48%)', // Cyan - 'hsl(24, 95%, 53%)', // Orange - 'hsl(162, 63%, 41%)', // Teal + "hsl(217, 91%, 60%)", // Blue + "hsl(142, 71%, 45%)", // Green + "hsl(38, 92%, 50%)", // Amber + "hsl(270, 91%, 65%)", // Purple + "hsl(330, 81%, 60%)", // Pink + "hsl(199, 89%, 48%)", // Cyan + "hsl(24, 95%, 53%)", // Orange + "hsl(162, 63%, 41%)", // Teal ]; -type ChartType = 'bar' | 'line' | 'pie' | 'area' | 'scatter' | 'histogram' | 'stacked-bar' | 'stacked-area'; +type ChartType = "bar" | "line" | "pie" | "area" | "scatter" | "histogram" | "stacked-bar" | "stacked-area"; -export type AggregationType = 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max'; -export type DateGrouping = 'hour' | 'day' | 'week' | 'month' | 'year'; +export type AggregationType = "none" | "sum" | "avg" | "count" | "min" | "max"; +export type DateGrouping = "hour" | "day" | "week" | "month" | "year"; export interface FieldAnalysis { name: string; - type: 'numeric' | 'categorical' | 'date' | 'unknown'; + type: "numeric" | "categorical" | "date" | "unknown"; uniqueValues: number; hasNulls: boolean; sample: unknown; @@ -97,29 +91,30 @@ interface DataChartsProps { } export function analyzeField(name: string, values: unknown[]): FieldAnalysis { - const nonNullValues = values.filter(v => v !== null && v !== undefined); + const nonNullValues = values.filter((v) => v !== null && v !== undefined); const uniqueValues = new Set(nonNullValues).size; const sample = nonNullValues[0]; // Check if numeric - const numericCount = nonNullValues.filter(v => typeof v === 'number' || (typeof v === 'string' && !isNaN(Number(v)))).length; + const numericCount = nonNullValues.filter( + (v) => typeof v === "number" || (typeof v === "string" && !isNaN(Number(v))), + ).length; const isNumeric = numericCount > nonNullValues.length * 0.8; // Check if date const datePatterns = [ - /^\d{4}-\d{2}-\d{2}/, // ISO date + /^\d{4}-\d{2}-\d{2}/, // ISO date /^\d{2}\/\d{2}\/\d{4}/, // US date /^\d{2}\.\d{2}\.\d{4}/, // EU date ]; - const isDate = nonNullValues.some(v => - (typeof v === 'string' && datePatterns.some(p => p.test(v))) || - v instanceof Date + const isDate = nonNullValues.some( + (v) => (typeof v === "string" && datePatterns.some((p) => p.test(v))) || v instanceof Date, ); - let type: FieldAnalysis['type'] = 'unknown'; - if (isDate) type = 'date'; - else if (isNumeric) type = 'numeric'; - else if (uniqueValues <= 50) type = 'categorical'; + let type: FieldAnalysis["type"] = "unknown"; + if (isDate) type = "date"; + else if (isNumeric) type = "numeric"; + else if (uniqueValues <= 50) type = "categorical"; return { name, @@ -137,9 +132,9 @@ export function analyzeData(result: QueryResult | null): DataAnalysis { numericFields: [], categoricalFields: [], dateFields: [], - suggestedChartType: 'bar', + suggestedChartType: "bar", isVisualizable: false, - reason: 'No data to visualize', + reason: "No data to visualize", }; } @@ -149,20 +144,23 @@ export function analyzeData(result: QueryResult | null): DataAnalysis { numericFields: [], categoricalFields: [], dateFields: [], - suggestedChartType: 'bar', + suggestedChartType: "bar", isVisualizable: false, - reason: 'Need at least 2 rows for visualization', + reason: "Need at least 2 rows for visualization", }; } const fieldNames = result.fields || Object.keys(result.rows[0]); - const fields = fieldNames.map(name => - analyzeField(name, result.rows.map(row => row[name])) + const fields = fieldNames.map((name) => + analyzeField( + name, + result.rows.map((row) => row[name]), + ), ); - const numericFields = fields.filter(f => f.type === 'numeric').map(f => f.name); - const categoricalFields = fields.filter(f => f.type === 'categorical').map(f => f.name); - const dateFields = fields.filter(f => f.type === 'date').map(f => f.name); + const numericFields = fields.filter((f) => f.type === "numeric").map((f) => f.name); + const categoricalFields = fields.filter((f) => f.type === "categorical").map((f) => f.name); + const dateFields = fields.filter((f) => f.type === "date").map((f) => f.name); if (numericFields.length === 0) { return { @@ -170,23 +168,23 @@ export function analyzeData(result: QueryResult | null): DataAnalysis { numericFields, categoricalFields, dateFields, - suggestedChartType: 'bar', + suggestedChartType: "bar", isVisualizable: false, - reason: 'No numeric fields found for Y-axis', + reason: "No numeric fields found for Y-axis", }; } // Suggest chart type based on data - let suggestedChartType: ChartType = 'bar'; + let suggestedChartType: ChartType = "bar"; if (dateFields.length > 0) { - suggestedChartType = 'line'; // Time series → line chart + suggestedChartType = "line"; // Time series → line chart } else if (numericFields.length >= 2 && categoricalFields.length === 0) { - suggestedChartType = 'scatter'; // 2+ numeric, no categorical → scatter + suggestedChartType = "scatter"; // 2+ numeric, no categorical → scatter } else if (categoricalFields.length > 0 && result.rows.length <= 10) { - suggestedChartType = 'pie'; // Few categories → pie chart + suggestedChartType = "pie"; // Few categories → pie chart } else if (categoricalFields.length > 0) { - suggestedChartType = 'bar'; // Many categories → bar chart + suggestedChartType = "bar"; // Many categories → bar chart } return { @@ -201,10 +199,10 @@ export function analyzeData(result: QueryResult | null): DataAnalysis { export function formatNumber(value: number): string { if (Math.abs(value) >= 1000000) { - return (value / 1000000).toFixed(1) + 'M'; + return (value / 1000000).toFixed(1) + "M"; } if (Math.abs(value) >= 1000) { - return (value / 1000).toFixed(1) + 'K'; + return (value / 1000).toFixed(1) + "K"; } return value.toLocaleString(); } @@ -235,7 +233,10 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps) => { }; // Histogram bin calculation -export function computeHistogramBins(values: number[], buckets: number): { range: string; count: number; min: number; max: number }[] { +export function computeHistogramBins( + values: number[], + buckets: number, +): { range: string; count: number; min: number; max: number }[] { if (values.length === 0) return []; const min = Math.min(...values); const max = Math.max(...values); @@ -247,7 +248,7 @@ export function computeHistogramBins(values: number[], buckets: number): { range min: min + i * binWidth, max: min + (i + 1) * binWidth, })); - values.forEach(v => { + values.forEach((v) => { let idx = Math.floor((v - min) / binWidth); if (idx >= buckets) idx = buckets - 1; bins[idx].count++; @@ -260,13 +261,13 @@ export function aggregateData( rows: Record[], groupByField: string, metrics: { field: string; aggregation: AggregationType }[], - dateGrouping?: DateGrouping + dateGrouping?: DateGrouping, ): Record[] { - if (metrics.every(m => m.aggregation === 'none')) return rows; + if (metrics.every((m) => m.aggregation === "none")) return rows; const groups = new Map[]>(); - rows.forEach(row => { - let key = String(row[groupByField] ?? ''); + rows.forEach((row) => { + let key = String(row[groupByField] ?? ""); if (dateGrouping && key) { key = groupByDate(key, dateGrouping); } @@ -277,14 +278,25 @@ export function aggregateData( return Array.from(groups.entries()).map(([key, groupRows]) => { const result: Record = { [groupByField]: key }; metrics.forEach(({ field, aggregation }) => { - const values = groupRows.map(r => Number(r[field]) || 0); + const values = groupRows.map((r) => Number(r[field]) || 0); switch (aggregation) { - case 'sum': result[field] = values.reduce((a, b) => a + b, 0); break; - case 'avg': result[field] = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0; break; - case 'count': result[field] = values.length; break; - case 'min': result[field] = Math.min(...values); break; - case 'max': result[field] = Math.max(...values); break; - default: result[field] = values[0]; + case "sum": + result[field] = values.reduce((a, b) => a + b, 0); + break; + case "avg": + result[field] = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0; + break; + case "count": + result[field] = values.length; + break; + case "min": + result[field] = Math.min(...values); + break; + case "max": + result[field] = Math.max(...values); + break; + default: + result[field] = values[0]; } }); return result; @@ -295,12 +307,21 @@ export function groupByDate(dateStr: string, grouping: DateGrouping): string { const date = new Date(dateStr); if (isNaN(date.getTime())) return dateStr; switch (grouping) { - case 'hour': return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:00`; - case 'day': return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`; - case 'week': { const d = new Date(date); d.setDate(d.getDate() - d.getDay()); return `W${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } - case 'month': return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}`; - case 'year': return `${date.getFullYear()}`; - default: return dateStr; + case "hour": + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:00`; + case "day": + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + case "week": { + const d = new Date(date); + d.setDate(d.getDate() - d.getDay()); + return `W${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + } + case "month": + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + case "year": + return `${date.getFullYear()}`; + default: + return dateStr; } } @@ -309,31 +330,43 @@ export function DataCharts({ result }: DataChartsProps) { const analysis = useMemo(() => analyzeData(result), [result]); const [chartType, setChartType] = useState(analysis.suggestedChartType); - const [xAxis, setXAxis] = useState(''); + const [xAxis, setXAxis] = useState(""); const [yAxis, setYAxis] = useState([]); - const [scatterY, setScatterY] = useState(''); + const [scatterY, setScatterY] = useState(""); const [histogramBuckets, setHistogramBuckets] = useState(10); - const [aggregation, setAggregation] = useState('none'); - const [dateGrouping, setDateGrouping] = useState(''); + const [aggregation, setAggregation] = useState("none"); + const [dateGrouping, setDateGrouping] = useState(""); // Saved charts state - const [savedCharts, setSavedCharts] = useState<{ id: string; name: string; chartType: ChartType; xAxis: string; yAxis: string[]; aggregation: AggregationType; dateGrouping: string }[]>([]); + const [savedCharts, setSavedCharts] = useState< + { + id: string; + name: string; + chartType: ChartType; + xAxis: string; + yAxis: string[]; + aggregation: AggregationType; + dateGrouping: string; + }[] + >([]); const [showSaveDialog, setShowSaveDialog] = useState(false); - const [saveName, setSaveName] = useState(''); + const [saveName, setSaveName] = useState(""); // Load saved charts from storage React.useEffect(() => { const charts = storage.getSavedCharts(); if (charts.length > 0) { - setSavedCharts(charts.map(c => ({ - id: c.id, - name: c.name, - chartType: c.chartType as ChartType, - xAxis: c.xAxis, - yAxis: c.yAxis, - aggregation: (c.aggregation || 'none') as AggregationType, - dateGrouping: c.dateGrouping || '', - }))); + setSavedCharts( + charts.map((c) => ({ + id: c.id, + name: c.name, + chartType: c.chartType as ChartType, + xAxis: c.xAxis, + yAxis: c.yAxis, + aggregation: (c.aggregation || "none") as AggregationType, + dateGrouping: c.dateGrouping || "", + })), + ); } }, []); @@ -342,7 +375,7 @@ export function DataCharts({ result }: DataChartsProps) { if (analysis.isVisualizable) { setChartType(analysis.suggestedChartType); - const defaultX = analysis.categoricalFields[0] || analysis.dateFields[0] || analysis.fields[0]?.name || ''; + const defaultX = analysis.categoricalFields[0] || analysis.dateFields[0] || analysis.fields[0]?.name || ""; setXAxis(defaultX); if (analysis.numericFields.length > 0) { @@ -358,38 +391,38 @@ export function DataCharts({ result }: DataChartsProps) { if (!result?.rows) return []; // Histogram: special data preparation - if (chartType === 'histogram' && yAxis.length > 0) { - const values = result.rows.map(r => Number(r[yAxis[0]]) || 0).filter(v => !isNaN(v)); + if (chartType === "histogram" && yAxis.length > 0) { + const values = result.rows.map((r) => Number(r[yAxis[0]]) || 0).filter((v) => !isNaN(v)); return computeHistogramBins(values, histogramBuckets); } // Scatter: needs both axes as numeric - if (chartType === 'scatter') { + if (chartType === "scatter") { if (!xAxis || !scatterY) return []; - return result.rows.map(row => ({ - [xAxis]: typeof row[xAxis] === 'number' ? row[xAxis] : Number(row[xAxis]) || 0, - [scatterY]: typeof row[scatterY] === 'number' ? row[scatterY] : Number(row[scatterY]) || 0, + return result.rows.map((row) => ({ + [xAxis]: typeof row[xAxis] === "number" ? row[xAxis] : Number(row[xAxis]) || 0, + [scatterY]: typeof row[scatterY] === "number" ? row[scatterY] : Number(row[scatterY]) || 0, })); } if (!xAxis) return []; - const baseData = result.rows.map(row => { + const baseData = result.rows.map((row) => { const dataPoint: Record = { [xAxis]: row[xAxis] }; - yAxis.forEach(field => { + yAxis.forEach((field) => { const value = row[field]; - dataPoint[field] = typeof value === 'number' ? value : Number(value) || 0; + dataPoint[field] = typeof value === "number" ? value : Number(value) || 0; }); return dataPoint; }); // Apply aggregation if set - if (aggregation !== 'none' && yAxis.length > 0) { + if (aggregation !== "none" && yAxis.length > 0) { return aggregateData( baseData, xAxis, - yAxis.map(f => ({ field: f, aggregation })), - dateGrouping || undefined + yAxis.map((f) => ({ field: f, aggregation })), + dateGrouping || undefined, ); } @@ -398,8 +431,8 @@ export function DataCharts({ result }: DataChartsProps) { return aggregateData( baseData, xAxis, - yAxis.map(f => ({ field: f, aggregation: 'sum' })), - dateGrouping + yAxis.map((f) => ({ field: f, aggregation: "sum" })), + dateGrouping, ); } @@ -416,7 +449,7 @@ export function DataCharts({ result }: DataChartsProps) { xAxis, yAxis: [...yAxis], aggregation, - dateGrouping: dateGrouping || '', + dateGrouping: dateGrouping || "", }; const updated = [...savedCharts, newChart]; setSavedCharts(updated); @@ -431,52 +464,58 @@ export function DataCharts({ result }: DataChartsProps) { createdAt: new Date(), }); setShowSaveDialog(false); - setSaveName(''); + setSaveName(""); }, [saveName, chartType, xAxis, yAxis, aggregation, dateGrouping, savedCharts]); // Load saved chart config - const loadSavedChart = useCallback((chart: typeof savedCharts[0]) => { + const loadSavedChart = useCallback((chart: (typeof savedCharts)[0]) => { setChartType(chart.chartType); setXAxis(chart.xAxis); setYAxis(chart.yAxis); setAggregation(chart.aggregation); - setDateGrouping((chart.dateGrouping || '') as DateGrouping | ''); + setDateGrouping((chart.dateGrouping || "") as DateGrouping | ""); }, []); // Delete saved chart - const deleteSavedChart = useCallback((id: string) => { - const updated = savedCharts.filter(c => c.id !== id); - setSavedCharts(updated); - storage.deleteChart(id); - }, [savedCharts]); + const deleteSavedChart = useCallback( + (id: string) => { + const updated = savedCharts.filter((c) => c.id !== id); + setSavedCharts(updated); + storage.deleteChart(id); + }, + [savedCharts], + ); - const exportChart = useCallback(async (format: 'png' | 'svg') => { + const exportChart = useCallback(async (format: "png" | "svg") => { if (!chartRef.current) return; - if (format === 'png') { + if (format === "png") { try { // Dynamic import for html2canvas - const html2canvasModule = await import('html2canvas'); - const html2canvas = html2canvasModule.default as (element: HTMLElement, options?: { backgroundColor?: string; scale?: number }) => Promise; + const html2canvasModule = await import("html2canvas"); + const html2canvas = html2canvasModule.default as ( + element: HTMLElement, + options?: { backgroundColor?: string; scale?: number }, + ) => Promise; const canvas = await html2canvas(chartRef.current, { - backgroundColor: '#080808', + backgroundColor: "#080808", scale: 2, }); - const link = document.createElement('a'); + const link = document.createElement("a"); link.download = `chart_${Date.now()}.png`; - link.href = canvas.toDataURL('image/png'); + link.href = canvas.toDataURL("image/png"); link.click(); } catch (error) { - console.error('Failed to export PNG:', error); + console.error("Failed to export PNG:", error); } } else { // SVG export - find the SVG element - const svgElement = chartRef.current.querySelector('svg'); + const svgElement = chartRef.current.querySelector("svg"); if (svgElement) { const svgData = new XMLSerializer().serializeToString(svgElement); - const blob = new Blob([svgData], { type: 'image/svg+xml' }); + const blob = new Blob([svgData], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob); - const link = document.createElement('a'); + const link = document.createElement("a"); link.download = `chart_${Date.now()}.svg`; link.href = url; link.click(); @@ -486,9 +525,9 @@ export function DataCharts({ result }: DataChartsProps) { }, []); const toggleYAxis = (field: string) => { - setYAxis(prev => { + setYAxis((prev) => { if (prev.includes(field)) { - return prev.filter(f => f !== field); + return prev.filter((f) => f !== field); } return [...prev, field]; }); @@ -506,22 +545,26 @@ export function DataCharts({ result }: DataChartsProps) { } const chartTypes: { type: ChartType; icon: React.ReactNode; label: string }[] = [ - { type: 'bar', icon: , label: 'Bar' }, - { type: 'line', icon: , label: 'Line' }, - { type: 'pie', icon: , label: 'Pie' }, - { type: 'area', icon: , label: 'Area' }, - { type: 'scatter', icon: , label: 'Scatter' }, - { type: 'histogram', icon: , label: 'Histogram' }, - { type: 'stacked-bar', icon: , label: 'Stacked' }, - { type: 'stacked-area', icon: , label: 'Stack Area' }, + { type: "bar", icon: , label: "Bar" }, + { type: "line", icon: , label: "Line" }, + { type: "pie", icon: , label: "Pie" }, + { type: "area", icon: , label: "Area" }, + { type: "scatter", icon: , label: "Scatter" }, + { type: "histogram", icon: , label: "Histogram" }, + { type: "stacked-bar", icon: , label: "Stacked" }, + { type: "stacked-area", icon: , label: "Stack Area" }, ]; - const getFieldIcon = (type: FieldAnalysis['type']) => { + const getFieldIcon = (type: FieldAnalysis["type"]) => { switch (type) { - case 'numeric': return ; - case 'date': return ; - case 'categorical': return ; - default: return ; + case "numeric": + return ; + case "date": + return ; + case "categorical": + return ; + default: + return ; } }; @@ -537,9 +580,7 @@ export function DataCharts({ result }: DataChartsProps) { onClick={() => setChartType(type)} className={cn( "flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all", - chartType === type - ? "bg-blue-600 text-white" - : "text-zinc-500 hover:text-zinc-300 hover:bg-white/5" + chartType === type ? "bg-blue-600 text-white" : "text-zinc-500 hover:text-zinc-300 hover:bg-white/5", )} title={label} > @@ -552,7 +593,7 @@ export function DataCharts({ result }: DataChartsProps) {
    {/* X-Axis Selector */} - {chartType !== 'pie' && ( + {chartType !== "pie" && (
    X-Axis @@ -613,16 +649,20 @@ export function DataCharts({ result }: DataChartsProps) { - {analysis.numericFields.filter(f => f !== xAxis).map(field => ( - {field} - ))} + {analysis.numericFields + .filter((f) => f !== xAxis) + .map((field) => ( + + {field} + + ))}
    )} {/* Histogram buckets */} - {chartType === 'histogram' && ( + {chartType === "histogram" && (
    Buckets @@ -639,7 +681,7 @@ export function DataCharts({ result }: DataChartsProps) { )} {/* Aggregation */} - {chartType !== 'scatter' && chartType !== 'histogram' && chartType !== 'pie' && ( + {chartType !== "scatter" && chartType !== "histogram" && chartType !== "pie" && (
    Agg @@ -656,17 +700,24 @@ export function DataCharts({ result }: DataChartsProps) { )} {/* Date Grouping */} - {analysis.dateFields.length > 0 && chartType !== 'scatter' && chartType !== 'histogram' && ( + {analysis.dateFields.length > 0 && chartType !== "scatter" && chartType !== "histogram" && (
    Group - setDateGrouping(v === "none" ? "" : (v as DateGrouping))} + > - None - {(['hour', 'day', 'week', 'month', 'year'] as const).map(g => ( - {g} + + None + + {(["hour", "day", "week", "month", "year"] as const).map((g) => ( + + {g} + ))} @@ -684,16 +735,30 @@ export function DataCharts({ result }: DataChartsProps) { placeholder="Chart name..." value={saveName} onChange={(e) => setSaveName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSaveChart()} + onKeyDown={(e) => e.key === "Enter" && handleSaveChart()} className="h-7 px-2 text-xs bg-white/5 border border-white/10 rounded text-zinc-300 focus:outline-none focus:border-blue-500" autoFocus /> - - + +
    ) : (
    - {savedCharts.length > 0 && ( @@ -704,10 +769,21 @@ export function DataCharts({ result }: DataChartsProps) { - {savedCharts.map(chart => ( - - loadSavedChart(chart)}>{chart.name} ({chart.chartType}) - @@ -726,10 +802,10 @@ export function DataCharts({ result }: DataChartsProps) { - exportChart('png')} className="text-xs cursor-pointer"> + exportChart("png")} className="text-xs cursor-pointer"> Export as PNG - exportChart('svg')} className="text-xs cursor-pointer"> + exportChart("svg")} className="text-xs cursor-pointer"> Export as SVG @@ -744,20 +820,11 @@ export function DataCharts({ result }: DataChartsProps) {
    ) : ( - {chartType === 'bar' ? ( + {chartType === "bar" ? ( - - + + } /> {yAxis.map((field, index) => ( @@ -769,20 +836,11 @@ export function DataCharts({ result }: DataChartsProps) { /> ))} - ) : chartType === 'line' ? ( + ) : chartType === "line" ? ( - - + + } /> {yAxis.map((field, index) => ( @@ -797,20 +855,11 @@ export function DataCharts({ result }: DataChartsProps) { /> ))} - ) : chartType === 'area' ? ( + ) : chartType === "area" ? ( - - + + } /> {yAxis.map((field, index) => ( @@ -825,88 +874,54 @@ export function DataCharts({ result }: DataChartsProps) { /> ))} - ) : chartType === 'scatter' ? ( + ) : chartType === "scatter" ? ( - } cursor={{ strokeDasharray: '3 3' }} /> - + } cursor={{ strokeDasharray: "3 3" }} /> + - ) : chartType === 'histogram' ? ( + ) : chartType === "histogram" ? ( - + } /> - ) : chartType === 'stacked-bar' ? ( + ) : chartType === "stacked-bar" ? ( - - + + } /> {yAxis.map((field, index) => ( - + ))} - ) : chartType === 'stacked-area' ? ( + ) : chartType === "stacked-area" ? ( - - + + } /> {yAxis.map((field, index) => ( @@ -931,13 +946,10 @@ export function DataCharts({ result }: DataChartsProps) { cy="50%" outerRadius="70%" label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`} - labelLine={{ stroke: '#444' }} + labelLine={{ stroke: "#444" }} > {chartData.slice(0, 10).map((_entry, index) => ( - + ))} } /> @@ -950,12 +962,16 @@ export function DataCharts({ result }: DataChartsProps) { {/* Footer Stats */}
    - Rows: {result?.rows.length || 0} - Fields: {analysis.fields.length} - Numeric: {analysis.numericFields.length} - {chartType === 'pie' && chartData.length > 10 && ( - Showing top 10 values - )} + + Rows: {result?.rows.length || 0} + + + Fields: {analysis.fields.length} + + + Numeric: {analysis.numericFields.length} + + {chartType === "pie" && chartData.length > 10 && Showing top 10 values}
    ); diff --git a/src/components/DataImportModal.tsx b/src/components/DataImportModal.tsx index 4722ffa3..01d3a7f4 100644 --- a/src/components/DataImportModal.tsx +++ b/src/components/DataImportModal.tsx @@ -1,15 +1,10 @@ "use client"; -import React, { useState, useCallback, useRef, useMemo } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { cn } from '@/lib/utils'; +import React, { useState, useCallback, useRef, useMemo } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; import { Upload, FileSpreadsheet, @@ -21,8 +16,8 @@ import { ArrowRight, Loader2, X, -} from 'lucide-react'; -import type { TableSchema } from '@/lib/types'; +} from "lucide-react"; +import type { TableSchema } from "@/lib/types"; interface DataImportModalProps { isOpen: boolean; @@ -38,16 +33,16 @@ export interface ParsedData { totalRows: number; } -type ImportStep = 'upload' | 'preview' | 'configure' | 'ready'; +type ImportStep = "upload" | "preview" | "configure" | "ready"; export function parseCSV(text: string): ParsedData { - const lines = text.split(/\r?\n/).filter(line => line.trim()); + const lines = text.split(/\r?\n/).filter((line) => line.trim()); if (lines.length === 0) return { headers: [], rows: [], totalRows: 0 }; // Parse CSV with basic quote handling const parseLine = (line: string): string[] => { const result: string[] = []; - let current = ''; + let current = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { @@ -61,9 +56,9 @@ export function parseCSV(text: string): ParsedData { } else { inQuotes = false; } - } else if (ch === ',' && !inQuotes) { + } else if (ch === "," && !inQuotes) { result.push(current.trim()); - current = ''; + current = ""; } else { current += ch; } @@ -73,7 +68,7 @@ export function parseCSV(text: string): ParsedData { }; const headers = parseLine(lines[0]); - const rows = lines.slice(1).map(line => parseLine(line)); + const rows = lines.slice(1).map((line) => parseLine(line)); return { headers, rows, totalRows: rows.length }; } @@ -82,35 +77,37 @@ export function parseJSON(text: string): ParsedData { const arr = Array.isArray(data) ? data : [data]; if (arr.length === 0) return { headers: [], rows: [], totalRows: 0 }; - const headers = [...new Set(arr.flatMap(obj => Object.keys(obj)))]; - const rows = arr.map(obj => headers.map(h => { - const val = obj[h]; - if (val === null || val === undefined) return ''; - if (typeof val === 'object') return JSON.stringify(val); - return String(val); - })); + const headers = [...new Set(arr.flatMap((obj) => Object.keys(obj)))]; + const rows = arr.map((obj) => + headers.map((h) => { + const val = obj[h]; + if (val === null || val === undefined) return ""; + if (typeof val === "object") return JSON.stringify(val); + return String(val); + }), + ); return { headers, rows, totalRows: rows.length }; } export function inferSqlType(values: string[]): string { - const nonEmpty = values.filter(v => v !== '' && v !== null); - if (nonEmpty.length === 0) return 'TEXT'; + const nonEmpty = values.filter((v) => v !== "" && v !== null); + if (nonEmpty.length === 0) return "TEXT"; - const allIntegers = nonEmpty.every(v => /^-?\d+$/.test(v)); - if (allIntegers) return 'INTEGER'; + const allIntegers = nonEmpty.every((v) => /^-?\d+$/.test(v)); + if (allIntegers) return "INTEGER"; - const allNumbers = nonEmpty.every(v => /^-?\d+(\.\d+)?$/.test(v)); - if (allNumbers) return 'NUMERIC'; + const allNumbers = nonEmpty.every((v) => /^-?\d+(\.\d+)?$/.test(v)); + if (allNumbers) return "NUMERIC"; - const allBooleans = nonEmpty.every(v => /^(true|false|0|1)$/i.test(v)); - if (allBooleans) return 'BOOLEAN'; + const allBooleans = nonEmpty.every((v) => /^(true|false|0|1)$/i.test(v)); + if (allBooleans) return "BOOLEAN"; - return 'TEXT'; + return "TEXT"; } export function escapeSQL(value: string): string { - if (value === '' || value === 'null' || value === 'NULL') return 'NULL'; + if (value === "" || value === "null" || value === "NULL") return "NULL"; return `'${value.replace(/'/g, "''")}'`; } @@ -121,71 +118,69 @@ export function generateImportSQL( newTableName: string, columnMapping: Record, ): string { - if (!parsedData) return ''; + if (!parsedData) return ""; - const tableName = createNewTable ? (newTableName || 'imported_data') : targetTable; - if (!tableName) return ''; + const tableName = createNewTable ? newTableName || "imported_data" : targetTable; + if (!tableName) return ""; const statements: string[] = []; // CREATE TABLE if new if (createNewTable) { - const colDefs = parsedData.headers.map(h => { - const colValues = parsedData.rows.slice(0, 100).map(r => r[parsedData.headers.indexOf(h)]); + const colDefs = parsedData.headers.map((h) => { + const colValues = parsedData.rows.slice(0, 100).map((r) => r[parsedData.headers.indexOf(h)]); const sqlType = inferSqlType(colValues); const colName = columnMapping[h] || h; return ` ${colName} ${sqlType}`; }); - statements.push(`CREATE TABLE ${tableName} (\n${colDefs.join(',\n')}\n);`); + statements.push(`CREATE TABLE ${tableName} (\n${colDefs.join(",\n")}\n);`); } // INSERT statements (batch in groups of 100) - const mappedHeaders = parsedData.headers.map(h => columnMapping[h] || h); + const mappedHeaders = parsedData.headers.map((h) => columnMapping[h] || h); const batchSize = 100; for (let i = 0; i < parsedData.rows.length; i += batchSize) { const batch = parsedData.rows.slice(i, i + batchSize); - const valueRows = batch.map(row => { + const valueRows = batch.map((row) => { const values = row.map((val, idx) => { - const sqlType = inferSqlType(parsedData.rows.slice(0, 100).map(r => r[idx])); - if (val === '' || val === 'NULL' || val === 'null') return 'NULL'; - if (sqlType === 'INTEGER' || sqlType === 'NUMERIC' || sqlType === 'BOOLEAN') { - if (sqlType === 'BOOLEAN') return val.toLowerCase() === 'true' || val === '1' ? 'TRUE' : 'FALSE'; + const sqlType = inferSqlType(parsedData.rows.slice(0, 100).map((r) => r[idx])); + if (val === "" || val === "NULL" || val === "null") return "NULL"; + if (sqlType === "INTEGER" || sqlType === "NUMERIC" || sqlType === "BOOLEAN") { + if (sqlType === "BOOLEAN") return val.toLowerCase() === "true" || val === "1" ? "TRUE" : "FALSE"; return val; } return escapeSQL(val); }); - return ` (${values.join(', ')})`; + return ` (${values.join(", ")})`; }); - statements.push( - `INSERT INTO ${tableName} (${mappedHeaders.join(', ')})\nVALUES\n${valueRows.join(',\n')};` - ); + statements.push(`INSERT INTO ${tableName} (${mappedHeaders.join(", ")})\nVALUES\n${valueRows.join(",\n")};`); } - return statements.join('\n\n'); + return statements.join("\n\n"); } export function DataImportModal({ isOpen, onClose, onImport, tables, databaseType }: DataImportModalProps) { - const [step, setStep] = useState('upload'); + const [step, setStep] = useState("upload"); const [parsedData, setParsedData] = useState(null); - const [fileName, setFileName] = useState(''); - const [fileType, setFileType] = useState<'csv' | 'json'>('csv'); - const [targetTable, setTargetTable] = useState(''); + const [fileName, setFileName] = useState(""); + const [fileType, setFileType] = useState<"csv" | "json">("csv"); + const [targetTable, setTargetTable] = useState(""); const [createNewTable, setCreateNewTable] = useState(false); - const [newTableName, setNewTableName] = useState(''); + const [newTableName, setNewTableName] = useState(""); const [columnMapping, setColumnMapping] = useState>({}); const [error, setError] = useState(null); const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); const resetState = useCallback(() => { - setStep('upload'); + setStep("upload"); setParsedData(null); - setFileName(''); - setTargetTable(''); + setFileName(""); + setTargetTable(""); setCreateNewTable(false); - setNewTableName(''); + setNewTableName(""); setColumnMapping({}); setError(null); setIsImporting(false); @@ -200,9 +195,9 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp setError(null); setFileName(file.name); - const ext = file.name.split('.').pop()?.toLowerCase(); - const isJSON = ext === 'json'; - setFileType(isJSON ? 'json' : 'csv'); + const ext = file.name.split(".").pop()?.toLowerCase(); + const isJSON = ext === "json"; + setFileType(isJSON ? "json" : "csv"); const reader = new FileReader(); reader.onload = (e) => { @@ -211,28 +206,33 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp const data = isJSON ? parseJSON(text) : parseCSV(text); if (data.headers.length === 0) { - setError('No data found in file'); + setError("No data found in file"); return; } setParsedData(data); // Auto-map columns 1:1 const mapping: Record = {}; - data.headers.forEach(h => { mapping[h] = h; }); + data.headers.forEach((h) => { + mapping[h] = h; + }); setColumnMapping(mapping); - setStep('preview'); + setStep("preview"); } catch (err) { - setError(`Failed to parse file: ${err instanceof Error ? err.message : 'Unknown error'}`); + setError(`Failed to parse file: ${err instanceof Error ? err.message : "Unknown error"}`); } }; reader.readAsText(file); }, []); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - if (file) handleFileSelect(file); - }, [handleFileSelect]); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (file) handleFileSelect(file); + }, + [handleFileSelect], + ); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -259,35 +259,43 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp Import Data - {fileName && ( - - {fileName} - - )} + {fileName && {fileName}} {/* Step Indicator */}
    - {(['upload', 'preview', 'configure', 'ready'] as ImportStep[]).map((s, idx) => ( + {(["upload", "preview", "configure", "ready"] as ImportStep[]).map((s, idx) => ( -
    -
    - {idx < ['upload', 'preview', 'configure', 'ready'].indexOf(step) ? ( +
    +
    + {idx < ["upload", "preview", "configure", "ready"].indexOf(step) ? ( ) : ( idx + 1 )}
    - {s === 'upload' ? 'Upload' : s === 'preview' ? 'Preview' : s === 'configure' ? 'Configure' : 'Import'} + + {s === "upload" ? "Upload" : s === "preview" ? "Preview" : s === "configure" ? "Configure" : "Import"} +
    {idx < 3 && } @@ -296,7 +304,7 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp
    {/* Step 1: Upload */} - {step === 'upload' && ( + {step === "upload" && (
    -

    - Drop a file here or click to browse -

    -

    - Supports CSV and JSON files -

    +

    Drop a file here or click to browse

    +

    Supports CSV and JSON files

    @@ -342,11 +346,11 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp )} {/* Step 2: Preview */} - {step === 'preview' && parsedData && ( + {step === "preview" && parsedData && (
    - {fileType === 'json' ? ( + {fileType === "json" ? ( ) : ( @@ -362,7 +366,9 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp variant="ghost" size="sm" className="h-7 text-xs text-zinc-500" - onClick={() => { resetState(); }} + onClick={() => { + resetState(); + }} > Reset @@ -373,8 +379,11 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp - {parsedData.headers.map(h => ( - ))} @@ -384,7 +393,10 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp {parsedData.rows.slice(0, 10).map((row, idx) => ( {row.map((cell, cidx) => ( - ))} @@ -403,7 +415,7 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp @@ -412,7 +424,7 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp )} {/* Step 3: Configure */} - {step === 'configure' && parsedData && ( + {step === "configure" && parsedData && (
    {/* Target Table */}
    @@ -421,7 +433,9 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp
    @@ -477,13 +495,16 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp Target Column
    - {parsedData.headers.map(header => ( -
    + {parsedData.headers.map((header) => ( +
    {header} setColumnMapping(prev => ({ ...prev, [header]: e.target.value }))} + value={columnMapping[header] || ""} + onChange={(e) => setColumnMapping((prev) => ({ ...prev, [header]: e.target.value }))} className="h-7 text-xs bg-[#111] border-white/10" placeholder={header} /> @@ -498,14 +519,14 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp variant="ghost" size="sm" className="h-8 text-xs text-zinc-500" - onClick={() => setStep('preview')} + onClick={() => setStep("preview")} > Back @@ -566,9 +585,13 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp disabled={isImporting} > {isImporting ? ( - <> Importing... + <> + Importing... + ) : ( - <> Execute Import + <> + Execute Import + )}
    diff --git a/src/components/DataProfiler.tsx b/src/components/DataProfiler.tsx index e91b8bcc..92950df9 100644 --- a/src/components/DataProfiler.tsx +++ b/src/components/DataProfiler.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useEffect, useMemo } from 'react'; -import { Loader2, BarChart3, X, Hash, AlertCircle, Sparkles, Lock } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { TableSchema, DatabaseConnection } from '@/lib/types'; -import { detectSensitiveColumns, maskValue } from '@/lib/data-masking'; +import { useState, useEffect, useMemo } from "react"; +import { Loader2, BarChart3, X, Hash, AlertCircle, Sparkles, Lock } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { TableSchema, DatabaseConnection } from "@/lib/types"; +import { detectSensitiveColumns, maskValue } from "@/lib/data-masking"; interface ColumnProfile { name: string; @@ -52,14 +52,14 @@ export function DataProfiler({ }: DataProfilerProps) { const [isLoading, setIsLoading] = useState(false); const [profile, setProfile] = useState(null); - const [aiSummary, setAiSummary] = useState(''); + const [aiSummary, setAiSummary] = useState(""); const [isAiLoading, setIsAiLoading] = useState(false); const [error, setError] = useState(null); // Detect sensitive columns for masking sample values in profiler const sensitiveColumnNames = useMemo(() => { if (!tableSchema?.columns) return new Map(); - return detectSensitiveColumns(tableSchema.columns.map(c => c.name)); + return detectSensitiveColumns(tableSchema.columns.map((c) => c.name)); }, [tableSchema]); useEffect(() => { @@ -68,10 +68,10 @@ export function DataProfiler({ } return () => { setProfile(null); - setAiSummary(''); + setAiSummary(""); setError(null); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, tableName]); const fetchProfile = async () => { @@ -87,16 +87,16 @@ export function DataProfiler({ data = await onProfile({ connectionId: connection.id, tableName }); } else { // Default: existing fetch behavior - const columns = tableSchema.columns?.map(c => c.name) || []; - const response = await fetch('/api/db/profile', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const columns = tableSchema.columns?.map((c) => c.name) || []; + const response = await fetch("/api/db/profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ connection, tableName, columns }), }); if (!response.ok) { const err = await response.json(); - throw new Error(err.error || 'Profile failed'); + throw new Error(err.error || "Profile failed"); } data = await response.json(); @@ -107,7 +107,7 @@ export function DataProfiler({ // Trigger AI summary fetchAiSummary(data); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : "Unknown error"); } finally { setIsLoading(false); } @@ -116,11 +116,14 @@ export function DataProfiler({ const fetchAiSummary = async (data: ProfileData) => { setIsAiLoading(true); try { - const profileSummary = data.columns.map(c => - `${c.name}: ${c.nullPercent}% null, ${c.distinctCount} distinct, min=${c.minValue || 'N/A'}, max=${c.maxValue || 'N/A'}` - ).join('\n'); + const profileSummary = data.columns + .map( + (c) => + `${c.name}: ${c.nullPercent}% null, ${c.distinctCount} distinct, min=${c.minValue || "N/A"}, max=${c.maxValue || "N/A"}`, + ) + .join("\n"); - const fullSchemaContext = `Table: ${tableName} (${data.totalRows} rows)\n\nColumn Profiles:\n${profileSummary}\n\nSchema:\n${schemaContext || ''}`; + const fullSchemaContext = `Table: ${tableName} (${data.totalRows} rows)\n\nColumn Profiles:\n${profileSummary}\n\nSchema:\n${schemaContext || ""}`; if (onDescribeSchema) { // Platform adapter: use callback instead of fetch @@ -128,13 +131,13 @@ export function DataProfiler({ setAiSummary(result); } else { // Default: existing fetch behavior - const response = await fetch('/api/ai/describe-schema', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/ai/describe-schema", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schemaContext: fullSchemaContext, databaseType, - mode: 'table', + mode: "table", }), }); @@ -143,7 +146,7 @@ export function DataProfiler({ const reader = response.body?.getReader(); if (!reader) return; - let full = ''; + let full = ""; while (true) { const { done, value } = await reader.read(); if (done) break; @@ -208,7 +211,8 @@ export function DataProfiler({

    {profile.columns.length > 0 ? Math.round(profile.columns.reduce((sum, c) => sum + c.nullPercent, 0) / profile.columns.length) - : 0}% + : 0} + %

    @@ -222,16 +226,14 @@ export function DataProfiler({
    {col.name} - {col.type && ( - {col.type} - )} + {col.type && {col.type}} {sensitiveColumnNames.has(col.name) && ( - + + + )}
    - - {col.distinctCount.toLocaleString()} distinct - + {col.distinctCount.toLocaleString()} distinct {col.error ? ( @@ -244,47 +246,57 @@ export function DataProfiler({
    50 ? "bg-red-500" : - col.nullPercent > 20 ? "bg-amber-500" : - "bg-emerald-500" + col.nullPercent > 50 + ? "bg-red-500" + : col.nullPercent > 20 + ? "bg-amber-500" + : "bg-emerald-500", )} style={{ width: `${100 - col.nullPercent}%` }} />
    - 50 ? "text-red-400" : - col.nullPercent > 20 ? "text-amber-400" : - "text-emerald-400" - )}> + 50 + ? "text-red-400" + : col.nullPercent > 20 + ? "text-amber-400" + : "text-emerald-400", + )} + > {col.nullPercent}% null {/* Min/Max */}
    - {col.minValue && (() => { - const rule = sensitiveColumnNames.get(col.name); - const display = rule - ? maskValue(col.minValue, rule) - : col.minValue.substring(0, 30); - return ( - - min: {display} - - ); - })()} - {col.maxValue && (() => { - const rule = sensitiveColumnNames.get(col.name); - const display = rule - ? maskValue(col.maxValue, rule) - : col.maxValue.substring(0, 30); - return ( - - max: {display} - - ); - })()} + {col.minValue && + (() => { + const rule = sensitiveColumnNames.get(col.name); + const display = rule ? maskValue(col.minValue, rule) : col.minValue.substring(0, 30); + return ( + + min:{" "} + + {display} + + + ); + })()} + {col.maxValue && + (() => { + const rule = sensitiveColumnNames.get(col.name); + const display = rule ? maskValue(col.maxValue, rule) : col.maxValue.substring(0, 30); + return ( + + max:{" "} + + {display} + + + ); + })()}
    {/* Sample Values */} @@ -292,11 +304,15 @@ export function DataProfiler({
    {col.sampleValues.map((val, i) => { const rule = sensitiveColumnNames.get(col.name); - const display = rule - ? maskValue(val, rule) - : val.substring(0, 20); + const display = rule ? maskValue(val, rule) : val.substring(0, 20); return ( - + {display} ); @@ -314,15 +330,11 @@ export function DataProfiler({
    - - AI Analysis - + AI Analysis {isAiLoading && }
    {aiSummary && ( -
    - {aiSummary} -
    +
    {aiSummary}
    )}
    )} diff --git a/src/components/DatabaseDocs.tsx b/src/components/DatabaseDocs.tsx index 8768faef..414697f0 100644 --- a/src/components/DatabaseDocs.tsx +++ b/src/components/DatabaseDocs.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { useState } from 'react'; -import { FileText, Loader2, Search, Sparkles, Download } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { TableSchema } from '@/lib/types'; +import React, { useState } from "react"; +import { FileText, Loader2, Search, Sparkles, Download } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { TableSchema } from "@/lib/types"; interface DatabaseDocsProps { schema: TableSchema[]; @@ -12,56 +12,70 @@ interface DatabaseDocsProps { } export function DatabaseDocs({ schema, schemaContext, databaseType }: DatabaseDocsProps) { - const [search, setSearch] = useState(''); - const [aiDocs, setAiDocs] = useState(''); + const [search, setSearch] = useState(""); + const [aiDocs, setAiDocs] = useState(""); const [isAiLoading, setIsAiLoading] = useState(false); const [error, setError] = useState(null); - const filteredSchema = schema.filter(t => - t.name.toLowerCase().includes(search.toLowerCase()) || - t.columns?.some(c => c.name.toLowerCase().includes(search.toLowerCase())) + const filteredSchema = schema.filter( + (t) => + t.name.toLowerCase().includes(search.toLowerCase()) || + t.columns?.some((c) => c.name.toLowerCase().includes(search.toLowerCase())), ); const generateAiDocs = async () => { setIsAiLoading(true); setError(null); - setAiDocs(''); + setAiDocs(""); try { - let filteredSchemaStr = ''; + let filteredSchemaStr = ""; if (schemaContext) { try { const tables = JSON.parse(schemaContext); - filteredSchemaStr = tables.slice(0, 50).map((t: { name: string; rowCount?: number; columns?: { name: string; type: string; isPrimary?: boolean; isNullable?: boolean }[] }) => { - const cols = t.columns?.map(c => - `${c.name} (${c.type}${c.isPrimary ? ', PK' : ''}${c.isNullable === false ? ', NOT NULL' : ''})` - ).join(', ') || ''; - return `Table: ${t.name} (${t.rowCount || 0} rows)\nColumns: ${cols}`; - }).join('\n\n'); + filteredSchemaStr = tables + .slice(0, 50) + .map( + (t: { + name: string; + rowCount?: number; + columns?: { name: string; type: string; isPrimary?: boolean; isNullable?: boolean }[]; + }) => { + const cols = + t.columns + ?.map( + (c) => + `${c.name} (${c.type}${c.isPrimary ? ", PK" : ""}${c.isNullable === false ? ", NOT NULL" : ""})`, + ) + .join(", ") || ""; + return `Table: ${t.name} (${t.rowCount || 0} rows)\nColumns: ${cols}`; + }, + ) + .join("\n\n"); } catch { filteredSchemaStr = schemaContext.substring(0, 5000); } } - const response = await fetch('/api/ai/describe-schema', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/ai/describe-schema", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schemaContext: filteredSchemaStr, databaseType, - mode: 'full', + mode: "full", }), }); if (!response.ok) { const err = await response.json(); - throw new Error(err.error || 'Documentation generation failed'); + throw new Error(err.error || "Documentation generation failed"); } const reader = response.body?.getReader(); - if (!reader) throw new Error('No reader'); + if (!reader) throw new Error("No reader"); - let full = ''; + let full = ""; while (true) { const { done, value } = await reader.read(); if (done) break; @@ -69,7 +83,7 @@ export function DatabaseDocs({ schema, schemaContext, databaseType }: DatabaseDo setAiDocs(full); } } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : "Unknown error"); } finally { setIsAiLoading(false); } @@ -77,7 +91,7 @@ export function DatabaseDocs({ schema, schemaContext, databaseType }: DatabaseDo const exportMarkdown = () => { let md = `# Database Documentation\n\n`; - md += `**Type:** ${databaseType || 'Unknown'}\n`; + md += `**Type:** ${databaseType || "Unknown"}\n`; md += `**Tables:** ${schema.length}\n\n`; if (aiDocs) { @@ -93,37 +107,61 @@ export function DatabaseDocs({ schema, schemaContext, databaseType }: DatabaseDo if (table.columns && table.columns.length > 0) { md += `| Column | Type | Primary | Nullable |\n|--------|------|---------|----------|\n`; for (const col of table.columns) { - md += `| ${col.name} | ${col.type} | ${col.isPrimary ? 'Yes' : ''} | ${col.nullable !== false ? 'Yes' : 'No'} |\n`; + md += `| ${col.name} | ${col.type} | ${col.isPrimary ? "Yes" : ""} | ${col.nullable !== false ? "Yes" : "No"} |\n`; } - md += '\n'; + md += "\n"; } } - const blob = new Blob([md], { type: 'text/markdown' }); + const blob = new Blob([md], { type: "text/markdown" }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; - a.download = 'database-docs.md'; + a.download = "database-docs.md"; a.click(); URL.revokeObjectURL(url); }; // Simple markdown rendering for AI docs const renderMarkdown = (text: string) => { - return text.split('\n').map((line, i) => { - if (line.startsWith('## ')) return

    {line.slice(3)}

    ; - if (line.startsWith('### ')) return

    {line.slice(4)}

    ; - if (line.startsWith('- ')) { + return text.split("\n").map((line, i) => { + if (line.startsWith("## ")) + return ( +

    + {line.slice(3)} +

    + ); + if (line.startsWith("### ")) + return ( +

    + {line.slice(4)} +

    + ); + if (line.startsWith("- ")) { const content = line.slice(2).replace(/\*\*(.*?)\*\*/g, '$1'); - return
  • ; + return ( +
  • + ); } if (line.match(/^\d+\.\s/)) { const content = line.replace(/\*\*(.*?)\*\*/g, '$1'); - return
  • ; + return ( +
  • + ); } if (line.trim()) { const content = line.replace(/\*\*(.*?)\*\*/g, '$1'); - return

    ; + return ( +

    + ); } return

    ; }); @@ -137,9 +175,7 @@ export function DatabaseDocs({ schema, schemaContext, databaseType }: DatabaseDo
    - - Database Docs - + Database Docs {schema.length} tables
    @@ -148,13 +184,15 @@ export function DatabaseDocs({ schema, schemaContext, databaseType }: DatabaseDo disabled={isAiLoading} className={cn( "flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors", - isAiLoading - ? "bg-teal-600/20 text-teal-400 cursor-wait" - : "bg-teal-600 hover:bg-teal-500 text-white" + isAiLoading ? "bg-teal-600/20 text-teal-400 cursor-wait" : "bg-teal-600 hover:bg-teal-500 text-white", )} > - {isAiLoading ? : } - {aiDocs ? 'Regenerate' : 'AI Describe'} + {isAiLoading ? ( + + ) : ( + + )} + {aiDocs ? "Regenerate" : "AI Describe"}
  • - {table.columns.map(col => ( + {table.columns.map((col) => ( - + ))} diff --git a/src/components/MaskingSettings.tsx b/src/components/MaskingSettings.tsx index e9b0cf92..a69abe62 100644 --- a/src/components/MaskingSettings.tsx +++ b/src/components/MaskingSettings.tsx @@ -1,35 +1,15 @@ -'use client'; +"use client"; -import React, { useState, useCallback } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; -import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Shield, - Plus, - Pencil, - Trash2, - RotateCcw, - Save, - Lock, -} from 'lucide-react'; -import { toast } from 'sonner'; +import React, { useState, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Shield, Plus, Pencil, Trash2, RotateCcw, Save, Lock } from "lucide-react"; +import { toast } from "sonner"; import { type MaskingConfig, type MaskingPattern, @@ -39,9 +19,20 @@ import { getPreviewMasked, loadMaskingConfig, saveMaskingConfig, -} from '@/lib/data-masking'; +} from "@/lib/data-masking"; -const ALL_MASK_TYPES: MaskType[] = ['email', 'phone', 'card', 'ssn', 'full', 'partial', 'ip', 'date', 'financial', 'custom']; +const ALL_MASK_TYPES: MaskType[] = [ + "email", + "phone", + "card", + "ssn", + "full", + "partial", + "ip", + "date", + "financial", + "custom", +]; export function MaskingSettings() { const [config, setConfig] = useState(() => loadMaskingConfig()); @@ -50,39 +41,35 @@ export function MaskingSettings() { const [isNewPattern, setIsNewPattern] = useState(false); // Edit dialog state - const [editName, setEditName] = useState(''); - const [editMaskType, setEditMaskType] = useState('full'); - const [editColumnPatterns, setEditColumnPatterns] = useState(''); - const [editCustomMask, setEditCustomMask] = useState(''); + const [editName, setEditName] = useState(""); + const [editMaskType, setEditMaskType] = useState("full"); + const [editColumnPatterns, setEditColumnPatterns] = useState(""); + const [editCustomMask, setEditCustomMask] = useState(""); const handleSave = useCallback(() => { saveMaskingConfig(config); - toast.success('Masking configuration saved'); + toast.success("Masking configuration saved"); }, [config]); const handleReset = useCallback(() => { setConfig(DEFAULT_MASKING_CONFIG); saveMaskingConfig(DEFAULT_MASKING_CONFIG); - toast.success('Masking configuration reset to defaults'); + toast.success("Masking configuration reset to defaults"); }, []); const toggleGlobal = useCallback((enabled: boolean) => { - setConfig(prev => ({ ...prev, enabled })); + setConfig((prev) => ({ ...prev, enabled })); }, []); const togglePatternEnabled = useCallback((patternId: string, enabled: boolean) => { - setConfig(prev => ({ + setConfig((prev) => ({ ...prev, - patterns: prev.patterns.map(p => p.id === patternId ? { ...p, enabled } : p), + patterns: prev.patterns.map((p) => (p.id === patternId ? { ...p, enabled } : p)), })); }, []); - const updateRoleSetting = useCallback(( - role: 'admin' | 'user', - key: 'canToggle' | 'canReveal', - value: boolean - ) => { - setConfig(prev => ({ + const updateRoleSetting = useCallback((role: "admin" | "user", key: "canToggle" | "canReveal", value: boolean) => { + setConfig((prev) => ({ ...prev, roleSettings: { ...prev.roleSettings, @@ -95,34 +82,34 @@ export function MaskingSettings() { setEditingPattern(pattern); setEditName(pattern.name); setEditMaskType(pattern.maskType); - setEditColumnPatterns(pattern.columnPatterns.join('\n')); - setEditCustomMask(pattern.customMask || ''); + setEditColumnPatterns(pattern.columnPatterns.join("\n")); + setEditCustomMask(pattern.customMask || ""); setIsNewPattern(false); setIsDialogOpen(true); }, []); const openNewDialog = useCallback(() => { setEditingPattern(null); - setEditName(''); - setEditMaskType('full'); - setEditColumnPatterns(''); - setEditCustomMask(''); + setEditName(""); + setEditMaskType("full"); + setEditColumnPatterns(""); + setEditCustomMask(""); setIsNewPattern(true); setIsDialogOpen(true); }, []); const handleDialogSave = useCallback(() => { const patterns = editColumnPatterns - .split('\n') - .map(s => s.trim()) + .split("\n") + .map((s) => s.trim()) .filter(Boolean); if (!editName.trim()) { - toast.error('Pattern name is required'); + toast.error("Pattern name is required"); return; } if (patterns.length === 0) { - toast.error('At least one column pattern is required'); + toast.error("At least one column pattern is required"); return; } @@ -134,25 +121,25 @@ export function MaskingSettings() { maskType: editMaskType, enabled: true, isBuiltin: false, - customMask: editMaskType === 'custom' ? editCustomMask : undefined, + customMask: editMaskType === "custom" ? editCustomMask : undefined, }; - setConfig(prev => ({ + setConfig((prev) => ({ ...prev, patterns: [...prev.patterns, newPattern], })); } else if (editingPattern) { - setConfig(prev => ({ + setConfig((prev) => ({ ...prev, - patterns: prev.patterns.map(p => + patterns: prev.patterns.map((p) => p.id === editingPattern.id ? { ...p, name: editName.trim(), maskType: editMaskType, columnPatterns: patterns, - customMask: editMaskType === 'custom' ? editCustomMask : undefined, + customMask: editMaskType === "custom" ? editCustomMask : undefined, } - : p + : p, ), })); } @@ -161,9 +148,9 @@ export function MaskingSettings() { }, [editName, editMaskType, editColumnPatterns, editCustomMask, isNewPattern, editingPattern]); const deletePattern = useCallback((patternId: string) => { - setConfig(prev => ({ + setConfig((prev) => ({ ...prev, - patterns: prev.patterns.filter(p => p.id !== patternId), + patterns: prev.patterns.filter((p) => p.id !== patternId), })); }, []); @@ -186,10 +173,7 @@ export function MaskingSettings() { When enabled, sensitive columns are automatically detected and masked

    - + {/* Role Permissions */} @@ -199,20 +183,22 @@ export function MaskingSettings() { {/* Admin Row */}
    - Admin + + Admin +
    @@ -221,20 +207,22 @@ export function MaskingSettings() { {/* User Row */}
    - User + + User +
    @@ -254,36 +242,32 @@ export function MaskingSettings() {
    - {config.patterns.map(pattern => ( + {config.patterns.map((pattern) => (
    - togglePatternEnabled(pattern.id, v)} - /> + togglePatternEnabled(pattern.id, v)} />
    {pattern.name} {pattern.isBuiltin && ( - builtin + + builtin + )} - {pattern.maskType} + + {pattern.maskType} +

    - {pattern.columnPatterns.join(', ')} + {pattern.columnPatterns.join(", ")}

    - {!pattern.isBuiltin && ( @@ -307,19 +291,22 @@ export function MaskingSettings() {

    Preview

    - {config.patterns.filter(p => p.enabled).slice(0, 5).map(pattern => { - const preview = MASK_TYPE_PREVIEWS[pattern.maskType]; - const masked = getPreviewMasked(pattern.maskType, pattern.customMask); - return ( -
    - - {pattern.name}: - {preview.sample} - - {masked} -
    - ); - })} + {config.patterns + .filter((p) => p.enabled) + .slice(0, 5) + .map((pattern) => { + const preview = MASK_TYPE_PREVIEWS[pattern.maskType]; + const masked = getPreviewMasked(pattern.maskType, pattern.customMask); + return ( +
    + + {pattern.name}: + {preview.sample} + + {masked} +
    + ); + })}
    @@ -341,18 +328,12 @@ export function MaskingSettings() { - - {isNewPattern ? 'Add Masking Pattern' : 'Edit Masking Pattern'} - + {isNewPattern ? "Add Masking Pattern" : "Edit Masking Pattern"}
    - setEditName(e.target.value)} - placeholder="Pattern name" - /> + setEditName(e.target.value)} placeholder="Pattern name" />
    @@ -361,7 +342,7 @@ export function MaskingSettings() { - {ALL_MASK_TYPES.map(t => ( + {ALL_MASK_TYPES.map((t) => ( {t} — {MASK_TYPE_PREVIEWS[t].label} @@ -369,7 +350,7 @@ export function MaskingSettings() {
    - {editMaskType === 'custom' && ( + {editMaskType === "custom" && (

    Preview:

    - {getPreviewMasked(editMaskType, editMaskType === 'custom' ? editCustomMask : undefined)} + {getPreviewMasked(editMaskType, editMaskType === "custom" ? editCustomMask : undefined)}

    diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx index 6c2a919b..327aef7d 100644 --- a/src/components/MobileNav.tsx +++ b/src/components/MobileNav.tsx @@ -1,20 +1,20 @@ "use client"; -import React from 'react'; -import { Database, Terminal, Table as TableIcon } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import React from "react"; +import { Database, Terminal, Table as TableIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; interface MobileNavProps { - activeTab: 'database' | 'schema' | 'editor'; - onTabChange: (tab: 'database' | 'schema' | 'editor') => void; + activeTab: "database" | "schema" | "editor"; + onTabChange: (tab: "database" | "schema" | "editor") => void; hasResult?: boolean; } export function MobileNav({ activeTab, onTabChange }: MobileNavProps) { const tabs = [ - { id: 'database', label: 'DB', icon: Database }, - { id: 'schema', label: 'Schema', icon: TableIcon }, - { id: 'editor', label: 'SQL', icon: Terminal }, + { id: "database", label: "DB", icon: Database }, + { id: "schema", label: "Schema", icon: TableIcon }, + { id: "editor", label: "SQL", icon: Terminal }, ] as const; return ( @@ -29,19 +29,19 @@ export function MobileNav({ activeTab, onTabChange }: MobileNavProps) { onClick={() => onTabChange(tab.id)} className={cn( "flex flex-col items-center gap-1 transition-all duration-200 relative", - isActive ? "text-blue-400" : "text-zinc-500" + isActive ? "text-blue-400" : "text-zinc-500", )} > -
    +
    {tab.label} - {isActive && ( -
    - )} + {isActive &&
    } ); })} diff --git a/src/components/NL2SQLPanel.tsx b/src/components/NL2SQLPanel.tsx index 155bfedd..a3186365 100644 --- a/src/components/NL2SQLPanel.tsx +++ b/src/components/NL2SQLPanel.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState, useRef, useEffect, type FormEvent } from 'react'; -import { Send, Loader2, Sparkles, X, Play, MessageSquare, Trash2 } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { useState, useRef, useEffect, type FormEvent } from "react"; +import { Send, Loader2, Sparkles, X, Play, MessageSquare, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; interface ConversationMessage { - role: 'user' | 'assistant'; + role: "user" | "assistant"; content: string; query?: string; // Extracted SQL/JSON query from assistant response } @@ -19,7 +19,11 @@ interface NL2SQLPanelProps { databaseType?: string; queryLanguage?: string; /** Optional API adapter: when provided, bypasses the built-in /api/ai/nl2sql fetch. */ - onNL2SQL?: (params: { prompt: string; schemaContext: string; conversationHistory?: { role: string; content: string }[] }) => Promise; + onNL2SQL?: (params: { + prompt: string; + schemaContext: string; + conversationHistory?: { role: string; content: string }[]; + }) => Promise; } function extractCodeBlock(text: string): string | null { @@ -38,7 +42,7 @@ export function NL2SQLPanel({ queryLanguage, onNL2SQL, }: NL2SQLPanelProps) { - const [question, setQuestion] = useState(''); + const [question, setQuestion] = useState(""); const [isLoading, setIsLoading] = useState(false); const [messages, setMessages] = useState([]); const [error, setError] = useState(null); @@ -46,7 +50,7 @@ export function NL2SQLPanel({ const inputRef = useRef(null); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); useEffect(() => { @@ -57,34 +61,46 @@ export function NL2SQLPanel({ if (e) e.preventDefault(); if (!question.trim() || isLoading) return; - const userMsg: ConversationMessage = { role: 'user', content: question.trim() }; - setMessages(prev => [...prev, userMsg]); - setQuestion(''); + const userMsg: ConversationMessage = { role: "user", content: question.trim() }; + setMessages((prev) => [...prev, userMsg]); + setQuestion(""); setIsLoading(true); setError(null); try { // Build filtered schema context (top 100 tables) - let filteredSchema = ''; + let filteredSchema = ""; if (schemaContext) { try { const tables = JSON.parse(schemaContext); const sorted = [...tables] .sort((a: { rowCount?: number }, b: { rowCount?: number }) => (b.rowCount || 0) - (a.rowCount || 0)) .slice(0, 100); - filteredSchema = sorted.map((t: { name: string; rowCount?: number; columns?: { name: string; type: string; isPrimary?: boolean }[] }) => { - const cols = t.columns?.slice(0, 10).map(c => `${c.name} (${c.type}${c.isPrimary ? ', PK' : ''})`).join(', ') || ''; - return `Table: ${t.name} (${t.rowCount || 0} rows)\nColumns: ${cols}`; - }).join('\n\n'); + filteredSchema = sorted + .map( + (t: { + name: string; + rowCount?: number; + columns?: { name: string; type: string; isPrimary?: boolean }[]; + }) => { + const cols = + t.columns + ?.slice(0, 10) + .map((c) => `${c.name} (${c.type}${c.isPrimary ? ", PK" : ""})`) + .join(", ") || ""; + return `Table: ${t.name} (${t.rowCount || 0} rows)\nColumns: ${cols}`; + }, + ) + .join("\n\n"); } catch { filteredSchema = schemaContext.substring(0, 3000); } } // Build conversation history (exclude current question) - const history = messages.map(m => ({ role: m.role, content: m.content })); + const history = messages.map((m) => ({ role: m.role, content: m.content })); - let fullResponse = ''; + let fullResponse = ""; if (onNL2SQL) { // Platform adapter: use callback instead of fetch @@ -95,9 +111,9 @@ export function NL2SQLPanel({ }); } else { // Default: existing fetch behavior - const response = await fetch('/api/ai/nl2sql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/ai/nl2sql", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question: question.trim(), schemaContext: filteredSchema, @@ -109,11 +125,11 @@ export function NL2SQLPanel({ if (!response.ok) { const errData = await response.json(); - throw new Error(errData.error || 'Request failed'); + throw new Error(errData.error || "Request failed"); } const reader = response.body?.getReader(); - if (!reader) throw new Error('No reader'); + if (!reader) throw new Error("No reader"); while (true) { const { done, value } = await reader.read(); @@ -124,13 +140,13 @@ export function NL2SQLPanel({ const extractedQuery = extractCodeBlock(fullResponse); const assistantMsg: ConversationMessage = { - role: 'assistant', + role: "assistant", content: fullResponse, query: extractedQuery || undefined, }; - setMessages(prev => [...prev, assistantMsg]); + setMessages((prev) => [...prev, assistantMsg]); } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; + const msg = err instanceof Error ? err.message : "Unknown error"; setError(msg); } finally { setIsLoading(false); @@ -152,12 +168,10 @@ export function NL2SQLPanel({
    - - Natural Language Query - + Natural Language Query {messages.length > 0 && ( - {messages.filter(m => m.role === 'user').length} questions + {messages.filter((m) => m.role === "user").length} questions )}
    @@ -186,21 +200,21 @@ export function NL2SQLPanel({

    Ask a question in plain English

    -

    - e.g. "Show me the top 10 employees by salary" -

    +

    e.g. "Show me the top 10 employees by salary"

    )} {messages.map((msg, i) => ( -
    -
    - {msg.role === 'user' ? ( +
    +
    + {msg.role === "user" ? (

    {msg.content}

    ) : (
    @@ -227,9 +241,9 @@ export function NL2SQLPanel({
    )} {/* Show explanation text (non-code parts) */} - {msg.content.replace(/```[\s\S]*?```/g, '').trim() && ( + {msg.content.replace(/```[\s\S]*?```/g, "").trim() && (

    - {msg.content.replace(/```[\s\S]*?```/g, '').trim()} + {msg.content.replace(/```[\s\S]*?```/g, "").trim()}

    )}
    @@ -272,7 +286,11 @@ export function NL2SQLPanel({ disabled={isLoading || !question.trim()} className="bg-violet-600 hover:bg-violet-500 disabled:opacity-50 px-3 py-2 rounded-lg text-white text-xs font-medium transition-colors flex items-center gap-1.5" > - {isLoading ? : } + {isLoading ? ( + + ) : ( + + )}
    diff --git a/src/components/PivotTable.tsx b/src/components/PivotTable.tsx index cde9922a..5857dbb7 100644 --- a/src/components/PivotTable.tsx +++ b/src/components/PivotTable.tsx @@ -1,34 +1,39 @@ "use client"; -import React, { useState, useMemo, useCallback, useEffect } from 'react'; -import { Columns3, GripVertical, ArrowRight } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { QueryResult } from '@/lib/types'; +import React, { useState, useMemo, useCallback, useEffect } from "react"; +import { Columns3, GripVertical, ArrowRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { QueryResult } from "@/lib/types"; interface PivotTableProps { result: QueryResult | null; onLoadQuery?: (query: string) => void; } -type AggFunction = 'count' | 'sum' | 'avg' | 'min' | 'max'; +type AggFunction = "count" | "sum" | "avg" | "min" | "max"; const AGG_LABELS: Record = { - count: 'COUNT', - sum: 'SUM', - avg: 'AVG', - min: 'MIN', - max: 'MAX', + count: "COUNT", + sum: "SUM", + avg: "AVG", + min: "MIN", + max: "MAX", }; export function aggregate(values: unknown[], fn: AggFunction): string { - const nums = values.map(v => Number(v)).filter(n => !isNaN(n)); + const nums = values.map((v) => Number(v)).filter((n) => !isNaN(n)); switch (fn) { - case 'count': return String(values.length); - case 'sum': return nums.length ? nums.reduce((a, b) => a + b, 0).toFixed(2) : '0'; - case 'avg': return nums.length ? (nums.reduce((a, b) => a + b, 0) / nums.length).toFixed(2) : '0'; - case 'min': return nums.length ? String(Math.min(...nums)) : '-'; - case 'max': return nums.length ? String(Math.max(...nums)) : '-'; + case "count": + return String(values.length); + case "sum": + return nums.length ? nums.reduce((a, b) => a + b, 0).toFixed(2) : "0"; + case "avg": + return nums.length ? (nums.reduce((a, b) => a + b, 0) / nums.length).toFixed(2) : "0"; + case "min": + return nums.length ? String(Math.min(...nums)) : "-"; + case "max": + return nums.length ? String(Math.max(...nums)) : "-"; } } @@ -36,26 +41,26 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) { const [rowField, setRowField] = useState(null); const [colField, setColField] = useState(null); const [valueField, setValueField] = useState(null); - const [aggFunction, setAggFunction] = useState('count'); + const [aggFunction, setAggFunction] = useState("count"); const fields = result?.fields || []; const rows = useMemo(() => result?.rows || [], [result?.rows]); // Auto-detect fields on first render useEffect(() => { if (fields.length >= 2 && !rowField) { - const strCol = fields.find(f => { + const strCol = fields.find((f) => { const sample = rows[0]?.[f]; - return typeof sample === 'string'; + return typeof sample === "string"; }); if (strCol) setRowField(strCol); - const numCol = fields.find(f => { + const numCol = fields.find((f) => { const sample = rows[0]?.[f]; - return typeof sample === 'number'; + return typeof sample === "number"; }); if (numCol) setValueField(numCol); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [fields.length]); // Compute pivot data @@ -67,8 +72,8 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) { const colValues = new Set(); for (const row of rows) { - const rowKey = String(row[rowField] ?? 'NULL'); - const colKey = colField ? String(row[colField] ?? 'NULL') : '__all__'; + const rowKey = String(row[rowField] ?? "NULL"); + const colKey = colField ? String(row[colField] ?? "NULL") : "__all__"; const value = valueField ? row[valueField] : 1; if (colField) colValues.add(colKey); @@ -79,7 +84,7 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) { colMap.get(colKey)!.push(value); } - const colKeys = colField ? Array.from(colValues).sort() : ['__all__']; + const colKeys = colField ? Array.from(colValues).sort() : ["__all__"]; // Build pivot rows const pivotRows: { rowKey: string; values: Map }[] = []; @@ -100,7 +105,7 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) { // Generate SQL const generateSQL = useCallback(() => { - if (!rowField) return ''; + if (!rowField) return ""; const select: string[] = [`"${rowField}"`]; const groupBy: string[] = [`"${rowField}"`]; @@ -108,18 +113,18 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) { // Use CASE WHEN for pivot columns const colKeys = pivotData?.colKeys || []; for (const ck of colKeys) { - if (ck === '__all__') continue; - const valExpr = valueField ? `"${valueField}"` : '1'; + if (ck === "__all__") continue; + const valExpr = valueField ? `"${valueField}"` : "1"; select.push( - `${AGG_LABELS[aggFunction]}(CASE WHEN "${colField}" = '${ck.replace(/'/g, "''")}' THEN ${valExpr} END) AS "${ck}"` + `${AGG_LABELS[aggFunction]}(CASE WHEN "${colField}" = '${ck.replace(/'/g, "''")}' THEN ${valExpr} END) AS "${ck}"`, ); } } else { - const valExpr = valueField ? `"${valueField}"` : '*'; + const valExpr = valueField ? `"${valueField}"` : "*"; select.push(`${AGG_LABELS[aggFunction]}(${valExpr}) AS "${aggFunction}_value"`); } - return `SELECT\n ${select.join(',\n ')}\nFROM your_table\nGROUP BY ${groupBy.join(', ')}\nORDER BY ${groupBy.join(', ')};`; + return `SELECT\n ${select.join(",\n ")}\nFROM your_table\nGROUP BY ${groupBy.join(", ")}\nORDER BY ${groupBy.join(", ")};`; }, [rowField, colField, valueField, aggFunction, pivotData]); if (!result || rows.length === 0) { @@ -140,12 +145,16 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) {
    Rows:
    @@ -153,12 +162,18 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) {
    Columns:
    @@ -166,18 +181,24 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) {
    Values:
    {/* Aggregation */}
    - {(Object.keys(AGG_LABELS) as AggFunction[]).map(fn => ( + {(Object.keys(AGG_LABELS) as AggFunction[]).map((fn) => (
    - {pivotData.colKeys.map(ck => ( - ))} @@ -226,12 +250,10 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) { {pivotData.pivotRows.map((row, i) => ( - - {pivotData.colKeys.map(ck => ( + + {pivotData.colKeys.map((ck) => ( ))} @@ -249,7 +271,8 @@ export function PivotTable({ result, onLoadQuery }: PivotTableProps) { {/* Status */} {pivotData && (
    - {pivotData.pivotRows.length} groups • {pivotData.colKeys.length} columns • {AGG_LABELS[aggFunction]} aggregation + {pivotData.pivotRows.length} groups • {pivotData.colKeys.length} columns • {AGG_LABELS[aggFunction]}{" "} + aggregation
    )} diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index 77248b37..6624e1fd 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -1,18 +1,18 @@ "use client"; -import React, { useRef, useEffect, useState, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react'; -import Editor, { useMonaco } from '@monaco-editor/react'; -import type * as Monaco from 'monaco-editor'; -import { Zap, Sparkles, Send, X, Loader2, AlignLeft, Trash2, Copy, Play, Hash } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { motion, AnimatePresence } from 'framer-motion'; -import { format } from 'sql-formatter'; -import { registerSQLCompletionProvider } from '@/lib/editor/sql-completions'; -import type { SchemaCompletionCache, SchemaColumnItem } from '@/lib/editor/sql-completions'; -import { registerMongoDBCompletionProvider } from '@/lib/editor/mongodb-completions'; -import { registerLibreDBLanguage } from '@/lib/editor/libredb-language'; -import { useAiChat } from '@/hooks/use-ai-chat'; +import React, { useRef, useEffect, useState, useMemo, forwardRef, useImperativeHandle, useCallback } from "react"; +import Editor, { useMonaco } from "@monaco-editor/react"; +import type * as Monaco from "monaco-editor"; +import { Zap, Sparkles, Send, X, Loader2, AlignLeft, Trash2, Copy, Play, Hash } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { motion, AnimatePresence } from "framer-motion"; +import { format } from "sql-formatter"; +import { registerSQLCompletionProvider } from "@/lib/editor/sql-completions"; +import type { SchemaCompletionCache, SchemaColumnItem } from "@/lib/editor/sql-completions"; +import { registerMongoDBCompletionProvider } from "@/lib/editor/mongodb-completions"; +import { registerLibreDBLanguage } from "@/lib/editor/libredb-language"; +import { useAiChat } from "@/hooks/use-ai-chat"; export interface QueryEditorRef { getSelectedText: () => string; @@ -32,13 +32,17 @@ interface QueryEditorProps { /** Called when content changes in real-time. Use sparingly as it triggers on every keystroke. */ onContentChange?: (val: string) => void; onExplain?: () => void; - language?: 'sql' | 'json' | 'libredb'; + language?: "sql" | "json" | "libredb"; tables?: string[]; databaseType?: string; schemaContext?: string; - capabilities?: import('@/lib/db/types').ProviderCapabilities; + capabilities?: import("@/lib/db/types").ProviderCapabilities; /** Optional API adapter: when provided, bypasses the built-in /api/ai/chat fetch. */ - onAiChat?: (params: { prompt: string; schemaContext: string; history: { role: string; content: string }[] }) => Promise; + onAiChat?: (params: { + prompt: string; + schemaContext: string; + history: { role: string; content: string }[]; + }) => Promise; } interface ParsedTable { @@ -56,22 +60,22 @@ const getEditorOptions = (showLineNumbers: boolean) => ({ minimap: { enabled: false }, fontSize: 13, fontFamily: '"JetBrains Mono", "Fira Code", Menlo, Monaco, Consolas, monospace', - lineNumbers: showLineNumbers ? ('on' as const) : ('off' as const), + lineNumbers: showLineNumbers ? ("on" as const) : ("off" as const), roundedSelection: true, scrollBeyondLastLine: false, readOnly: false, automaticLayout: true, padding: { top: 12 }, - cursorSmoothCaretAnimation: 'on' as const, - cursorBlinking: 'smooth' as const, + cursorSmoothCaretAnimation: "on" as const, + cursorBlinking: "smooth" as const, smoothScrolling: true, contextmenu: true, - renderLineHighlight: 'all' as const, + renderLineHighlight: "all" as const, bracketPairColorization: { enabled: true }, guides: { indentation: true }, scrollbar: { - vertical: 'visible' as const, - horizontal: 'visible' as const, + vertical: "visible" as const, + horizontal: "visible" as const, verticalScrollbarSize: 8, horizontalScrollbarSize: 8, }, @@ -80,491 +84,497 @@ const getEditorOptions = (showLineNumbers: boolean) => ({ quickSuggestions: { other: true, comments: false, - strings: true + strings: true, }, parameterHints: { - enabled: true - } + enabled: true, + }, }); -export const QueryEditor = forwardRef(({ - value, - onChange, - onContentChange, - onExplain, - language = 'sql', - tables = [], - databaseType, - schemaContext, - capabilities, - onAiChat, -}, ref) => { - const monaco = useMonaco(); - const editorRef = useRef(null); - const [hasSelection, setHasSelection] = useState(false); - - // Line numbers toggle state (persisted in localStorage) - const [showLineNumbers, setShowLineNumbers] = useState(() => { - if (typeof window !== 'undefined') { - const saved = localStorage.getItem('editor-line-numbers'); - return saved !== null ? saved === 'true' : true; // default: true - } - return true; - }); - - // Track last synced value to detect external changes - const lastSyncedValueRef = useRef(value); - const isInternalChangeRef = useRef(false); - - // Sync editor content when value prop changes externally (e.g., tab switch) - useEffect(() => { - if (editorRef.current && value !== lastSyncedValueRef.current) { - const currentEditorValue = editorRef.current.getValue(); - // Only update if the new value is different from current editor content - // This prevents unnecessary updates when we're the source of the change - if (value !== currentEditorValue) { - isInternalChangeRef.current = true; - editorRef.current.setValue(value); - lastSyncedValueRef.current = value; - isInternalChangeRef.current = false; +export const QueryEditor = forwardRef( + ( + { + value, + onChange, + onContentChange, + onExplain, + language = "sql", + tables = [], + databaseType, + schemaContext, + capabilities, + onAiChat, + }, + ref, + ) => { + const monaco = useMonaco(); + const editorRef = useRef(null); + const [hasSelection, setHasSelection] = useState(false); + + // Line numbers toggle state (persisted in localStorage) + const [showLineNumbers, setShowLineNumbers] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("editor-line-numbers"); + return saved !== null ? saved === "true" : true; // default: true } - } - }, [value]); - - // Update editor options when line numbers toggle changes - useEffect(() => { - if (editorRef.current) { - editorRef.current.updateOptions({ lineNumbers: showLineNumbers ? 'on' : 'off' }); - } - }, [showLineNumbers]); - - // Persist line numbers preference to localStorage - useEffect(() => { - if (typeof window !== 'undefined') { - localStorage.setItem('editor-line-numbers', String(showLineNumbers)); - } - }, [showLineNumbers]); - - const parsedSchema = useMemo((): ParsedTable[] => { - if (!schemaContext) return []; - try { - return JSON.parse(schemaContext); - } catch (e) { - console.error('Failed to parse schema context for editor:', e); - return []; - } - }, [schemaContext]); - - // Pre-compute schema-based completion items for faster lookups - const schemaCompletionCache = useMemo((): SchemaCompletionCache => { - const tableItems: SchemaCompletionCache['tableItems'] = []; - const columnMap = new Map(); - const allColumns = new Map(); - - parsedSchema.forEach((table) => { - const tableLower = table.name.toLowerCase(); - tableItems.push({ - label: table.name, - labelLower: tableLower, - rowCount: table.rowCount || 0, - columnNames: table.columns?.map((c) => c.name).join(', ') || '' - }); - - const tableColumns: SchemaColumnItem[] = []; - table.columns?.forEach((col) => { - const colItem: SchemaColumnItem = { - label: col.name, - labelLower: col.name.toLowerCase(), - type: col.type, - isPrimary: col.isPrimary || false, - tableName: table.name - }; - tableColumns.push(colItem); + return true; + }); - // Only store first occurrence for global column suggestions - if (!allColumns.has(col.name)) { - allColumns.set(col.name, colItem); + // Track last synced value to detect external changes + const lastSyncedValueRef = useRef(value); + const isInternalChangeRef = useRef(false); + + // Sync editor content when value prop changes externally (e.g., tab switch) + useEffect(() => { + if (editorRef.current && value !== lastSyncedValueRef.current) { + const currentEditorValue = editorRef.current.getValue(); + // Only update if the new value is different from current editor content + // This prevents unnecessary updates when we're the source of the change + if (value !== currentEditorValue) { + isInternalChangeRef.current = true; + editorRef.current.setValue(value); + lastSyncedValueRef.current = value; + isInternalChangeRef.current = false; } - }); - columnMap.set(tableLower, tableColumns); - }); + } + }, [value]); - return { tableItems, columnMap, allColumns }; - }, [parsedSchema]); - - const handleFormat = () => { - if (!editorRef.current) return; - const currentValue = editorRef.current.getValue(); - if (!currentValue) return; - - try { - let formatted: string; - if (language === 'json') { - // JSON formatting for MongoDB queries - const parsed = JSON.parse(currentValue); - formatted = JSON.stringify(parsed, null, 2); - } else if (language === 'sql') { - formatted = format(currentValue, { - language: 'postgresql', - keywordCase: 'upper', - dataTypeCase: 'upper', - indentStyle: 'tabularLeft', - logicalOperatorNewline: 'before', - expressionWidth: 100, - tabWidth: 2, - linesBetweenQueries: 2, - }); - } else { - return; + // Update editor options when line numbers toggle changes + useEffect(() => { + if (editorRef.current) { + editorRef.current.updateOptions({ lineNumbers: showLineNumbers ? "on" : "off" }); + } + }, [showLineNumbers]); + + // Persist line numbers preference to localStorage + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem("editor-line-numbers", String(showLineNumbers)); } - editorRef.current.setValue(formatted); - lastSyncedValueRef.current = formatted; - onChange?.(formatted); - } catch (e) { - console.error('Formatting failed:', e); - } - }; - - const getSelectedText = () => { - if (!editorRef.current) return ''; - const selection = editorRef.current.getSelection(); - const model = editorRef.current.getModel(); - if (!selection || !model) return ''; - return model.getValueInRange(selection); - }; - - const getEffectiveQuery = () => { - const editorValue = editorRef.current?.getValue() || ''; - if (!editorRef.current || !monaco) return { query: editorValue, range: null }; - - const model = editorRef.current.getModel(); - if (!model) return { query: editorValue, range: null }; - - // 1. Check for explicit selection - const selection = editorRef.current.getSelection(); - if (selection) { - const selectedText = model.getValueInRange(selection); - if (selectedText && selectedText.trim().length > 0) { - return { query: selectedText, range: selection }; + }, [showLineNumbers]); + + const parsedSchema = useMemo((): ParsedTable[] => { + if (!schemaContext) return []; + try { + return JSON.parse(schemaContext); + } catch (e) { + console.error("Failed to parse schema context for editor:", e); + return []; } - } - - // 2. If no selection, try to find the current statement (between semicolons) - if (language === 'sql') { - const position = editorRef.current.getPosition(); - if (position) { - const fullText = model.getValue(); - const cursorOffset = model.getOffsetAt(position); - - // Find boundaries of the current statement - let startOffset = fullText.lastIndexOf(';', cursorOffset - 1); - let endOffset = fullText.indexOf(';', cursorOffset); - - if (startOffset === -1) startOffset = 0; - else startOffset += 1; // skip the semicolon - - if (endOffset === -1) endOffset = fullText.length; - - const statement = fullText.substring(startOffset, endOffset).trim(); - if (statement.length > 0) { - const startPos = model.getPositionAt(startOffset); - const endPos = model.getPositionAt(endOffset); - const range = new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column); - return { query: statement, range }; + }, [schemaContext]); + + // Pre-compute schema-based completion items for faster lookups + const schemaCompletionCache = useMemo((): SchemaCompletionCache => { + const tableItems: SchemaCompletionCache["tableItems"] = []; + const columnMap = new Map(); + const allColumns = new Map(); + + parsedSchema.forEach((table) => { + const tableLower = table.name.toLowerCase(); + tableItems.push({ + label: table.name, + labelLower: tableLower, + rowCount: table.rowCount || 0, + columnNames: table.columns?.map((c) => c.name).join(", ") || "", + }); + + const tableColumns: SchemaColumnItem[] = []; + table.columns?.forEach((col) => { + const colItem: SchemaColumnItem = { + label: col.name, + labelLower: col.name.toLowerCase(), + type: col.type, + isPrimary: col.isPrimary || false, + tableName: table.name, + }; + tableColumns.push(colItem); + + // Only store first occurrence for global column suggestions + if (!allColumns.has(col.name)) { + allColumns.set(col.name, colItem); + } + }); + columnMap.set(tableLower, tableColumns); + }); + + return { tableItems, columnMap, allColumns }; + }, [parsedSchema]); + + const handleFormat = () => { + if (!editorRef.current) return; + const currentValue = editorRef.current.getValue(); + if (!currentValue) return; + + try { + let formatted: string; + if (language === "json") { + // JSON formatting for MongoDB queries + const parsed = JSON.parse(currentValue); + formatted = JSON.stringify(parsed, null, 2); + } else if (language === "sql") { + formatted = format(currentValue, { + language: "postgresql", + keywordCase: "upper", + dataTypeCase: "upper", + indentStyle: "tabularLeft", + logicalOperatorNewline: "before", + expressionWidth: 100, + tabWidth: 2, + linesBetweenQueries: 2, + }); + } else { + return; } + editorRef.current.setValue(formatted); + lastSyncedValueRef.current = formatted; + onChange?.(formatted); + } catch (e) { + console.error("Formatting failed:", e); } - } - - return { query: editorValue, range: null }; - }; - - // Track active highlight timeout to prevent race conditions - const highlightTimeoutRef = useRef(null); - const activeDecorationsRef = useRef([]); - - const flashHighlight = (range: Monaco.Range | null) => { - if (!editorRef.current || !monaco || !range) return; - - // Clear any existing highlight first - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current); - highlightTimeoutRef.current = null; - } - if (activeDecorationsRef.current.length > 0 && editorRef.current) { - editorRef.current.deltaDecorations(activeDecorationsRef.current, []); - activeDecorationsRef.current = []; - } - - // Create new decoration - const decorations = editorRef.current.deltaDecorations([], [ - { - range: range, - options: { - isWholeLine: false, - className: 'executed-query-highlight', - inlineClassName: 'executed-query-inline-highlight' + }; + + const getSelectedText = () => { + if (!editorRef.current) return ""; + const selection = editorRef.current.getSelection(); + const model = editorRef.current.getModel(); + if (!selection || !model) return ""; + return model.getValueInRange(selection); + }; + + const getEffectiveQuery = () => { + const editorValue = editorRef.current?.getValue() || ""; + if (!editorRef.current || !monaco) return { query: editorValue, range: null }; + + const model = editorRef.current.getModel(); + if (!model) return { query: editorValue, range: null }; + + // 1. Check for explicit selection + const selection = editorRef.current.getSelection(); + if (selection) { + const selectedText = model.getValueInRange(selection); + if (selectedText && selectedText.trim().length > 0) { + return { query: selectedText, range: selection }; } } - ]); - activeDecorationsRef.current = decorations; - // Schedule removal with ref tracking for safe cleanup - highlightTimeoutRef.current = setTimeout(() => { - if (editorRef.current && activeDecorationsRef.current.length > 0) { - editorRef.current.deltaDecorations(activeDecorationsRef.current, []); - activeDecorationsRef.current = []; + // 2. If no selection, try to find the current statement (between semicolons) + if (language === "sql") { + const position = editorRef.current.getPosition(); + if (position) { + const fullText = model.getValue(); + const cursorOffset = model.getOffsetAt(position); + + // Find boundaries of the current statement + let startOffset = fullText.lastIndexOf(";", cursorOffset - 1); + let endOffset = fullText.indexOf(";", cursorOffset); + + if (startOffset === -1) startOffset = 0; + else startOffset += 1; // skip the semicolon + + if (endOffset === -1) endOffset = fullText.length; + + const statement = fullText.substring(startOffset, endOffset).trim(); + if (statement.length > 0) { + const startPos = model.getPositionAt(startOffset); + const endPos = model.getPositionAt(endOffset); + const range = new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column); + return { query: statement, range }; + } + } } - highlightTimeoutRef.current = null; - }, 1000); - }; - // Cleanup highlight timeout on unmount - useEffect(() => { - return () => { + return { query: editorValue, range: null }; + }; + + // Track active highlight timeout to prevent race conditions + const highlightTimeoutRef = useRef(null); + const activeDecorationsRef = useRef([]); + + const flashHighlight = (range: Monaco.Range | null) => { + if (!editorRef.current || !monaco || !range) return; + + // Clear any existing highlight first if (highlightTimeoutRef.current) { clearTimeout(highlightTimeoutRef.current); + highlightTimeoutRef.current = null; + } + if (activeDecorationsRef.current.length > 0 && editorRef.current) { + editorRef.current.deltaDecorations(activeDecorationsRef.current, []); + activeDecorationsRef.current = []; } + + // Create new decoration + const decorations = editorRef.current.deltaDecorations( + [], + [ + { + range: range, + options: { + isWholeLine: false, + className: "executed-query-highlight", + inlineClassName: "executed-query-inline-highlight", + }, + }, + ], + ); + activeDecorationsRef.current = decorations; + + // Schedule removal with ref tracking for safe cleanup + highlightTimeoutRef.current = setTimeout(() => { + if (editorRef.current && activeDecorationsRef.current.length > 0) { + editorRef.current.deltaDecorations(activeDecorationsRef.current, []); + activeDecorationsRef.current = []; + } + highlightTimeoutRef.current = null; + }, 1000); }; - }, []); - - // AI Chat hook (must be before useImperativeHandle that references showAi/setShowAi) - const getEditorValue = useCallback(() => editorRef.current?.getValue() || '', []); - const setEditorValueForAi = useCallback((val: string) => { - if (editorRef.current) { - editorRef.current.setValue(val); - lastSyncedValueRef.current = val; - } - }, []); - - const { - showAi, - setShowAi, - aiPrompt, - setAiPrompt, - isAiLoading, - aiError, - setAiError, - aiConversationHistory, - setAiConversationHistory, - handleAiSubmit, - } = useAiChat({ - parsedSchema, - schemaContext, - databaseType, - getEditorValue, - setEditorValue: setEditorValueForAi, - onChange, - onAiChat, - }); - - useImperativeHandle(ref, () => ({ - getSelectedText, - getEffectiveQuery: () => getEffectiveQuery().query, - getValue: () => editorRef.current?.getValue() || '', - setValue: (newValue: string) => { + + // Cleanup highlight timeout on unmount + useEffect(() => { + return () => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + }; + }, []); + + // AI Chat hook (must be before useImperativeHandle that references showAi/setShowAi) + const getEditorValue = useCallback(() => editorRef.current?.getValue() || "", []); + const setEditorValueForAi = useCallback((val: string) => { if (editorRef.current) { - editorRef.current.setValue(newValue); - lastSyncedValueRef.current = newValue; + editorRef.current.setValue(val); + lastSyncedValueRef.current = val; } - }, - focus: () => editorRef.current?.focus(), - format: handleFormat, - toggleAi: () => setShowAi(!showAi), - })); - - const handleCopy = () => { - const textToCopy = getSelectedText() || editorRef.current?.getValue() || ''; - navigator.clipboard.writeText(textToCopy); - }; - - const handleClear = () => { - if (editorRef.current) { - editorRef.current.setValue(''); - lastSyncedValueRef.current = ''; - onChange?.(''); - } - }; - - // Store original console.error for cleanup - const originalConsoleErrorRef = useRef(null); - - // Cleanup console.error override on unmount - useEffect(() => { - return () => { - if (originalConsoleErrorRef.current) { - console.error = originalConsoleErrorRef.current; - originalConsoleErrorRef.current = null; + }, []); + + const { + showAi, + setShowAi, + aiPrompt, + setAiPrompt, + isAiLoading, + aiError, + setAiError, + aiConversationHistory, + setAiConversationHistory, + handleAiSubmit, + } = useAiChat({ + parsedSchema, + schemaContext, + databaseType, + getEditorValue, + setEditorValue: setEditorValueForAi, + onChange, + onAiChat, + }); + + useImperativeHandle(ref, () => ({ + getSelectedText, + getEffectiveQuery: () => getEffectiveQuery().query, + getValue: () => editorRef.current?.getValue() || "", + setValue: (newValue: string) => { + if (editorRef.current) { + editorRef.current.setValue(newValue); + lastSyncedValueRef.current = newValue; + } + }, + focus: () => editorRef.current?.focus(), + format: handleFormat, + toggleAi: () => setShowAi(!showAi), + })); + + const handleCopy = () => { + const textToCopy = getSelectedText() || editorRef.current?.getValue() || ""; + navigator.clipboard.writeText(textToCopy); + }; + + const handleClear = () => { + if (editorRef.current) { + editorRef.current.setValue(""); + lastSyncedValueRef.current = ""; + onChange?.(""); } }; - }, []); - - const handleBeforeMount = (monacoInstance: typeof Monaco) => { - // Register the LibreDB command language (idempotent) so its tabs highlight - // correctly instead of being treated as JSON. - registerLibreDBLanguage(monacoInstance); - - // Suppress Monaco's "Canceled" errors in console (with cleanup tracking) - if (!originalConsoleErrorRef.current) { - originalConsoleErrorRef.current = console.error; - const originalConsoleError = console.error; - console.error = (...args: unknown[]) => { - const message = args[0]?.toString?.() || ''; - if (message.includes('Canceled') || message.includes('ERR Canceled')) { - return; // Suppress Monaco cancellation errors + + // Store original console.error for cleanup + const originalConsoleErrorRef = useRef(null); + + // Cleanup console.error override on unmount + useEffect(() => { + return () => { + if (originalConsoleErrorRef.current) { + console.error = originalConsoleErrorRef.current; + originalConsoleErrorRef.current = null; } - originalConsoleError.apply(console, args as Parameters); }; - } - - monacoInstance.editor.defineTheme('db-dark', { - base: 'vs-dark', - inherit: true, - rules: [ - { token: 'keyword', foreground: '569cd6', fontStyle: 'bold' }, - { token: 'function', foreground: 'dcdcaa' }, - { token: 'string', foreground: 'ce9178' }, - { token: 'number', foreground: 'b5cea8' }, - { token: 'comment', foreground: '6a9955' }, - { token: 'operator', foreground: 'd4d4d4' }, - { token: 'identifier', foreground: '9cdcfe' }, - ], - colors: { - 'editor.background': '#050505', - 'editor.foreground': '#d4d4d4', - 'editorCursor.foreground': '#569cd6', - 'editor.lineHighlightBackground': '#111111', - 'editorLineNumber.foreground': '#333333', - 'editorLineNumber.activeForeground': '#666666', - 'editor.selectionBackground': '#264f78', - 'editor.inactiveSelectionBackground': '#3a3d41', - 'editorIndentGuide.background': '#1a1a1a', - 'editorIndentGuide.activeBackground': '#333333', + }, []); + + const handleBeforeMount = (monacoInstance: typeof Monaco) => { + // Register the LibreDB command language (idempotent) so its tabs highlight + // correctly instead of being treated as JSON. + registerLibreDBLanguage(monacoInstance); + + // Suppress Monaco's "Canceled" errors in console (with cleanup tracking) + if (!originalConsoleErrorRef.current) { + originalConsoleErrorRef.current = console.error; + const originalConsoleError = console.error; + console.error = (...args: unknown[]) => { + const message = args[0]?.toString?.() || ""; + if (message.includes("Canceled") || message.includes("ERR Canceled")) { + return; // Suppress Monaco cancellation errors + } + originalConsoleError.apply(console, args as Parameters); + }; } - }); - }; - - // SQL completion provider - useEffect(() => { - if (monaco && language === 'sql') { - const disposable = registerSQLCompletionProvider(monaco, schemaCompletionCache); - return () => disposable.dispose(); - } - }, [monaco, language, schemaCompletionCache]); - - // MongoDB JSON completion provider - useEffect(() => { - if (monaco && language === 'json') { - const disposable = registerMongoDBCompletionProvider(monaco, schemaCompletionCache); - return () => disposable.dispose(); - } - }, [monaco, language, schemaCompletionCache]); - - const handleEditorChange = (val: string | undefined) => { - const newValue = val || ''; - // Only call onContentChange if provided (for real-time sync scenarios) - // This avoids the performance hit of updating parent state on every keystroke - onContentChange?.(newValue); - }; - - // Sync to parent on blur (when user leaves the editor) - const handleEditorBlur = () => { - if (editorRef.current) { - const currentValue = editorRef.current.getValue(); - lastSyncedValueRef.current = currentValue; - onChange?.(currentValue); - } - }; - - const handleExecute = () => { - // Sync current content to parent before executing - if (editorRef.current) { - const currentValue = editorRef.current.getValue(); - lastSyncedValueRef.current = currentValue; - onChange?.(currentValue); - } - - const { query, range } = getEffectiveQuery(); - flashHighlight(range); - const event = new CustomEvent('execute-query', { detail: { query } }); - window.dispatchEvent(event); - }; - - - return ( -
    - {/* Dynamic Pro Toolbar - Hidden on mobile */} -
    - {hasSelection && ( + + monacoInstance.editor.defineTheme("db-dark", { + base: "vs-dark", + inherit: true, + rules: [ + { token: "keyword", foreground: "569cd6", fontStyle: "bold" }, + { token: "function", foreground: "dcdcaa" }, + { token: "string", foreground: "ce9178" }, + { token: "number", foreground: "b5cea8" }, + { token: "comment", foreground: "6a9955" }, + { token: "operator", foreground: "d4d4d4" }, + { token: "identifier", foreground: "9cdcfe" }, + ], + colors: { + "editor.background": "#050505", + "editor.foreground": "#d4d4d4", + "editorCursor.foreground": "#569cd6", + "editor.lineHighlightBackground": "#111111", + "editorLineNumber.foreground": "#333333", + "editorLineNumber.activeForeground": "#666666", + "editor.selectionBackground": "#264f78", + "editor.inactiveSelectionBackground": "#3a3d41", + "editorIndentGuide.background": "#1a1a1a", + "editorIndentGuide.activeBackground": "#333333", + }, + }); + }; + + // SQL completion provider + useEffect(() => { + if (monaco && language === "sql") { + const disposable = registerSQLCompletionProvider(monaco, schemaCompletionCache); + return () => disposable.dispose(); + } + }, [monaco, language, schemaCompletionCache]); + + // MongoDB JSON completion provider + useEffect(() => { + if (monaco && language === "json") { + const disposable = registerMongoDBCompletionProvider(monaco, schemaCompletionCache); + return () => disposable.dispose(); + } + }, [monaco, language, schemaCompletionCache]); + + const handleEditorChange = (val: string | undefined) => { + const newValue = val || ""; + // Only call onContentChange if provided (for real-time sync scenarios) + // This avoids the performance hit of updating parent state on every keystroke + onContentChange?.(newValue); + }; + + // Sync to parent on blur (when user leaves the editor) + const handleEditorBlur = () => { + if (editorRef.current) { + const currentValue = editorRef.current.getValue(); + lastSyncedValueRef.current = currentValue; + onChange?.(currentValue); + } + }; + + const handleExecute = () => { + // Sync current content to parent before executing + if (editorRef.current) { + const currentValue = editorRef.current.getValue(); + lastSyncedValueRef.current = currentValue; + onChange?.(currentValue); + } + + const { query, range } = getEffectiveQuery(); + flashHighlight(range); + const event = new CustomEvent("execute-query", { detail: { query } }); + window.dispatchEvent(event); + }; + + return ( +
    + {/* Dynamic Pro Toolbar - Hidden on mobile */} +
    + {hasSelection && ( + + )} + + {(language === "sql" || language === "json") && ( + + )} + - )} - {(language === 'sql' || language === 'json') && ( + +
    + + + + - )} - - - - - -
    - - - - -
    +
    {onExplain && capabilities?.supportsExplain && ( @@ -583,9 +593,9 @@ export const QueryEditor = forwardRef(({
    - {/* Floating AI Input */} - - {showAi && ( + {/* Floating AI Input */} + + {showAi && ( (({ Context: {tables.length} tables
    -
    +
    - - {aiError && ( - -
    -
    - -
    -
    -

    AI Error

    -

    {aiError}

    -
    - + + {aiError && ( + +
    +
    +
    - - )} - - -
    - +
    +

    AI Error

    +

    {aiError}

    +
    + +
    + + )} + + +
    (({
    - )} - - -
    -
    } - onMount={(editor, monaco) => { - editorRef.current = editor; - - // Sync to parent when editor loses focus - editor.onDidBlurEditorText(() => { - handleEditorBlur(); - }); - - editor.onDidChangeCursorSelection(() => { - const selection = editor.getSelection(); - setHasSelection(selection ? !selection.isEmpty() : false); - }); - - // Add custom keyboard shortcut - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { - handleExecute(); - }); - - // Add format shortcut - editor.addCommand(monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, () => { - handleFormat(); - }); - - // Context Menu Actions - editor.addAction({ - id: 'run-query', - label: 'Run Query', - keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], - contextMenuGroupId: 'navigation', - contextMenuOrder: 1, - run: () => handleExecute() - }); - - if (onExplain) { + )} + + +
    + + +
    + } + onMount={(editor, monaco) => { + editorRef.current = editor; + + // Sync to parent when editor loses focus + editor.onDidBlurEditorText(() => { + handleEditorBlur(); + }); + + editor.onDidChangeCursorSelection(() => { + const selection = editor.getSelection(); + setHasSelection(selection ? !selection.isEmpty() : false); + }); + + // Add custom keyboard shortcut + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { + handleExecute(); + }); + + // Add format shortcut + editor.addCommand(monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, () => { + handleFormat(); + }); + + // Context Menu Actions editor.addAction({ - id: 'explain-query', - label: 'Explain Plan', - contextMenuGroupId: 'navigation', - contextMenuOrder: 2, - run: () => onExplain() + id: "run-query", + label: "Run Query", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], + contextMenuGroupId: "navigation", + contextMenuOrder: 1, + run: () => handleExecute(), }); - } - editor.addAction({ - id: 'format-sql', - label: 'Format SQL', - keybindings: [monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF], - contextMenuGroupId: 'modification', - contextMenuOrder: 1, - run: () => handleFormat() - }); - }} - options={getEditorOptions(showLineNumbers)} - /> + if (onExplain) { + editor.addAction({ + id: "explain-query", + label: "Explain Plan", + contextMenuGroupId: "navigation", + contextMenuOrder: 2, + run: () => onExplain(), + }); + } + editor.addAction({ + id: "format-sql", + label: "Format SQL", + keybindings: [monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF], + contextMenuGroupId: "modification", + contextMenuOrder: 1, + run: () => handleFormat(), + }); + }} + options={getEditorOptions(showLineNumbers)} + /> +
    -
    - ); -}); + ); + }, +); -QueryEditor.displayName = 'QueryEditor'; +QueryEditor.displayName = "QueryEditor"; diff --git a/src/components/QueryHistory.tsx b/src/components/QueryHistory.tsx index 99705617..5cafd7d9 100644 --- a/src/components/QueryHistory.tsx +++ b/src/components/QueryHistory.tsx @@ -1,18 +1,25 @@ "use client"; -import React, { useState, useEffect, useMemo } from 'react'; -import { storage } from '@/lib/storage'; -import { QueryHistoryItem } from '@/lib/types'; -import { - CheckCircle2, AlertCircle, - RotateCcw, Trash2, Search, Download, - ArrowUpDown, Hash, - Database, History as HistoryIcon, X -} from 'lucide-react'; -import { Button } from './ui/button'; -import { Input } from './ui/input'; -import { cn } from '@/lib/utils'; -import { format } from 'date-fns'; +import React, { useState, useEffect, useMemo } from "react"; +import { storage } from "@/lib/storage"; +import { QueryHistoryItem } from "@/lib/types"; +import { + CheckCircle2, + AlertCircle, + RotateCcw, + Trash2, + Search, + Download, + ArrowUpDown, + Hash, + Database, + History as HistoryIcon, + X, +} from "lucide-react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; import { DropdownMenu, DropdownMenuContent, @@ -26,16 +33,16 @@ interface QueryHistoryProps { refreshTrigger?: number; } -type SortField = 'executedAt' | 'executionTime' | 'rowCount'; -type SortOrder = 'asc' | 'desc'; +type SortField = "executedAt" | "executionTime" | "rowCount"; +type SortOrder = "asc" | "desc"; export function QueryHistory({ onSelectQuery, activeConnectionId, refreshTrigger }: QueryHistoryProps) { const [history, setHistory] = useState([]); - const [search, setSearch] = useState(''); - const [filterStatus, setFilterStatus] = useState<'all' | 'success' | 'error'>('all'); + const [search, setSearch] = useState(""); + const [filterStatus, setFilterStatus] = useState<"all" | "success" | "error">("all"); const [isGlobal, setIsGlobal] = useState(false); - const [sortField, setSortField] = useState('executedAt'); - const [sortOrder, setSortOrder] = useState('desc'); + const [sortField, setSortField] = useState("executedAt"); + const [sortOrder, setSortOrder] = useState("desc"); // Refresh history when refreshTrigger changes (replaces key-based re-mount) useEffect(() => { @@ -43,32 +50,35 @@ export function QueryHistory({ onSelectQuery, activeConnectionId, refreshTrigger }, [refreshTrigger]); const filteredHistory = useMemo(() => { - return history.filter(item => { - const matchesSearch = item.query.toLowerCase().includes(search.toLowerCase()) || - item.connectionName?.toLowerCase().includes(search.toLowerCase()) || - item.tabName?.toLowerCase().includes(search.toLowerCase()); - const matchesStatus = filterStatus === 'all' || item.status === filterStatus; - const matchesConnection = isGlobal || !activeConnectionId || item.connectionId === activeConnectionId; - return matchesSearch && matchesStatus && matchesConnection; - }).sort((a, b) => { - let valA: number = 0; - let valB: number = 0; - - if (sortField === 'executedAt') { - valA = a.executedAt ? new Date(a.executedAt).getTime() : 0; - valB = b.executedAt ? new Date(b.executedAt).getTime() : 0; - } else { - valA = (a[sortField] as number) || 0; - valB = (b[sortField] as number) || 0; - } + return history + .filter((item) => { + const matchesSearch = + item.query.toLowerCase().includes(search.toLowerCase()) || + item.connectionName?.toLowerCase().includes(search.toLowerCase()) || + item.tabName?.toLowerCase().includes(search.toLowerCase()); + const matchesStatus = filterStatus === "all" || item.status === filterStatus; + const matchesConnection = isGlobal || !activeConnectionId || item.connectionId === activeConnectionId; + return matchesSearch && matchesStatus && matchesConnection; + }) + .sort((a, b) => { + let valA: number = 0; + let valB: number = 0; + + if (sortField === "executedAt") { + valA = a.executedAt ? new Date(a.executedAt).getTime() : 0; + valB = b.executedAt ? new Date(b.executedAt).getTime() : 0; + } else { + valA = (a[sortField] as number) || 0; + valB = (b[sortField] as number) || 0; + } - if (sortOrder === 'asc') return valA > valB ? 1 : -1; - return valA < valB ? 1 : -1; - }); + if (sortOrder === "asc") return valA > valB ? 1 : -1; + return valA < valB ? 1 : -1; + }); }, [history, search, filterStatus, isGlobal, activeConnectionId, sortField, sortOrder]); const handleClearHistory = () => { - if (confirm('Are you sure you want to clear all history?')) { + if (confirm("Are you sure you want to clear all history?")) { storage.clearHistory(); setHistory([]); } @@ -76,40 +86,42 @@ export function QueryHistory({ onSelectQuery, activeConnectionId, refreshTrigger const handleSort = (field: SortField) => { if (sortField === field) { - setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); } else { setSortField(field); - setSortOrder('desc'); + setSortOrder("desc"); } }; - const exportHistory = (format: 'csv' | 'json') => { - let content = ''; - let mimeType = ''; + const exportHistory = (format: "csv" | "json") => { + let content = ""; + let mimeType = ""; const fileName = `query_history_${new Date().getTime()}.${format}`; - if (format === 'csv') { - const headers = ['Executed At', 'Status', 'Connection', 'Tab', 'Execution Time (ms)', 'Rows', 'Query', 'Error']; - const rows = filteredHistory.map(item => [ - item.executedAt, - item.status, - item.connectionName || item.connectionId, - item.tabName || '', - item.executionTime, - item.rowCount || 0, - `"${item.query.replace(/"/g, '""')}"`, - `"${(item.errorMessage || '').replace(/"/g, '""')}"` - ].join(',')); - content = [headers.join(','), ...rows].join('\n'); - mimeType = 'text/csv'; + if (format === "csv") { + const headers = ["Executed At", "Status", "Connection", "Tab", "Execution Time (ms)", "Rows", "Query", "Error"]; + const rows = filteredHistory.map((item) => + [ + item.executedAt, + item.status, + item.connectionName || item.connectionId, + item.tabName || "", + item.executionTime, + item.rowCount || 0, + `"${item.query.replace(/"/g, '""')}"`, + `"${(item.errorMessage || "").replace(/"/g, '""')}"`, + ].join(","), + ); + content = [headers.join(","), ...rows].join("\n"); + mimeType = "text/csv"; } else { content = JSON.stringify(filteredHistory, null, 2); - mimeType = 'application/json'; + mimeType = "application/json"; } const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = url; link.download = fileName; link.click(); @@ -125,68 +137,70 @@ export function QueryHistory({ onSelectQuery, activeConnectionId, refreshTrigger
    -

    - Query History -

    -

    - Showing {filteredHistory.length} executions -

    +

    Query History

    +

    Showing {filteredHistory.length} executions

    - +
    - - exportHistory('csv')} className="text-xs cursor-pointer"> + exportHistory("csv")} className="text-xs cursor-pointer"> Export as CSV - exportHistory('json')} className="text-xs cursor-pointer"> + exportHistory("json")} className="text-xs cursor-pointer"> Export as JSON -
    - +
    - setSearch(e.target.value)} className="pl-9 h-9 bg-white/5 border-white/10 text-xs focus:ring-emerald-500/20 rounded-lg" /> {search && ( - )}
    - +
    - {(['all', 'success', 'error'] as const).map((status) => ( + {(["all", "success", "error"] as const).map((status) => (
    - - - @@ -257,13 +303,13 @@ export function QueryHistory({ onSelectQuery, activeConnectionId, refreshTrigger {filteredHistory.map((item) => ( - @@ -288,11 +334,11 @@ export function QueryHistory({ onSelectQuery, activeConnectionId, refreshTrigger
    - {item.connectionName || 'Unknown'} + {item.connectionName || "Unknown"}
    - {item.tabName || 'Default Tab'} + {item.tabName || "Default Tab"}
    @@ -309,22 +355,24 @@ export function QueryHistory({ onSelectQuery, activeConnectionId, refreshTrigger
    + {parsedData.headers.map((h) => ( + {h}
    + {cell || NULL}
    {col.name} {col.type} {col.isPrimary && PK} {col.nullable !== false ? 'Yes' : 'No'}{col.nullable !== false ? "Yes" : "No"}
    {rowField} - {ck === '__all__' ? `${AGG_LABELS[aggFunction]}(${valueField || '*'})` : ck} + {pivotData.colKeys.map((ck) => ( + + {ck === "__all__" ? `${AGG_LABELS[aggFunction]}(${valueField || "*"})` : ck}
    - {row.rowKey} - {row.rowKey} - {row.values.get(ck) || '0'} + {row.values.get(ck) || "0"}
    Status handleSort('executedAt')}> + handleSort("executedAt")} + >
    Executed At - +
    Source SQL Query handleSort('executionTime')}> + handleSort("executionTime")} + >
    Duration - +
    handleSort('rowCount')}> + handleSort("rowCount")} + >
    Rows - +
    - {item.status === 'success' ? ( + {item.status === "success" ? (
    @@ -277,10 +323,10 @@ export function QueryHistory({ onSelectQuery, activeConnectionId, refreshTrigger
    - {item.executedAt ? format(new Date(item.executedAt), 'MMM d, HH:mm:ss') : '-'} + {item.executedAt ? format(new Date(item.executedAt), "MMM d, HH:mm:ss") : "-"} - {item.executedAt ? format(new Date(item.executedAt), 'yyyy') : ''} + {item.executedAt ? format(new Date(item.executedAt), "yyyy") : ""}
    - 500 ? "text-amber-400 bg-amber-400/10" : "text-zinc-400 bg-white/5" - )}> + 500 ? "text-amber-400 bg-amber-400/10" : "text-zinc-400 bg-white/5", + )} + > {item.executionTime}ms - {item.rowCount != null ? item.rowCount.toLocaleString() : '-'} + {item.rowCount != null ? item.rowCount.toLocaleString() : "-"} - @@ -286,5 +328,5 @@ export function isDangerousQuery(query: string): boolean { if (/^\s*DELETE\b/i.test(normalized) && !/\bWHERE\b/.test(normalized)) return true; if (/\bUPDATE\b[\s\S]*?\bSET\b/i.test(normalized) && !/\bWHERE\b/.test(normalized)) return true; - return patterns.some(p => p.test(query)); + return patterns.some((p) => p.test(query)); } diff --git a/src/components/ResultsGrid.tsx b/src/components/ResultsGrid.tsx index f2fa9bfe..d3d9ec1b 100644 --- a/src/components/ResultsGrid.tsx +++ b/src/components/ResultsGrid.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useMemo, useState, useRef, useCallback, useEffect } from 'react'; -import { QueryResult } from '@/lib/types'; +import React, { useMemo, useState, useRef, useCallback, useEffect } from "react"; +import { QueryResult } from "@/lib/types"; import { flexRender, getCoreRowModel, @@ -9,17 +9,10 @@ import { getSortedRowModel, SortingState, ColumnDef, -} from '@tanstack/react-table'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { cn } from '@/lib/utils'; -import { - ArrowUpDown, - ArrowUp, - ArrowDown, - Eye, - Filter, - Lock, -} from 'lucide-react'; +} from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { cn } from "@/lib/utils"; +import { ArrowUpDown, ArrowUp, ArrowDown, Eye, Filter, Lock } from "lucide-react"; import { type MaskingConfig, detectSensitiveColumnsFromConfig, @@ -28,11 +21,11 @@ import { canToggleMasking, canReveal, loadMaskingConfig, -} from '@/lib/data-masking'; -import { ResultCard } from '@/components/results-grid/ResultCard'; -import { RowDetailSheet } from '@/components/results-grid/RowDetailSheet'; -import { StatsBar, LoadMoreFooter } from '@/components/results-grid/StatsBar'; -import { formatCellValue } from '@/components/results-grid/utils'; +} from "@/lib/data-masking"; +import { ResultCard } from "@/components/results-grid/ResultCard"; +import { RowDetailSheet } from "@/components/results-grid/RowDetailSheet"; +import { StatsBar, LoadMoreFooter } from "@/components/results-grid/StatsBar"; +import { formatCellValue } from "@/components/results-grid/utils"; export interface CellChange { rowIndex: number; @@ -59,11 +52,11 @@ interface ResultsGridProps { // Detect primary column (first text-like column that's not an ID) function detectPrimaryColumn(fields: string[], rows: Record[]): string { - const preferredNames = ['name', 'title', 'label', 'username', 'email', 'description']; + const preferredNames = ["name", "title", "label", "username", "email", "description"]; for (const name of preferredNames) { - if (fields.some(f => f.toLowerCase().includes(name))) { - return fields.find(f => f.toLowerCase().includes(name))!; + if (fields.some((f) => f.toLowerCase().includes(name))) { + return fields.find((f) => f.toLowerCase().includes(name))!; } } @@ -71,7 +64,7 @@ function detectPrimaryColumn(fields: string[], rows: Record[]): if (rows.length > 0) { for (const field of fields) { const value = rows[0][field]; - if (typeof value === 'string' && !field.toLowerCase().includes('id')) { + if (typeof value === "string" && !field.toLowerCase().includes("id")) { return field; } } @@ -82,7 +75,7 @@ function detectPrimaryColumn(fields: string[], rows: Record[]): // Get ID column if exists function detectIdColumn(fields: string[]): string | null { - return fields.find(f => f.toLowerCase() === 'id' || f.toLowerCase().endsWith('_id')) || null; + return fields.find((f) => f.toLowerCase() === "id" || f.toLowerCase().endsWith("_id")) || null; } export function ResultsGrid({ @@ -100,9 +93,9 @@ export function ResultsGrid({ onApplyChanges, }: ResultsGridProps) { const [sorting, setSorting] = useState([]); - const [editingCell, setEditingCell] = useState<{ rowIndex: number, columnId: string } | null>(null); + const [editingCell, setEditingCell] = useState<{ rowIndex: number; columnId: string } | null>(null); const [editValue, setEditValue] = useState(""); - const [viewMode, setViewMode] = useState<'card' | 'table'>('card'); + const [viewMode, setViewMode] = useState<"card" | "table">("card"); const [selectedRow, setSelectedRow] = useState<{ row: Record; index: number } | null>(null); const [columnFilters, setColumnFilters] = useState>(new Map()); const [activeFilterCol, setActiveFilterCol] = useState(null); @@ -122,7 +115,7 @@ export function ResultsGrid({ // Config-based sensitive column detection const sensitiveColumns = useMemo( () => detectSensitiveColumnsFromConfig(result.fields, resolvedConfig), - [result.fields, resolvedConfig] + [result.fields, resolvedConfig], ); const hasSensitive = sensitiveColumns.size > 0; @@ -134,9 +127,9 @@ export function ResultsGrid({ // Per-cell reveal with auto-hide const revealCell = useCallback((key: string) => { - setRevealedCells(prev => new Set(prev).add(key)); + setRevealedCells((prev) => new Set(prev).add(key)); setTimeout(() => { - setRevealedCells(prev => { + setRevealedCells((prev) => { const next = new Set(prev); next.delete(key); return next; @@ -144,23 +137,17 @@ export function ResultsGrid({ }, 10000); }, []); - const primaryColumn = useMemo( - () => detectPrimaryColumn(result.fields, result.rows), - [result.fields, result.rows] - ); + const primaryColumn = useMemo(() => detectPrimaryColumn(result.fields, result.rows), [result.fields, result.rows]); - const idColumn = useMemo( - () => detectIdColumn(result.fields), - [result.fields] - ); + const idColumn = useMemo(() => detectIdColumn(result.fields), [result.fields]); // Filter rows based on column filters const filteredRows = useMemo(() => { if (columnFilters.size === 0) return result.rows; - return result.rows.filter(row => { + return result.rows.filter((row) => { for (const [col, filterVal] of columnFilters) { if (!filterVal) continue; - const cellVal = String(row[col] ?? '').toLowerCase(); + const cellVal = String(row[col] ?? "").toLowerCase(); if (!cellVal.includes(filterVal.toLowerCase())) return false; } return true; @@ -176,9 +163,12 @@ export function ResultsGrid({ }, [columnFilters]); // Check if a cell has a pending change - const getCellChange = useCallback((rowIndex: number, columnId: string): CellChange | undefined => { - return pendingChanges?.find(c => c.rowIndex === rowIndex && c.columnId === columnId); - }, [pendingChanges]); + const getCellChange = useCallback( + (rowIndex: number, columnId: string): CellChange | undefined => { + return pendingChanges?.find((c) => c.rowIndex === rowIndex && c.columnId === columnId); + }, + [pendingChanges], + ); const handleClearFilters = useCallback(() => { setColumnFilters(new Map()); @@ -186,7 +176,7 @@ export function ResultsGrid({ }, []); const columns = useMemo>[]>(() => { - return result.fields.map(field => ({ + return result.fields.map((field) => ({ accessorKey: field, header: ({ column }) => { const hasFilter = columnFilters.has(field) && !!columnFilters.get(field); @@ -199,7 +189,9 @@ export function ResultsGrid({ > {field} {isSensitive && ( - + + + )}
    {column.getIsSorted() === "asc" ? ( @@ -214,9 +206,14 @@ export function ResultsGrid({