Repository: caioricciuti/duck-ui Branch: main Commit: 6cbc5df4ac5d Files: 173 Total size: 925.3 KB Directory structure: gitextract_fb4zvf6o/ ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.yml │ │ └── feature.yml │ └── workflows/ │ ├── 2-docker-build.yml │ └── ci.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── LICENSE.md ├── README.md ├── components.json ├── docker-compose.yml ├── eslint.config.js ├── index.html ├── inject-env.js ├── package.json ├── public/ │ └── databases/ │ ├── README.md │ └── manifest.json ├── serve.json ├── src/ │ ├── components/ │ │ ├── charts/ │ │ │ ├── ChartVisualizationPro.tsx │ │ │ ├── UPlotChart.tsx │ │ │ └── tooltipPlugin.ts │ │ ├── cloud/ │ │ │ ├── CloudBrowser.tsx │ │ │ └── CloudConnectionModal.tsx │ │ ├── command-palette/ │ │ │ └── CommandPalette.tsx │ │ ├── common/ │ │ │ ├── FloatingActionButton.tsx │ │ │ └── ImportOptionsPopover.tsx │ │ ├── connection/ │ │ │ └── ConnectionsModal.tsx │ │ ├── duck-brain/ │ │ │ ├── DuckBrainCodeBlock.tsx │ │ │ ├── DuckBrainInput.tsx │ │ │ ├── DuckBrainMessages.tsx │ │ │ ├── DuckBrainPanel.tsx │ │ │ ├── MarkdownContent.tsx │ │ │ ├── ResultsArtifact.tsx │ │ │ └── SchemaAutocomplete.tsx │ │ ├── editor/ │ │ │ ├── SqlEditor.tsx │ │ │ └── monacoConfig.ts │ │ ├── explorer/ │ │ │ ├── ColumnNode.tsx │ │ │ ├── DataExplorer.tsx │ │ │ ├── FileImporter.tsx │ │ │ └── TreeNode.tsx │ │ ├── folders/ │ │ │ └── FolderBrowser.tsx │ │ ├── layout/ │ │ │ ├── ConnectionSwitcher.tsx │ │ │ ├── MobileNavDrawer.tsx │ │ │ └── Sidebar.tsx │ │ ├── notebook/ │ │ │ ├── MarkdownRenderer.tsx │ │ │ ├── NotebookCell.tsx │ │ │ ├── NotebookTab.tsx │ │ │ └── NotebookToolbar.tsx │ │ ├── profile/ │ │ │ ├── PasswordDialog.tsx │ │ │ ├── ProfileAvatar.tsx │ │ │ ├── ProfileEditor.tsx │ │ │ └── ProfilePicker.tsx │ │ ├── saved-queries/ │ │ │ ├── SaveQueryDialog.tsx │ │ │ └── SavedQueriesPanel.tsx │ │ ├── table/ │ │ │ ├── CellValueViewer.tsx │ │ │ ├── ColumnStatsPanel.tsx │ │ │ └── DuckUItable.tsx │ │ ├── theme/ │ │ │ ├── mode-toggle.tsx │ │ │ └── theme-provider.tsx │ │ ├── ui/ │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── multi-select.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ └── tooltip.tsx │ │ └── workspace/ │ │ ├── BrainTab.tsx │ │ ├── ConnectionsTab.tsx │ │ ├── ExplainPlanViewer.tsx │ │ ├── HomeTab.tsx │ │ ├── QueryHistory.tsx │ │ ├── SettingsTab.tsx │ │ ├── SortableTab.tsx │ │ ├── SqlTab.tsx │ │ └── WorkspaceTabs.tsx │ ├── hooks/ │ │ └── useQueryFromURL.ts │ ├── index.css │ ├── lib/ │ │ ├── chartDataTransform.ts │ │ ├── chartExport.ts │ │ ├── chartUtils.ts │ │ ├── cloudStorage/ │ │ │ ├── index.ts │ │ │ └── testHttpfs.ts │ │ ├── duckBrain/ │ │ │ ├── index.ts │ │ │ ├── models.config.ts │ │ │ ├── prompts/ │ │ │ │ └── text-to-sql.ts │ │ │ ├── providers/ │ │ │ │ ├── anthropic.provider.ts │ │ │ │ ├── index.ts │ │ │ │ ├── openai.provider.ts │ │ │ │ └── types.ts │ │ │ ├── schemaFormatter.ts │ │ │ ├── sqlParser.ts │ │ │ ├── webllm.service.ts │ │ │ └── webllm.worker.ts │ │ ├── fileSystem/ │ │ │ └── index.ts │ │ ├── sqlSanitize.ts │ │ └── utils.ts │ ├── main.tsx │ ├── pages/ │ │ └── Home.tsx │ ├── services/ │ │ ├── duckdb/ │ │ │ ├── __tests__/ │ │ │ │ ├── resultParser.test.ts │ │ │ │ └── utils.test.ts │ │ │ ├── externalConnection.ts │ │ │ ├── index.ts │ │ │ ├── opfsConnection.ts │ │ │ ├── resultParser.ts │ │ │ ├── schemaFetcher.ts │ │ │ ├── utils.ts │ │ │ └── wasmConnection.ts │ │ └── persistence/ │ │ ├── __tests__/ │ │ │ ├── crypto.test.ts │ │ │ └── migrations.test.ts │ │ ├── crypto.ts │ │ ├── fallback.ts │ │ ├── index.ts │ │ ├── migrations.ts │ │ ├── repositories/ │ │ │ ├── aiConfigRepository.ts │ │ │ ├── connectionRepository.ts │ │ │ ├── index.ts │ │ │ ├── profileRepository.ts │ │ │ ├── queryHistoryRepository.ts │ │ │ ├── savedQueryRepository.ts │ │ │ ├── settingsRepository.ts │ │ │ └── workspaceRepository.ts │ │ └── systemDb.ts │ ├── store/ │ │ ├── index.ts │ │ ├── slices/ │ │ │ ├── connectionSlice.ts │ │ │ ├── duckBrainSlice.ts │ │ │ ├── duckdbSlice.ts │ │ │ ├── fileSystemSlice.ts │ │ │ ├── profileSlice.ts │ │ │ ├── querySlice.ts │ │ │ ├── schemaSlice.ts │ │ │ └── tabSlice.ts │ │ └── types.ts │ ├── types/ │ │ └── filesystem.d.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Ignore node_modules (local dependencies) node_modules # Ignore build artifacts dist # Ignore configuration files that shouldn't be shared .env .vscode .idea # Ignore miscellaneous system files and logs .DS_Store npm-debug.log yarn-debug.log yarn-error.log package-lock.json # Ignore Git-specific files .git .gitignore /docs ================================================ FILE: .github/ISSUE_TEMPLATE/bug.yml ================================================ name: 🐞 Bug Report description: Found something that doesn't work as expected? body: - type: dropdown id: Environment attributes: label: Environment description: How are you using Duck-UI? options: - NPM build - Docker - Other validations: required: true - type: textarea id: repro attributes: label: How did you encounter the bug? description: How can this bug be reproduced? Please provide steps to reproduce. placeholder: |- 1. Run Docker container... 2. npm run build... 3. Go to... validations: required: true - type: textarea id: expected attributes: label: What did you expect? description: What it supposed to happen? What did you expect to see? validations: required: true - type: textarea id: actual attributes: label: Actual Result description: What was the accual result? validations: required: true - type: dropdown id: Browser attributes: label: Browser description: What browser are you using? options: - Chrome - Firefox - Edge - Safari - Brave - Other validations: required: true # browser version with instructions to check it - type: textarea id: browser-version attributes: label: Browser Version description: What version of the browser are you using? placeholder: e.g. 125.0.0 validations: required: true - type: textarea id: version attributes: label: Version description: What version of Duck-UI are you using? placeholder: e.g. 1.5.0 validations: required: true - type: markdown attributes: value: |- ### All done, now, just submit the issue and I will do my best to take care of it! 🙏 validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature.yml ================================================ name: 💡 Feature Request description: Tell us about something Duck-UI doesn't do yet, but should! body: - type: textarea id: idea attributes: label: Idea Statement description: Which is the feature you would like to see implemented? placeholder: |- I want to be able to do anything I want, whenever I want. Because my ideas are the best. validations: required: true - type: textarea id: expected attributes: label: Feature implementation brainstorm description: All your ideas are welcome, let's brainstorm together. placeholder: |- Create the next big feature that will all our problems. validations: required: false - type: markdown attributes: value: |- ## Thanks 🙏 validations: required: false ================================================ FILE: .github/workflows/2-docker-build.yml ================================================ # .github/workflows/2-docker-build.yml name: Build and Push Docker Image on: push: branches: - main paths-ignore: - '**.md' - 'docs/**' pull_request: branches: - main paths-ignore: - '**.md' - 'docs/**' jobs: build: runs-on: ubuntu-latest if: | (!contains(github.event.head_commit.message, 'docker-false') && !contains(github.event.head_commit.message, 'docs-only')) || contains(github.event.head_commit.message, 'docker-only') permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_PAT }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=sha,format=long type=ref,event=pr - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main] paths-ignore: - "**.md" - "docs/**" pull_request: branches: [main] paths-ignore: - "**.md" - "docs/**" jobs: lint: name: Lint & Format runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - run: bun install --frozen-lockfile - run: bun run lint - run: bun run format:check typecheck: name: Type Check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - run: bun install --frozen-lockfile - run: bun run typecheck test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - run: bun install --frozen-lockfile - run: bun run test build: name: Build runs-on: ubuntu-latest needs: [lint, typecheck, test] steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - run: bun install --frozen-lockfile - run: bun run build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/ retention-days: 7 ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local package-lock.json yarn.lock pnpm-lock.yaml # Editor directories and files .vscode/* /vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? .env post.md CLAUDE.md ================================================ FILE: .husky/pre-commit ================================================ bunx lint-staged ================================================ FILE: .prettierignore ================================================ dist node_modules bun.lockb *.wasm public docs/.vitepress/cache docs/.vitepress/dist ================================================ FILE: .prettierrc ================================================ { "semi": true, "singleQuote": false, "trailingComma": "es5", "tabWidth": 2, "printWidth": 100, "bracketSpacing": true, "arrowParens": "always", "endOfLine": "lf" } ================================================ FILE: Dockerfile ================================================ # Use an official Node runtime as a parent image with bun FROM oven/bun:1-alpine AS build ARG DUCK_UI_BASEPATH="/" # Set the working directory WORKDIR /app # Copy package.json and bun.lockb (if exists) COPY package.json bun.lockb* ./ # Install dependencies RUN bun install --frozen-lockfile # Bundle app source inside Docker image COPY . . # Build the app RUN bun run build # Use a second stage to reduce image size FROM oven/bun:1-alpine # Set the working directory for the second stage WORKDIR /app # Copy the build directory from the first stage to the second stage COPY --from=build /app/dist /app # Copy the injection script and serve config (COOP/COEP headers for OPFS) COPY inject-env.js /app/ COPY serve.json /app/ # Install just serve for serving the built app RUN bun add serve # Expose port 5522 EXPOSE 5522 # Define environment variables ENV DUCK_UI_EXTERNAL_CONNECTION_NAME="" ENV DUCK_UI_EXTERNAL_HOST="" ENV DUCK_UI_EXTERNAL_PORT="" ENV DUCK_UI_EXTERNAL_USER="" ENV DUCK_UI_EXTERNAL_PASS="" ENV DUCK_UI_EXTERNAL_DATABASE_NAME="" # Create user and change ownership RUN addgroup -S duck-group -g 1001 && adduser -S duck-user -u 1001 -G duck-group RUN chown -R duck-user:duck-group /app USER duck-user # Run the injection script then serve using bunx CMD bun inject-env.js && bunx serve -s -l 5522 -c serve.json ================================================ FILE: LICENSE.md ================================================ # License ## DUCK UI - Apache License 2.0 Copyright 2025 Caio Ricciuti Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ## What does this mean? The Apache License 2.0 is a permissive open source license that provides users with extensive freedom to use, modify, and distribute the software, while also offering robust legal protection through its patent grant and clear contribution terms. ### You are free to: - ✅ Use the software commercially - ✅ Modify the software - ✅ Distribute the software - ✅ Use the software for private use - ✅ Sublicense the software - ✅ Use patents claims of contributors to the code ### Under the following conditions: - ℹ️ Include the original copyright notice - ℹ️ Include a copy of the license - ℹ️ State significant changes made to the software - ℹ️ Include the NOTICE file (if present) with attribution notes ### With the understanding that: - ⚠️ The software is provided "as is", without warranty of any kind - ⚠️ The authors cannot be held liable for damages - ⚠️ Trademark use is not granted except as required for describing the origin of the work ## Key Benefits of Apache 2.0 ### Patent Protection Unlike simpler licenses like MIT, Apache 2.0 includes an express patent grant from contributors to users. This means that if a contributor has patents that cover their contribution, they automatically grant you a license to use those patents. ### Clear Contribution Terms The license explicitly states that any contributions are assumed to be under the same Apache 2.0 license unless otherwise specified, providing clarity for collaborative development. ### Compatibility Apache 2.0 is compatible with many other open source licenses and is widely accepted in both open source and commercial contexts. ## Third-Party Licenses CH-UI is built on top of several open-source projects. We'd like to acknowledge and give credit to these projects: - [ClickHouse](https://github.com/ClickHouse/ClickHouse) - Apache 2.0 License - [React](https://github.com/facebook/react) - MIT License - [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) - MIT License - [Zustand](https://github.com/pmndrs/zustand) - MIT License - [Monaco Editor](https://github.com/microsoft/monaco-editor) - MIT License - [Lucide Icons](https://github.com/lucide-icons/lucide) - ISC License For the full text of these licenses, please visit the respective project repositories. ================================================ FILE: README.md ================================================ # Duck-UI Logo Duck-UI Duck-UI is a web-based interface for interacting with DuckDB, a high-performance analytical database system. This project leverages DuckDB's WebAssembly (WASM) capabilities to provide a seamless and efficient user experience directly in the browser. # [Official Docs](https://duckui.com?utm_source=github&utm_medium=readme) 🚀 # [Demo](https://demo.duckui.com?utm_source=github&utm_medium=readme) 💻 ## Features - **SQL Editor**: Write and execute SQL queries with syntax highlighting and auto-completion. - **Data Import**: Import data from CSV, JSON, Parquet, and Arrow files. - **Data Explorer**: Browse and manage databases and tables. - **Query History**: View and manage your recent SQL queries. ## Getting Started ### Docker (Recommended) ```bash docker run -p 5522:5522 ghcr.io/caioricciuti/duck-ui:latest ``` Open your browser and navigate to `http://localhost:5522`. ### Environment Variables You can customize Duck-UI behavior using environment variables: ```bash # For external DuckDB connections docker run -p 5522:5522 \ -e DUCK_UI_EXTERNAL_CONNECTION_NAME="My DuckDB Server" \ -e DUCK_UI_EXTERNAL_HOST="http://duckdb-server" \ -e DUCK_UI_EXTERNAL_PORT="8000" \ -e DUCK_UI_EXTERNAL_USER="username" \ -e DUCK_UI_EXTERNAL_PASS="password" \ -e DUCK_UI_EXTERNAL_DATABASE_NAME="my_database" \ -e DUCK_UI_ALLOW_UNSIGNED_EXTENSIONS="true" \ ghcr.io/caioricciuti/duck-ui:latest ``` | Runtime Variable | Description | Default | |----------|-------------|---------| | `DUCK_UI_EXTERNAL_CONNECTION_NAME` | Name for the external connection | "" | | `DUCK_UI_EXTERNAL_HOST` | Host URL for external DuckDB | "" | | `DUCK_UI_EXTERNAL_PORT` | Port for external DuckDB | null | | `DUCK_UI_EXTERNAL_USER` | Username for external connection | "" | | `DUCK_UI_EXTERNAL_PASS` | Password for external connection | "" | | `DUCK_UI_EXTERNAL_DATABASE_NAME` | Database name for external connection | "" | | `DUCK_UI_ALLOW_UNSIGNED_EXTENSIONS` | Allow unsigned extensions in DuckDB | false | | `DUCK_UI_DUCKDB_WASM_USE_CDN` | Load DuckDB WASM from CDN (ignored when build-time `DUCK_UI_DUCKDB_WASM_CDN_ONLY=true`) | false | | `DUCK_UI_DUCKDB_WASM_BASE_URL` | Custom CDN base URL (used when `DUCK_UI_DUCKDB_WASM_USE_CDN=true`) | auto jsDelivr | | Build-time Variable | Description | Default | |----------|-------------|---------| | `DUCK_UI_DUCKDB_WASM_CDN_ONLY` | Build a CDN-only artifact (local DuckDB WASM assets are not bundled). | false | When `DUCK_UI_DUCKDB_WASM_CDN_ONLY=true`, runtime `DUCK_UI_DUCKDB_WASM_USE_CDN=false` cannot switch back to local WASM. ### Prerequisites - Node.js >= 20.x - npm >= 10.x ### Installation 1. Clone the repository: ```bash git clone https://github.com/caioricciuti/duck-ui.git cd duck-ui ``` 2. Install dependencies: ```bash npm install # or yarn install ``` ### Running the Application 1. Start the development server: ```bash npm run dev # or yarn dev ``` 2. Open your browser and navigate to `http://localhost:5173`. ### Building for Production To create a production build, run: ```bash npm run build # or yarn build ``` The output will be in the `dist` directory. ### Running with Docker 1. Build the Docker image: ```bash docker build -t duck-ui . ``` 2. Run the Docker container: ```bash docker run -p 5522:5522 duck-ui ``` 3. Open your browser and navigate to `http://localhost:5522`. ## Usage ### SQL Editor - Write your SQL queries in the editor. - Use `Cmd/Ctrl + Enter` to execute the query. - View the results in the results pane. ### Data Import - Click on the "Import Files" button to upload CSV, JSON, Parquet, or Arrow files. - Configure the table name and import settings. - For CSV files, you can customize import options: - Header row detection - Auto-detection of column types - Delimiter specification - Error handling (ignore errors, null padding for missing columns) - View the imported data in the Data Explorer. ### Data Explorer - Browse through the databases and tables. - Preview table data and view table schemas. - Delete tables if needed. ### Query History - Access your recent queries from the Query History section. - Copy queries to the clipboard or re-execute them. ### Theme Toggle - Switch between light and dark themes using the theme toggle button. ### Keyboard Shortcuts - `Cmd/Ctrl + B`: Expand/Shrink Sidebar - `Cmd/Ctrl + K`: Open Search Bar - `Cmd/Ctrl + Enter`: Run Query - `Cmd/Ctrl + Shift + Enter`: Run highlighted query ## Contributing Contributions are welcome! Please follow these steps to contribute: 1. Fork the repository. 2. Create a new branch (`git checkout -b feature/your-feature`). 3. Commit your changes (`git commit -m 'Add some feature'`). 4. Push to the branch (`git push origin feature/your-feature`). 5. Open a pull request. ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE.md) file for details. ## Acknowledgements - [DuckDB](https://duckdb.org/) - [React](https://reactjs.org/) - [Tailwind CSS](https://tailwindcss.com/) - [Zustand](https://github.com/pmndrs/zustand) - [Lucide Icons](https://lucide.dev/) ## Contact For any inquiries or support, please contact [Caio Ricciuti](https://github.com/caioricciuti). ## Sponsors This project is sponsored by: ### [Ibero Data](https://iberodata.es/) Ibero Data Logo ### [qxip](https://qxip.net/?utm_source=duck-ui&utm_medium=sponsorship) qxip
Want to be a sponsor? [Contact us](mailto:caio.ricciuti+sponsorship@outlook.com). ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: docker-compose.yml ================================================ services: duck-ui: image: ghcr.io/caioricciuti/duck-ui:latest restart: always ports: - "${DUCK_UI_PORT:-5522}:5522" environment: # External connection (optional) - DUCK_UI_EXTERNAL_CONNECTION_NAME=${DUCK_UI_EXTERNAL_CONNECTION_NAME:-} - DUCK_UI_EXTERNAL_HOST=${DUCK_UI_EXTERNAL_HOST:-} - DUCK_UI_EXTERNAL_PORT=${DUCK_UI_EXTERNAL_PORT:-} - DUCK_UI_EXTERNAL_USER=${DUCK_UI_EXTERNAL_USER:-} - DUCK_UI_EXTERNAL_PASS=${DUCK_UI_EXTERNAL_PASS:-} - DUCK_UI_EXTERNAL_DATABASE_NAME=${DUCK_UI_EXTERNAL_DATABASE_NAME:-} # DuckDB configuration - DUCK_UI_ALLOW_UNSIGNED_EXTENSIONS=${DUCK_UI_ALLOW_UNSIGNED_EXTENSIONS:-false} ================================================ FILE: eslint.config.js ================================================ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' export default tseslint.config( { ignores: ['dist', 'docs'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, ], }, }, ) ================================================ FILE: index.html ================================================ Duck UI
================================================ FILE: inject-env.js ================================================ const fs = require("fs"); const path = require("path"); const indexHtmlPath = path.join(__dirname, "index.html"); let indexHtmlContent = fs.readFileSync(indexHtmlPath, "utf8"); // Inject the environment variables const envVars = { DUCK_UI_EXTERNAL_CONNECTION_NAME: process.env.DUCK_UI_EXTERNAL_CONNECTION_NAME || "", DUCK_UI_EXTERNAL_HOST: process.env.DUCK_UI_EXTERNAL_HOST || "", DUCK_UI_EXTERNAL_PORT: process.env.DUCK_UI_EXTERNAL_PORT || null, DUCK_UI_EXTERNAL_USER: process.env.DUCK_UI_EXTERNAL_USER || "", DUCK_UI_EXTERNAL_PASS: process.env.DUCK_UI_EXTERNAL_PASS || "", DUCK_UI_EXTERNAL_DATABASE_NAME: process.env.DUCK_UI_EXTERNAL_DATABASE_NAME || "", // Add new configuration for DuckDB settings DUCK_UI_ALLOW_UNSIGNED_EXTENSIONS: process.env.DUCK_UI_ALLOW_UNSIGNED_EXTENSIONS === "true" || false, DUCK_UI_DUCKDB_WASM_USE_CDN: process.env.DUCK_UI_DUCKDB_WASM_USE_CDN === "true" || false, DUCK_UI_DUCKDB_WASM_BASE_URL: process.env.DUCK_UI_DUCKDB_WASM_BASE_URL || "" }; const scriptContent = ` `; // Insert the script just before the closing tag indexHtmlContent = indexHtmlContent.replace( "", `${scriptContent}` ); fs.writeFileSync(indexHtmlPath, indexHtmlContent); console.log("Environment variables injected successfully"); ================================================ FILE: package.json ================================================ { "name": "duck-ui", "private": true, "version": "0.0.39", "release_date": "2026-04-13", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", "typecheck": "tsc --noEmit", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", "prepare": "husky" }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@duckdb/duckdb-wasm": "1.33.1-dev45.0", "@hookform/resolvers": "^5.2.2", "@mlc-ai/web-llm": "^0.2.82", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.23", "@types/dompurify": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dompurify": "^3.3.3", "exceljs": "^4.4.0", "framer-motion": "^12.38.0", "html2canvas": "^1.4.1", "lucide-react": "^0.546.0", "marked": "^17.0.6", "monaco-editor": "^0.55.1", "openai": "^6.34.0", "papaparse": "^5.5.3", "react": "^19.2.5", "react-dom": "^19.2.5", "react-error-boundary": "^6.1.1", "react-hook-form": "^7.72.1", "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "react-router": "^7.14.0", "sonner": "^2.0.7", "sql-formatter": "^15.7.3", "tailwind-merge": "^3.5.0", "uplot": "^1.6.32", "vaul": "^1.1.2", "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.2", "@types/papaparse": "^5.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", "baseline-browser-mapping": "^2.10.18", "esbuild": "^0.25.12", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", "prettier": "^3.8.2", "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "typescript-eslint": "^8.58.2", "vite": "^7.3.2", "vitest": "^4.1.4" }, "description": "Duck-UI is a web-based interface for interacting with DuckDB, a high-performance analytical database system. This project leverages DuckDB's WebAssembly (WASM) capabilities to provide a seamless and efficient user experience directly in the browser.", "main": "eslint.config.js", "directories": { "doc": "docs" }, "repository": { "type": "git", "url": "git+https://github.com/caioricciuti/duck-ui.git" }, "keywords": [], "author": "", "license": "ISC", "bugs": { "url": "https://github.com/caioricciuti/duck-ui/issues" }, "homepage": "https://github.com/caioricciuti/duck-ui#readme", "lint-staged": { "src/**/*.{ts,tsx,css}": "prettier --write" }, "overrides": { "brace-expansion": "^2.0.3", "picomatch": "^4.0.4", "flatted": "^3.4.2" } } ================================================ FILE: public/databases/README.md ================================================ # Embedded Databases This directory allows you to embed DuckDB database files (`.db`) that will be automatically loaded when DuckUI starts. This is perfect for: - Deploying DuckUI with demo data - Distributing pre-configured databases - Creating self-contained analytical dashboards ## How to Add Your Database 1. **Place your `.db` file in this directory** ``` public/databases/my-database.db ``` 2. **Register it in `manifest.json`** ```json { "databases": [ { "name": "My Database", "file": "my-database.db", "description": "Description of your database", "autoLoad": true } ] } ``` 3. **Build and deploy** ```bash bun run build ``` ## Manifest Format Each database entry in `manifest.json` supports: - **`name`** (required): Display name in the UI - **`file`** (required): Filename of the `.db` file in this directory - **`description`** (optional): Description shown in the UI - **`autoLoad`** (optional, default: `true`): Whether to load on startup ## Example ```json { "databases": [ { "name": "Sales Demo", "file": "sales-demo.db", "description": "Sample sales data from 2023", "autoLoad": true }, { "name": "Analytics", "file": "analytics.db", "description": "Web analytics data", "autoLoad": false } ] } ``` ## Notes - Database files are fetched and loaded in the browser - Large database files will increase initial load time - All databases are loaded into memory (consider file sizes) - You can attach/detach databases dynamically from the SQL editor ================================================ FILE: public/databases/manifest.json ================================================ { "databases": [] } ================================================ FILE: serve.json ================================================ { "headers": [ { "source": "**/*", "headers": [ { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, { "key": "Cross-Origin-Embedder-Policy", "value": "credentialless" } ] } ] } ================================================ FILE: src/components/charts/ChartVisualizationPro.tsx ================================================ /** * Professional Chart Visualization Component * Features: Auto-chart, live preview, multi-series, customization, export * Powered by uPlot (canvas-based, lightweight) */ import React, { useState, useRef, useMemo, useEffect, useCallback } from "react"; import uPlot from "uplot"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { MultiSelect } from "@/components/ui/multi-select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input"; import { Download, BarChart3, LineChart, PieChart as PieChartIcon, ScatterChart, AreaChart, Settings2, RotateCcw, Layers, TrendingUp, CircleDot, ArrowUpDown, } from "lucide-react"; import { toast } from "sonner"; import { useTheme } from "@/components/theme/theme-provider"; import { formatNumber, formatNumberWithSuffix, shortenLabel } from "@/lib/chartUtils"; import { transformData, isNumericColumn, suggestChartTypes } from "@/lib/chartDataTransform"; import { exportChartAsPNG } from "@/lib/chartExport"; import UPlotChart from "./UPlotChart"; import { tooltipPlugin } from "./tooltipPlugin"; import type { QueryResult, ChartConfig, ChartType, DataTransform } from "@/store"; interface ChartVisualizationProProps { result: QueryResult; chartConfig?: ChartConfig; onConfigChange: (config: ChartConfig | undefined) => void; } // Enhanced color palette const DEFAULT_COLORS = [ "#D99B43", "#8B5CF6", "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#EC4899", "#6366F1", "#14B8A6", "#F97316", ]; // Chart type display labels with icons const CHART_TYPE_INFO: Record = { bar: { label: "Bar", icon: BarChart3 }, grouped_bar: { label: "Grouped Bar", icon: Layers }, stacked_bar: { label: "Stacked Bar", icon: Layers }, line: { label: "Line", icon: LineChart }, area: { label: "Area", icon: AreaChart }, stacked_area: { label: "Stacked Area", icon: TrendingUp }, pie: { label: "Pie", icon: PieChartIcon }, donut: { label: "Donut", icon: CircleDot }, scatter: { label: "Scatter", icon: ScatterChart }, }; // ── SVG Pie/Donut Chart ────────────────────────────────────────────────────── function PieChartDisplay({ data, xKey, yKey, colors, isDonut, innerRadius = 0.45, theme, }: { data: Record[]; xKey: string; yKey: string; colors: string[]; isDonut: boolean; innerRadius?: number; theme: string; }) { const [hoveredIdx, setHoveredIdx] = useState(null); const total = data.reduce((sum, row) => sum + (Number(row[yKey]) || 0), 0); if (total === 0) return (
No data
); const slices: { label: string; value: number; pct: number; color: string }[] = []; let cumAngle = -Math.PI / 2; const arcs: { d: string; color: string; midAngle: number; pct: number; label: string; value: number; }[] = []; data.forEach((row, i) => { const value = Number(row[yKey]) || 0; const pct = value / total; const angle = pct * 2 * Math.PI; const startAngle = cumAngle; const endAngle = cumAngle + angle; const midAngle = startAngle + angle / 2; const color = colors[i % colors.length]; slices.push({ label: String(row[xKey]), value, pct, color }); const outerR = 1; const innerR = isDonut ? innerRadius : 0; const largeArc = angle > Math.PI ? 1 : 0; const x1 = Math.cos(startAngle) * outerR; const y1 = Math.sin(startAngle) * outerR; const x2 = Math.cos(endAngle) * outerR; const y2 = Math.sin(endAngle) * outerR; const ix1 = Math.cos(endAngle) * innerR; const iy1 = Math.sin(endAngle) * innerR; const ix2 = Math.cos(startAngle) * innerR; const iy2 = Math.sin(startAngle) * innerR; const d = isDonut ? `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z` : `M 0 0 L ${x1} ${y1} A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2} Z`; arcs.push({ d, color, midAngle, pct, label: String(row[xKey]), value }); cumAngle = endAngle; }); const strokeColor = theme === "dark" ? "#1a1a1a" : "#fff"; return (
{/* Chart */}
{arcs.map((arc, i) => { const isHovered = hoveredIdx === i; const tx = isHovered ? Math.cos(arc.midAngle) * 0.06 : 0; const ty = isHovered ? Math.sin(arc.midAngle) * 0.06 : 0; return ( setHoveredIdx(i)} onMouseLeave={() => setHoveredIdx(null)} /> {arc.pct >= 0.05 && ( {`${(arc.pct * 100).toFixed(0)}%`} )} ); })} {/* Donut center total */} {isDonut && ( Total {formatNumberWithSuffix(total)} )} {/* Hover tooltip */} {hoveredIdx !== null && (
{slices[hoveredIdx].label}
{formatNumber(slices[hoveredIdx].value)} ({(slices[hoveredIdx].pct * 100).toFixed(1)} %)
)}
{/* Legend */}
{slices.map((s, i) => (
setHoveredIdx(i)} onMouseLeave={() => setHoveredIdx(null)} > {s.label} {formatNumberWithSuffix(s.value)} {(s.pct * 100).toFixed(1)}%
))}
); } // ── XY Chart Legend ────────────────────────────────────────────────────────── function ChartLegend({ series, colors, onToggle, }: { series: { label: string; show: boolean }[]; colors: string[]; onToggle: (idx: number) => void; }) { if (series.length <= 1) return null; return (
{series.map((s, i) => ( ))}
); } // ── Main Component ─────────────────────────────────────────────────────────── export const ChartVisualizationPro: React.FC = ({ result, chartConfig, onConfigChange, }) => { const { theme } = useTheme(); const chartRef = useRef(null); const uPlotRef = useRef(null); const [hiddenSeries, setHiddenSeries] = useState>(new Set()); // Get numeric columns for Y-axis const numericColumns = useMemo( () => result.columns.filter((col) => isNumericColumn(result.data, col)), [result] ); // Auto-chart: detect best config when no config exists const autoDetect = useCallback((): ChartConfig => { const xAxis = result.columns.find((col) => !isNumericColumn(result.data, col)) || result.columns[0] || ""; const yCol = numericColumns[0] || result.columns[1] || ""; const suggested = suggestChartTypes(result, xAxis, yCol); const type = (suggested[0] || "bar") as ChartType; return { type, xAxis, yAxis: yCol, colors: DEFAULT_COLORS, showGrid: true, showValues: false, smooth: false, legend: { show: true, position: "bottom" }, }; }, [result, numericColumns]); // On mount: auto-generate config if none provided useEffect(() => { if (!chartConfig && result.data.length > 0) { onConfigChange(autoDetect()); } }, []); // eslint-disable-line react-hooks/exhaustive-deps // Live config update helper const updateConfig = useCallback( (updates: Partial) => { const base = chartConfig || autoDetect(); onConfigChange({ ...base, ...updates }); }, [chartConfig, autoDetect, onConfigChange] ); const updateTransform = useCallback( (updates: Partial) => { const base = chartConfig || autoDetect(); onConfigChange({ ...base, transform: { ...base.transform, ...updates } }); }, [chartConfig, autoDetect, onConfigChange] ); // Current effective config const config = chartConfig || autoDetect(); // Selected Y columns (from series or single yAxis) const selectedYColumns = useMemo(() => { if (config.series?.length) return config.series.map((s) => s.column); if (config.yAxis) return [config.yAxis]; return []; }, [config]); const handleYColumnsChange = useCallback( (cols: string[]) => { const series = cols.map((col, idx) => ({ column: col, label: col, color: DEFAULT_COLORS[idx % DEFAULT_COLORS.length], })); updateConfig({ series: series.length > 1 ? series : undefined, yAxis: series.length === 1 ? cols[0] : undefined, }); }, [updateConfig] ); // Transform data based on configuration const transformedData = useMemo(() => { return transformData(result, config.transform, config.xAxis, config.yAxis || config.series); }, [result, config.transform, config.xAxis, config.yAxis, config.series]); const handleExportPNG = async () => { if (!chartRef.current) { toast.error("No chart to export"); return; } try { const fileName = `chart-${Date.now()}.png`; const bg = theme === "dark" ? "#1a1a1a" : "#ffffff"; await exportChartAsPNG(chartRef.current, fileName, bg); toast.success("Chart exported as PNG"); } catch (error) { toast.error( `Failed to export chart: ${error instanceof Error ? error.message : "Unknown error"}` ); } }; // ── Build uPlot options + data ────────────────────────────────────────────── const { uPlotOptions, uPlotData, seriesInfo } = useMemo(() => { if (!config.xAxis || (!config.yAxis && (!config.series || config.series.length === 0))) { return { uPlotOptions: null, uPlotData: null, seriesInfo: [] }; } const chartData = transformedData.map((row) => { const newRow: Record = { ...row }; if (config.yAxis) newRow[config.yAxis] = Number(row[config.yAxis]) || 0; if (config.series) { config.series.forEach((s) => { newRow[s.column] = Number(row[s.column]) || 0; }); } return newRow; }); const yKeys: string[] = config.series?.map((s) => s.column) ?? (config.yAxis ? [config.yAxis] : []); const colors = config.colors || DEFAULT_COLORS; const isDark = theme === "dark"; const xs = chartData.map((_, i) => i); const seriesData = yKeys.map((key) => chartData.map((row) => (Number(row[key]) || 0) as number) ); const isStacked = config.type === "stacked_bar" || config.type === "stacked_area"; const stackedData = isStacked ? seriesData.reduce((acc, curr) => { if (acc.length === 0) return [curr]; const prev = acc[acc.length - 1]; acc.push(curr.map((v, i) => v + prev[i])); return acc; }, []) : seriesData; const finalSeriesData = isStacked ? stackedData : seriesData; const isBarType = ["bar", "stacked_bar", "grouped_bar"].includes(config.type); const isAreaType = ["area", "stacked_area"].includes(config.type); const isLineType = config.type === "line"; const isScatter = config.type === "scatter"; const useSmooth = config.smooth && (isLineType || isAreaType); const xLabels = chartData.map((row) => shortenLabel(String(row[config.xAxis]))); const barsBuilder = isBarType ? uPlot.paths.bars!({ size: [0.6, 100], radius: 0.2, gap: yKeys.length > 1 && config.type === "grouped_bar" ? 2 : 0, }) : undefined; const splineBuilder = useSmooth ? uPlot.paths.spline!() : undefined; // Build series config const sInfo: { label: string; color: string }[] = []; const uSeries: uPlot.Series[] = [ { label: config.xAxis }, ...yKeys.map((key, i) => { const color = config.series?.[i]?.color || colors[i % colors.length]; const seriesLabel = config.series?.[i]?.label || key; sInfo.push({ label: seriesLabel, color }); const s: uPlot.Series = { label: seriesLabel, stroke: color, width: isBarType ? 0 : 2, fill: isBarType || isAreaType ? color + (isAreaType ? "66" : "cc") : undefined, points: { show: isScatter, size: isScatter ? 8 : 4 }, paths: isBarType ? barsBuilder : useSmooth ? splineBuilder : isScatter ? () => null : undefined, }; if (config.type === "grouped_bar" && yKeys.length > 1) { const groupBars = uPlot.paths.bars!({ size: [0.6 / yKeys.length, 100], radius: 0.2, align: i === 0 ? -1 : i === yKeys.length - 1 ? 1 : 0, }); s.paths = groupBars; } return s; }), ]; const needsRotation = chartData.length > 10; const maxLabelLen = Math.max(...xLabels.map((l) => l.length), 1); const xAxisSize = needsRotation ? Math.min(120, 40 + maxLabelLen * 5) : 60; const uAxes: uPlot.Axis[] = [ { stroke: isDark ? "#888" : "#666", grid: { stroke: isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)", width: 1 }, ticks: { stroke: isDark ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.15)", width: 1 }, values: (_u: uPlot, vals: number[]) => vals.map((v) => xLabels[v] ?? ""), gap: 8, size: xAxisSize, font: "12px system-ui, sans-serif", labelFont: "12px system-ui, sans-serif", rotate: needsRotation ? -45 : 0, }, { stroke: isDark ? "#888" : "#666", grid: config.showGrid ? { stroke: isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)", width: 1, dash: [4, 4], } : { show: false }, ticks: { stroke: isDark ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.15)", width: 1 }, values: (_u: uPlot, vals: number[]) => vals.map((v) => formatNumberWithSuffix(v)), gap: 8, size: 70, font: "12px system-ui, sans-serif", labelFont: "12px system-ui, sans-serif", }, ]; // Data labels hook for bar charts const hooks: uPlot.Plugin["hooks"] = {}; if (config.showValues && isBarType) { hooks.draw = [ (u: uPlot) => { const ctx = u.ctx; ctx.save(); ctx.font = "10px system-ui, sans-serif"; ctx.fillStyle = isDark ? "#ccc" : "#333"; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; for (let si = 1; si < u.series.length; si++) { if (!u.series[si].show) continue; for (let di = 0; di < u.data[si].length; di++) { const val = u.data[si][di]; if (val == null) continue; const cx = u.valToPos(u.data[0][di]!, "x", true); const cy = u.valToPos(val, "y", true); ctx.fillText(formatNumberWithSuffix(val as number), cx, cy - 4); } } ctx.restore(); }, ]; } const opts: Omit = { scales: { x: { time: false } }, series: uSeries, axes: uAxes, cursor: { drag: { x: false, y: false }, points: { size: 6, fill: (u: uPlot, i: number) => (typeof u.series[i].stroke === "function" ? (u.series[i].stroke as (self: uPlot, seriesIdx: number) => string)(u, i) : u.series[i].stroke) as string, stroke: "transparent", width: 0, }, }, legend: { show: false }, plugins: [tooltipPlugin(xLabels, { stacked: isStacked }), { hooks }], padding: [16, 16, 8, 0], }; const data: uPlot.AlignedData = isStacked ? ([xs, ...finalSeriesData.reverse()] as uPlot.AlignedData) : ([xs, ...finalSeriesData] as uPlot.AlignedData); return { uPlotOptions: opts, uPlotData: data, seriesInfo: sInfo }; }, [config, transformedData, theme]); const legendState = useMemo( () => seriesInfo.map((s, i) => ({ label: s.label, show: !hiddenSeries.has(i) })), [seriesInfo, hiddenSeries] ); const handleLegendToggle = useCallback((idx: number) => { setHiddenSeries((prev) => { const next = new Set(prev); if (next.has(idx)) { next.delete(idx); } else { next.add(idx); } // Toggle series visibility on the uPlot instance if (uPlotRef.current) { uPlotRef.current.setSeries(idx + 1, { show: !next.has(idx) }); } return next; }); }, []); // ── Render ────────────────────────────────────────────────────────────────── const renderChart = () => { if (config.type === "pie" || config.type === "donut") { const chartData = transformedData.map((row) => ({ ...row, [config.xAxis]: String(row[config.xAxis]), [config.yAxis || config.series?.[0]?.column || ""]: Number(row[config.yAxis || config.series?.[0]?.column || ""]) || 0, })); return ( ); } if (!uPlotOptions || !uPlotData) return null; return (
{ uPlotRef.current = u; }} />
s.color)} onToggle={handleLegendToggle} />
); }; if (!result || !result.data || result.data.length === 0) { return (
No data available for visualization
); } const isLineOrArea = ["line", "area", "stacked_area"].includes(config.type); return (
{/* Compact Toolbar */}
{/* Chart Type */} {/* X-Axis */} {/* Y-Axis */}
({ label: col, value: col, color: selectedYColumns.includes(col) ? DEFAULT_COLORS[selectedYColumns.indexOf(col) % DEFAULT_COLORS.length] : DEFAULT_COLORS[idx % DEFAULT_COLORS.length], }))} selected={selectedYColumns} onChange={handleYColumnsChange} placeholder="Values..." className="h-8 text-xs" />
{/* Settings Popover */}

Chart Settings

{/* Sort */}
{config.transform?.sortBy && ( )}
{/* Limit */}
{ const v = e.target.value ? parseInt(e.target.value, 10) : undefined; updateTransform({ limit: v }); }} />
{/* Aggregation */}
{/* Toggles */}
updateConfig({ showValues: v })} />
updateConfig({ showGrid: v })} />
{isLineOrArea && (
updateConfig({ smooth: v })} />
)}
{/* Export */} {/* Clear / Reset */}
{/* Chart Display */}
{renderChart()}
); }; export default React.memo(ChartVisualizationPro); ================================================ FILE: src/components/charts/UPlotChart.tsx ================================================ import { useRef, useEffect, useCallback } from "react"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; interface UPlotChartProps { options: Omit; data: uPlot.AlignedData; className?: string; onInit?: (chart: uPlot) => void; } export default function UPlotChart({ options, data, className, onInit }: UPlotChartProps) { const containerRef = useRef(null); const chartRef = useRef(null); const create = useCallback(() => { if (!containerRef.current) return; const el = containerRef.current; const { width, height } = el.getBoundingClientRect(); if (width === 0 || height === 0) return; chartRef.current?.destroy(); const chart = new uPlot({ ...options, width, height }, data, el); chartRef.current = chart; onInit?.(chart); }, [options, data, onInit]); useEffect(() => { create(); const el = containerRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const { width: w, height: h } = entries[0].contentRect; if (w > 0 && h > 0) { chartRef.current?.setSize({ width: w, height: h }); } }); ro.observe(el); return () => { ro.disconnect(); chartRef.current?.destroy(); chartRef.current = null; }; }, [create]); return
; } ================================================ FILE: src/components/charts/tooltipPlugin.ts ================================================ import uPlot from "uplot"; import { formatNumberWithSuffix } from "@/lib/chartUtils"; function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } export interface TooltipPluginOptions { stacked?: boolean; } export function tooltipPlugin(xLabels: string[], opts?: TooltipPluginOptions): uPlot.Plugin { let tooltip: HTMLDivElement; let over: HTMLElement; const isStacked = opts?.stacked ?? false; return { hooks: { init(u: uPlot) { over = u.over; tooltip = document.createElement("div"); tooltip.className = "uplot-tooltip"; tooltip.style.display = "none"; tooltip.style.position = "absolute"; tooltip.style.pointerEvents = "none"; tooltip.style.zIndex = "100"; over.appendChild(tooltip); over.addEventListener("mouseenter", () => { tooltip.style.display = "block"; }); over.addEventListener("mouseleave", () => { tooltip.style.display = "none"; }); }, setSize() { // bounds recalculated in setCursor via over.clientWidth/Height }, setCursor(u: uPlot) { const { idx, left, top } = u.cursor; if (idx == null || left == null || top == null) { tooltip.style.display = "none"; return; } const xLabel = escapeHtml(xLabels[idx] ?? String(u.data[0][idx])); let html = `
${xLabel}
`; let total = 0; const rows: { label: string; color: string; val: number | null }[] = []; for (let i = 1; i < u.series.length; i++) { const s = u.series[i]; if (!s.show) continue; const rawVal = u.data[i][idx]; const val = rawVal != null ? (rawVal as number) : null; const color = typeof s.stroke === "function" ? (s.stroke as (self: uPlot, seriesIdx: number) => string)(u, i) : s.stroke; const safeLabel = typeof s.label === "string" ? escapeHtml(s.label) : ""; const safeColor = typeof color === "string" ? escapeHtml(color) : ""; if (val != null) total += val; rows.push({ label: safeLabel, color: safeColor, val }); } for (const row of rows) { const formatted = row.val != null ? formatNumberWithSuffix(row.val) : "\u2014"; const pct = isStacked && row.val != null && total > 0 ? ` (${((row.val / total) * 100).toFixed(1)}%)` : ""; html += `
${row.label} ${formatted}${pct}
`; } if (isStacked && rows.length > 1) { html += `
Total ${formatNumberWithSuffix(total)}
`; } tooltip.innerHTML = html; const tooltipW = tooltip.offsetWidth; const tooltipH = tooltip.offsetHeight; const overW = over.clientWidth; const overH = over.clientHeight; const pad = 12; let posLeft = left + pad; let posTop = top - tooltipH / 2; if (posLeft + tooltipW > overW) posLeft = left - tooltipW - pad; if (posTop < 0) posTop = 0; if (posTop + tooltipH > overH) posTop = overH - tooltipH; tooltip.style.left = posLeft + "px"; tooltip.style.top = posTop + "px"; tooltip.style.display = "block"; }, }, }; } ================================================ FILE: src/components/cloud/CloudBrowser.tsx ================================================ /** * Cloud Browser Component * Displays cloud storage connections and allows browsing/importing files */ import { useState, useEffect } from "react"; import { useDuckStore } from "@/store"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Cloud, CloudOff, Plus, MoreVertical, Trash2, Link, Unlink, AlertCircle, Loader2, } from "lucide-react"; import { CloudConnectionModal } from "./CloudConnectionModal"; import type { CloudConnection } from "@/store"; // Provider icons/colors const PROVIDER_CONFIG = { s3: { label: "S3", color: "text-orange-500", bgColor: "bg-orange-500/10" }, gcs: { label: "GCS", color: "text-blue-500", bgColor: "bg-blue-500/10" }, azure: { label: "Azure", color: "text-cyan-500", bgColor: "bg-cyan-500/10" }, }; interface CloudConnectionItemProps { connection: CloudConnection; onConnect: () => void; onDisconnect: () => void; onRemove: () => void; } function CloudConnectionItem({ connection, onConnect, onDisconnect, onRemove, }: CloudConnectionItemProps) { const [isLoading, setIsLoading] = useState(false); const config = PROVIDER_CONFIG[connection.type]; const handleConnect = async () => { setIsLoading(true); try { await onConnect(); } finally { setIsLoading(false); } }; const handleDisconnect = async () => { setIsLoading(true); try { await onDisconnect(); } finally { setIsLoading(false); } }; return (
{connection.name} {config.label} {connection.isConnected && }
{connection.lastError && (
Error

{connection.lastError}

)}
{connection.isConnected ? ( Disconnect ) : ( Connect )} Remove
); } export default function CloudBrowser() { const [isModalOpen, setIsModalOpen] = useState(false); const cloudConnections = useDuckStore((s) => s.cloudConnections); const cloudSupportStatus = useDuckStore((s) => s.cloudSupportStatus); const isCloudStorageInitialized = useDuckStore((s) => s.isCloudStorageInitialized); const initCloudStorage = useDuckStore((s) => s.initCloudStorage); const connectCloudStorage = useDuckStore((s) => s.connectCloudStorage); const disconnectCloudStorage = useDuckStore((s) => s.disconnectCloudStorage); const removeCloudConnection = useDuckStore((s) => s.removeCloudConnection); // Initialize cloud storage on mount useEffect(() => { if (!isCloudStorageInitialized) { initCloudStorage(); } }, [isCloudStorageInitialized, initCloudStorage]); const handleConnect = async (id: string) => { await connectCloudStorage(id); }; const handleDisconnect = async (id: string) => { await disconnectCloudStorage(id); }; const handleRemove = async (id: string) => { await removeCloudConnection(id); }; // Show warning if cloud storage is not supported const showWarning = cloudSupportStatus && !cloudSupportStatus.httpfsAvailable; return (
{/* Header */}
Cloud Add Cloud Connection
{/* Warning Banner */} {showWarning && (

Cloud storage has limited support in browsers. Consider using HTTPS URLs instead.

)} {/* Connections List */} {cloudConnections.length === 0 ? (

No cloud connections

) : (
{cloudConnections.map((conn) => ( handleConnect(conn.id)} onDisconnect={() => handleDisconnect(conn.id)} onRemove={() => handleRemove(conn.id)} /> ))}
)} {/* Add Connection Modal */} setIsModalOpen(false)} />
); } ================================================ FILE: src/components/cloud/CloudConnectionModal.tsx ================================================ /** * Cloud Connection Modal * Form for adding/editing S3, GCS, and Azure cloud storage connections */ import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Loader2, Cloud, AlertTriangle } from "lucide-react"; import { useDuckStore } from "@/store"; import type { CloudConnection } from "@/store"; interface CloudConnectionModalProps { isOpen: boolean; onClose: () => void; existingConnection?: CloudConnection; } // Form validation schema const cloudConnectionSchema = z .object({ name: z.string().min(1, "Name is required"), type: z.enum(["s3", "gcs", "azure"]), // S3 fields bucket: z.string().optional(), region: z.string().optional(), accessKeyId: z.string().optional(), secretAccessKey: z.string().optional(), endpoint: z.string().optional(), // GCS fields hmacKeyId: z.string().optional(), hmacSecret: z.string().optional(), // Azure fields accountName: z.string().optional(), accountKey: z.string().optional(), containerName: z.string().optional(), }) .refine( (data) => { // Validate required fields based on type if (data.type === "s3") { return data.bucket && data.accessKeyId && data.secretAccessKey; } if (data.type === "gcs") { return data.bucket && data.hmacKeyId && data.hmacSecret; } if (data.type === "azure") { return data.containerName && data.accountName && data.accountKey; } return true; }, { message: "Please fill in all required fields for the selected provider", } ); type CloudConnectionFormData = z.infer; export function CloudConnectionModal({ isOpen, onClose, existingConnection, }: CloudConnectionModalProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [isTesting, setIsTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null); const addCloudConnection = useDuckStore((s) => s.addCloudConnection); const cloudSupportStatus = useDuckStore((s) => s.cloudSupportStatus); const form = useForm({ resolver: zodResolver(cloudConnectionSchema), defaultValues: { name: existingConnection?.name || "", type: existingConnection?.type || "s3", bucket: existingConnection?.bucket || "", region: existingConnection?.region || "us-east-1", accessKeyId: existingConnection?.accessKeyId || "", secretAccessKey: existingConnection?.secretAccessKey || "", endpoint: existingConnection?.endpoint || "", hmacKeyId: existingConnection?.hmacKeyId || "", hmacSecret: existingConnection?.hmacSecret || "", accountName: existingConnection?.accountName || "", accountKey: existingConnection?.accountKey || "", containerName: existingConnection?.containerName || "", }, }); const selectedType = form.watch("type"); const onSubmit = async (data: CloudConnectionFormData) => { setIsSubmitting(true); try { await addCloudConnection({ name: data.name, type: data.type, bucket: data.bucket, region: data.region, accessKeyId: data.accessKeyId, secretAccessKey: data.secretAccessKey, endpoint: data.endpoint, hmacKeyId: data.hmacKeyId, hmacSecret: data.hmacSecret, accountName: data.accountName, accountKey: data.accountKey, containerName: data.containerName, }); onClose(); } finally { setIsSubmitting(false); } }; const handleTest = async () => { setIsTesting(true); setTestResult(null); // For testing, we'd need to add the connection first, test it, then remove if failed // For now, show a message about the support status setTimeout(() => { if (!cloudSupportStatus?.httpfsAvailable) { setTestResult({ success: false, error: "Cloud storage (httpfs) is not available in this browser. Direct S3/GCS/Azure access may not work due to CORS restrictions.", }); } else if (!cloudSupportStatus?.secretsSupported) { setTestResult({ success: false, error: "DuckDB secrets are not supported. Cloud storage authentication may not work.", }); } else { setTestResult({ success: true, }); } setIsTesting(false); }, 500); }; return ( {existingConnection ? "Edit" : "Add"} Cloud Connection Connect to cloud storage (S3, Google Cloud Storage, or Azure Blob Storage) {/* Support Status Warning */} {cloudSupportStatus && !cloudSupportStatus.httpfsAvailable && ( Cloud storage support is limited in this browser. The httpfs extension is not available. You may need to use HTTPS URLs directly instead of s3:// or gcs:// protocols. )}
{/* Connection Name */} ( Connection Name )} /> {/* Provider Type */} ( Provider S3-compatible includes MinIO, Cloudflare R2, DigitalOcean Spaces )} /> {/* S3 Fields */} {selectedType === "s3" && ( <> ( Bucket Name * )} /> ( Region )} /> ( Access Key ID * )} /> ( Secret Access Key * )} /> ( Custom Endpoint (Optional) For S3-compatible services like MinIO or R2 )} /> )} {/* GCS Fields */} {selectedType === "gcs" && ( <> ( Bucket Name * )} /> ( HMAC Key ID * Generate HMAC keys in GCP Console under Cloud Storage Settings )} /> ( HMAC Secret * )} /> )} {/* Azure Fields */} {selectedType === "azure" && ( <> ( Container Name * )} /> ( Storage Account Name * )} /> ( Account Key * )} /> )} {/* Test Result */} {testResult && ( {testResult.success ? "Cloud storage support is available!" : testResult.error} )}
); } ================================================ FILE: src/components/command-palette/CommandPalette.tsx ================================================ import { useEffect, useState, useMemo } from "react"; import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from "@/components/ui/command"; import { useDuckStore } from "@/store"; import { useTheme } from "@/components/theme/theme-provider"; import { Terminal, Home, Cable, Brain, Settings, Sun, Moon, Database, Table, Bookmark, } from "lucide-react"; import type { EditorTabType } from "@/store"; import { getSavedQueries, type SavedQuery, } from "@/services/persistence/repositories/savedQueryRepository"; export default function CommandPalette() { const [open, setOpen] = useState(false); const { theme, setTheme } = useTheme(); const tabs = useDuckStore((s) => s.tabs); const activeTabId = useDuckStore((s) => s.activeTabId); const createTab = useDuckStore((s) => s.createTab); const setActiveTab = useDuckStore((s) => s.setActiveTab); const toggleBrainPanel = useDuckStore((s) => s.toggleBrainPanel); const databases = useDuckStore((s) => s.databases); const connectionList = useDuckStore((s) => s.connectionList); const currentConnection = useDuckStore((s) => s.currentConnection); const setCurrentConnection = useDuckStore((s) => s.setCurrentConnection); const currentProfileId = useDuckStore((s) => s.currentProfileId); const savedQueriesVersion = useDuckStore((s) => s.savedQueriesVersion); const [savedQueries, setSavedQueries] = useState([]); // Load saved queries when palette opens useEffect(() => { if (open && currentProfileId) { getSavedQueries(currentProfileId) .then(setSavedQueries) .catch(() => {}); } }, [open, currentProfileId, savedQueriesVersion]); // Keyboard shortcut useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((o) => !o); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, []); const openOrFocusTab = (type: EditorTabType, title: string) => { const existing = tabs.find((t) => t.type === type); if (existing) { setActiveTab(existing.id); } else { createTab(type, "", title); } setOpen(false); }; const openTabs = useMemo(() => tabs.filter((t) => t.id !== activeTabId), [tabs, activeTabId]); const tableEntries = useMemo(() => { const entries: { database: string; table: string }[] = []; for (const db of databases) { for (const table of db.tables) { entries.push({ database: db.name, table: table.name }); } } return entries; }, [databases]); return ( No results found. {/* Quick Actions */} { createTab("sql", ""); setOpen(false); }} > New SQL Tab openOrFocusTab("home", "Home")}> Home openOrFocusTab("connections", "Connections")}> Connections openOrFocusTab("brain", "Duck Brain")}> Duck Brain openOrFocusTab("settings", "Settings")}> Settings { toggleBrainPanel(); setOpen(false); }} > Toggle AI Panel { setTheme(theme === "dark" ? "light" : "dark"); setOpen(false); }} > {theme === "dark" ? ( ) : ( )} {theme === "dark" ? "Light Theme" : "Dark Theme"} {/* Open Tabs */} {openTabs.length > 0 && ( <> {openTabs.map((tab) => ( { setActiveTab(tab.id); setOpen(false); }} > {tab.title || tab.type} {tab.type} ))} )} {/* Connections */} {connectionList.connections.length > 1 && ( <> {connectionList.connections .filter((c) => c.id !== currentConnection?.id) .map((conn) => ( { await setCurrentConnection(conn.id); setOpen(false); }} > Switch to {conn.name} {conn.scope} ))} )} {/* Saved Queries */} {savedQueries.length > 0 && ( <> {savedQueries.map((query) => ( { createTab("sql", query.sql_text, query.name); setOpen(false); }} > {query.name} ))} )} {/* Tables */} {tableEntries.length > 0 && ( <> {tableEntries.map(({ database, table }) => ( { const query = `SELECT * FROM "${database}"."${table}" LIMIT 100`; createTab("sql", query, table); setOpen(false); }} > {database}.{table} SELECT ))} )} ); } ================================================ FILE: src/components/common/FloatingActionButton.tsx ================================================ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { LucideIcon } from "lucide-react"; interface FloatingActionButtonProps { onClick: () => void; icon: LucideIcon; label: string; disabled?: boolean; className?: string; variant?: "default" | "outline" | "secondary"; } export const FloatingActionButton: React.FC = ({ onClick, icon: Icon, label, disabled = false, className, variant = "default", }) => { return ( ); }; export default FloatingActionButton; ================================================ FILE: src/components/common/ImportOptionsPopover.tsx ================================================ import React, { useState } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Import, Table, Link2 } from "lucide-react"; import { cn } from "@/lib/utils"; export interface ImportOptions { tableName: string; importMode: "table" | "view"; } interface ImportOptionsPopoverProps { fileName: string; onImport: (options: ImportOptions) => void; children: React.ReactNode; disabled?: boolean; } /** * Generate a valid table name from a filename */ function generateTableName(fileName: string): string { return fileName .replace(/\.[^.]+$/, "") // Remove extension .replace(/[^a-zA-Z0-9_]/g, "_") // Replace special chars with underscore .replace(/^[0-9]/, "_$&") // Ensure doesn't start with number .replace(/_+/g, "_") // Collapse multiple underscores .replace(/^_|_$/g, ""); // Trim leading/trailing underscores } const ImportOptionsPopover: React.FC = ({ fileName, onImport, children, disabled = false, }) => { const [open, setOpen] = useState(false); const [tableName, setTableName] = useState(generateTableName(fileName)); const [importMode, setImportMode] = useState<"table" | "view">("table"); const handleImport = () => { if (!tableName.trim()) return; onImport({ tableName: tableName.trim(), importMode }); setOpen(false); }; const handleOpenChange = (isOpen: boolean) => { if (isOpen) { // Reset to defaults when opening setTableName(generateTableName(fileName)); setImportMode("table"); } setOpen(isOpen); }; return ( {children}

Import Options

{fileName}

setTableName(e.target.value)} placeholder="table_name" className="h-8 text-sm" />
Table

{importMode === "table" ? "Copies data into DuckDB (faster queries)" : "Links to file (fresh data, less memory)"}

); }; export default ImportOptionsPopover; ================================================ FILE: src/components/connection/ConnectionsModal.tsx ================================================ // ConnectionManager.tsx import React from "react"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetFooter, } from "@/components/ui/sheet"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import * as z from "zod"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { useDuckStore } from "@/store"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Info } from "lucide-react"; const scopeEnum = z.enum(["External", "OPFS"]); const nameSchema = z .string() .min(2, { message: "Connection name must be at least 2 characters.", }) .max(30, { message: "Connection name must not exceed 30 characters.", }); const opfsSchema = z.object({ name: nameSchema, scope: z.literal(scopeEnum.enum.OPFS), path: z.string().min(1, { message: "Path is required.", }), }); const externalSchema = z.object({ name: nameSchema, scope: z.literal(scopeEnum.enum.External), host: z.string().url({ message: "Host must be a valid URL.", }), port: z .string() .refine((val) => !isNaN(parseInt(val, 10)) || val === "", { message: "Port must be a number.", }) .optional(), database: z.string().optional(), user: z.string().optional(), password: z.string().optional(), authMode: z.enum(["none", "password", "api_key"]).optional(), apiKey: z.string().optional(), }); const connectionSchema = z.discriminatedUnion("scope", [opfsSchema, externalSchema]); type ConnectionFormValues = z.infer; interface ConnectionManagerProps { open: boolean; onOpenChange: (open: boolean) => void; onSubmit: (values: ConnectionFormValues) => Promise; initialValues?: ConnectionFormValues; isEditMode?: boolean; } const ConnectionManager: React.FC = ({ open, onOpenChange, onSubmit, initialValues, isEditMode = false, }) => { const form = useForm({ resolver: zodResolver(connectionSchema), defaultValues: initialValues || { name: "Local DuckDB", scope: "External" as const, host: "http://localhost:9999", port: "", database: "", user: "", password: "", authMode: "none" as const, apiKey: "", }, mode: "onChange", }); const currentScope = form.watch("scope"); const isLoadingExternalConnection = useDuckStore((s) => s.isLoadingExternalConnection); const handleSubmit = async (values: ConnectionFormValues) => { await onSubmit(values); form.reset(); onOpenChange(false); }; return ( {isEditMode ? "Edit Connection" : "Add New Connection"} {isEditMode ? "Modify existing connection details." : "Connect to a DuckDB instance or browser storage."}
( Connection Name )} /> ( Connection Type )} /> {/* External Connection Fields */} {currentScope === "External" && ( <>

Start HTTP server in DuckDB:

                        {`INSTALL httpserver FROM community;
LOAD httpserver;
SELECT httpserve_start('0.0.0.0', 9999, '');`}
                      
( Host URL Full URL including protocol (http/https) )} /> ( Database (optional) )} /> ( Authentication )} /> {form.watch("authMode") === "password" && (
( Username )} /> ( Password )} />
)} {form.watch("authMode") === "api_key" && ( ( API Key )} /> )} )} {/* OPFS Fields */} {currentScope === "OPFS" && ( <> Data persists in your browser across sessions. ( Database File Filename for your database (e.g., data.db) )} /> )}
); }; export default ConnectionManager; ================================================ FILE: src/components/duck-brain/DuckBrainCodeBlock.tsx ================================================ import React from "react"; import { Copy, Play, FileInput, Check, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import type { QueryResultArtifact } from "@/store"; import ResultsArtifact from "./ResultsArtifact"; interface DuckBrainCodeBlockProps { sql: string; messageId: string; queryResult?: QueryResultArtifact; onExecute?: (messageId: string, sql: string) => void; onInsert?: (sql: string) => void; className?: string; } const DuckBrainCodeBlock: React.FC = ({ sql, messageId, queryResult, onExecute, onInsert, className, }) => { const [copied, setCopied] = React.useState(false); const isRunning = queryResult?.status === "running"; const handleCopy = async () => { try { await navigator.clipboard.writeText(sql); setCopied(true); toast.success("SQL copied to clipboard"); setTimeout(() => setCopied(false), 2000); } catch { toast.error("Failed to copy"); } }; const handleExecute = () => { if (onExecute && !isRunning) { onExecute(messageId, sql); } }; const handleRetry = () => { handleExecute(); }; return (
{/* SQL Code Block */}
{/* SQL Code */}
          {sql}
        
{/* Actions */}
{onInsert && ( )} {onExecute && ( )}
{/* Results Artifact */} {queryResult && queryResult.status !== "pending" && ( )}
); }; export default DuckBrainCodeBlock; ================================================ FILE: src/components/duck-brain/DuckBrainInput.tsx ================================================ import React, { useState, useRef, useEffect, useCallback } from "react"; import { Send, Square, Loader2, Table2, Columns } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import type { DatabaseInfo } from "@/store"; import SchemaAutocomplete, { buildSchemaSuggestions, type SchemaSuggestion, } from "./SchemaAutocomplete"; // Render text with styled @ mentions const renderWithMentions = (text: string) => { const mentionRegex = /@([\w.]+)/g; const parts: React.ReactNode[] = []; let lastIndex = 0; let match; while ((match = mentionRegex.exec(text)) !== null) { // Add text before the mention if (match.index > lastIndex) { parts.push( {text.slice(lastIndex, match.index)} ); } // Add the mention as a styled pill const mention = match[1]; const isColumn = mention.includes("."); parts.push( {isColumn ? : } {mention} ); lastIndex = match.index + match[0].length; } // Add remaining text if (lastIndex < text.length) { parts.push( {text.slice(lastIndex)} ); } return parts.length > 0 ? parts : text; }; interface DuckBrainInputProps { onSend: (message: string) => void; onAbort?: () => void; isGenerating: boolean; disabled?: boolean; placeholder?: string; databases?: DatabaseInfo[]; className?: string; } const DuckBrainInput: React.FC = ({ onSend, onAbort, isGenerating, disabled = false, placeholder = "Ask Duck Brain to write SQL... (@ for tables)", databases = [], className, }) => { const [input, setInput] = useState(""); const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false); const [suggestions, setSuggestions] = useState([]); const [activeIndex, setActiveIndex] = useState(0); const [mentionStart, setMentionStart] = useState(null); const textareaRef = useRef(null); const containerRef = useRef(null); // Auto-resize textarea useEffect(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`; } }, [input]); // Detect @ mentions and filter suggestions const handleInputChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; const cursorPos = e.target.selectionStart || 0; setInput(value); // Find the @ before cursor const textBeforeCursor = value.slice(0, cursorPos); const lastAtIndex = textBeforeCursor.lastIndexOf("@"); if (lastAtIndex !== -1) { // Check if there's a space between @ and cursor (mention ended) const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1); const hasSpace = /\s/.test(textAfterAt); if (!hasSpace) { // Active mention - show suggestions setMentionStart(lastAtIndex); const filter = textAfterAt; const filtered = buildSchemaSuggestions(databases, filter); setSuggestions(filtered); setIsAutocompleteOpen(filtered.length > 0); setActiveIndex(0); return; } } // No active mention setIsAutocompleteOpen(false); setMentionStart(null); }, [databases] ); // Insert selected suggestion const insertSuggestion = useCallback( (suggestion: SchemaSuggestion) => { if (mentionStart === null) return; const before = input.slice(0, mentionStart); const cursorPos = textareaRef.current?.selectionStart || input.length; const after = input.slice(cursorPos); // Insert with @ prefix so it renders as a pill const insertText = `@${suggestion.fullPath}`; const newInput = `${before}${insertText} ${after}`; setInput(newInput); setIsAutocompleteOpen(false); setMentionStart(null); // Focus and set cursor position setTimeout(() => { const newCursorPos = before.length + insertText.length + 1; textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); }, 0); }, [input, mentionStart] ); const handleKeyDown = (e: React.KeyboardEvent) => { // Handle autocomplete navigation if (isAutocompleteOpen && suggestions.length > 0) { switch (e.key) { case "ArrowDown": e.preventDefault(); setActiveIndex((prev) => (prev + 1) % suggestions.length); return; case "ArrowUp": e.preventDefault(); setActiveIndex((prev) => (prev === 0 ? suggestions.length - 1 : prev - 1)); return; case "Tab": case "Enter": if (suggestions[activeIndex]) { e.preventDefault(); insertSuggestion(suggestions[activeIndex]); return; } break; case "Escape": e.preventDefault(); setIsAutocompleteOpen(false); return; } } // Regular Enter to send (if not in autocomplete) if (e.key === "Enter" && !e.shiftKey && !isAutocompleteOpen) { e.preventDefault(); handleSubmit(); } }; const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); if (!input.trim() || isGenerating || disabled) return; onSend(input.trim()); setInput(""); setIsAutocompleteOpen(false); }; // Close autocomplete when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsAutocompleteOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Check if input has any @ mentions to show overlay const hasMentions = /@[\w.]+/.test(input); return (
{/* Visual overlay for styled mentions */} {hasMentions && ( )}