diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 15d20ff..5c76514 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -2,37 +2,37 @@ name: CI on: push: - branches: [main, master] + branches: [main] pull_request: - branches: [main, master] - -concurrency: - group: ${{ gitea.workflow }}-${{ gitea.ref }} - cancel-in-progress: true + branches: [main] jobs: - lint-and-test: + lint-and-build: runs-on: ubuntu-latest + container: + image: ubuntu:22.04 + env: + DEBIAN_FRONTEND: noninteractive steps: - uses: actions/checkout@v4 - - name: Install Linux dependencies + - name: Install system dependencies run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev + apt-get update + apt-get install -y \ + build-essential curl wget pkg-config \ + libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \ + libssl-dev git ca-certificates - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt + - name: Install Node.js 22 + run: | + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y nodejs - - uses: Swatinem/rust-cache@v2 - with: - workspaces: src-tauri - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm + - name: Install Rust toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --component clippy,rustfmt + echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Install frontend dependencies run: npm ci @@ -41,51 +41,21 @@ jobs: run: npm run lint - name: Rust fmt check - run: cd src-tauri && cargo fmt --check + run: | + . "$HOME/.cargo/env" + cd src-tauri && cargo fmt --check - name: Rust clippy - run: cd src-tauri && cargo clippy -- -D warnings + run: | + . "$HOME/.cargo/env" + cd src-tauri && cargo clippy -- -D warnings - name: Rust tests - run: cd src-tauri && cargo test - - - name: Frontend tests - run: npm test - - build: - needs: lint-and-test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Linux dependencies run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: src-tauri - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Install frontend dependencies - run: npm ci + . "$HOME/.cargo/env" + cd src-tauri && cargo test - name: Build Tauri app - run: npm run tauri build - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: tusk-linux-x64 - path: | - src-tauri/target/release/bundle/deb/*.deb - src-tauri/target/release/bundle/rpm/*.rpm - src-tauri/target/release/bundle/appimage/*.AppImage - if-no-files-found: ignore + run: | + . "$HOME/.cargo/env" + npm run tauri build diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6b25c56..5cd3d33 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -8,30 +8,38 @@ on: jobs: release: runs-on: ubuntu-latest + container: + image: ubuntu:22.04 + env: + DEBIAN_FRONTEND: noninteractive steps: - uses: actions/checkout@v4 - - name: Install Linux dependencies + - name: Install system dependencies run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev + apt-get update + apt-get install -y \ + build-essential curl wget pkg-config jq file \ + libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \ + libssl-dev git ca-certificates - - uses: dtolnay/rust-toolchain@stable + - name: Install Node.js 22 + run: | + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y nodejs - - uses: Swatinem/rust-cache@v2 - with: - workspaces: src-tauri - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm + - name: Install Rust toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Install frontend dependencies run: npm ci - name: Build Tauri app - run: npm run tauri build + run: | + . "$HOME/.cargo/env" + npm run tauri build - name: Create release and upload assets env: diff --git a/Makefile b/Makefile index 3fb782c..dd66a7e 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ TARGET_DIR := $(if $(TARGET),src-tauri/target/$(TARGET)/release,src-tauri/targe # ────────────────────────────────────────────── .PHONY: dev -dev: node_modules ## Run app in dev mode (Vite HMR + Rust backend) +dev: ## Run app in dev mode (Vite HMR + Rust backend) npm run tauri dev .PHONY: dev-frontend @@ -98,12 +98,14 @@ export DESKTOP_ENTRY .PHONY: install install: build ## Build release and install to system (PREFIX=/usr/local) - install -Dm755 $(TARGET_DIR)/$(APP_NAME) $(DESTDIR)$(BINDIR)/$(APP_NAME) + @mkdir -p $(DESTDIR)$(BINDIR) + install -m755 $(TARGET_DIR)/$(APP_NAME) $(DESTDIR)$(BINDIR)/$(APP_NAME) @mkdir -p $(DESTDIR)$(DATADIR)/applications @echo "$$DESKTOP_ENTRY" > $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop @for size in 32x32 128x128; do \ if [ -f src-tauri/icons/$$size.png ]; then \ - install -Dm644 src-tauri/icons/$$size.png \ + mkdir -p $(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps; \ + install -m644 src-tauri/icons/$$size.png \ $(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps/$(APP_NAME).png; \ fi; \ done diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..483d5e2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'src-tauri']), { files: ['**/*.{ts,tsx}'], extends: [ @@ -19,5 +19,8 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, }, ]) diff --git a/index.html b/index.html index d901d62..412e2f8 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,12 @@ - + Tusk + + +
diff --git a/package-lock.json b/package-lock.json index a420f16..f46c36a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,9 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-shell": "^2.3.5", - "@types/dagre": "^0.7.53", + "@types/dagre": "^0.7.54", "@uiw/react-codemirror": "^4.25.4", - "@xyflow/react": "^12.10.0", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -37,6 +37,8 @@ "@eslint/js": "^9.39.1", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/cli": "^2.10.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -45,14 +47,30 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^28.1.0", "shadcn": "^3.8.4", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@antfu/ni": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz", @@ -75,6 +93,64 @@ "nup": "bin/nup.mjs" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -106,7 +182,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -550,6 +625,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -650,7 +738,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz", "integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -658,6 +745,138 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", @@ -1438,6 +1657,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", @@ -1821,7 +2058,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3796,6 +4032,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -4399,6 +4642,82 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -4427,6 +4746,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4472,6 +4799,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -4522,9 +4860,16 @@ } }, "node_modules/@types/dagre": { - "version": "0.7.53", - "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz", - "integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==", + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -4547,7 +4892,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4558,7 +4902,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4569,7 +4912,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4633,7 +4975,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -4932,13 +5273,124 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@xyflow/react": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", - "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.74", + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -4976,9 +5428,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.74", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", - "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -5012,7 +5464,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5156,6 +5607,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -5186,6 +5657,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -5255,7 +5736,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5358,6 +5838,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5711,6 +6201,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5724,6 +6235,32 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5788,7 +6325,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5857,6 +6393,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5875,6 +6425,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", @@ -5960,6 +6517,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5992,6 +6559,14 @@ "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "17.2.4", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz", @@ -6083,6 +6658,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -6123,6 +6711,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6214,7 +6809,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6397,6 +6991,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6467,13 +7071,22 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7082,11 +7695,23 @@ "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -7108,6 +7733,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -7186,6 +7825,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7357,6 +8006,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -7466,6 +8122,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7933,6 +8630,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7953,6 +8661,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -8070,6 +8785,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8359,6 +9084,17 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8565,6 +9301,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8609,6 +9358,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8705,6 +9461,47 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -8933,7 +9730,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8943,7 +9739,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8951,6 +9746,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -9057,6 +9860,20 @@ "node": ">= 4" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9248,6 +10065,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9474,6 +10304,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9537,6 +10374,13 @@ "sql-formatter": "bin/sql-formatter-cli.cjs" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9547,6 +10391,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -9642,6 +10493,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -9674,6 +10538,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -9725,6 +10596,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -9752,6 +10630,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.23", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", @@ -9808,6 +10696,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -9913,7 +10814,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9946,6 +10846,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -10122,7 +11032,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10192,12 +11101,103 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -10208,6 +11208,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10224,6 +11259,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -10318,6 +11370,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -10454,7 +11523,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 43ce77f..3e2eb0a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", "tauri": "tauri" }, "dependencies": { @@ -18,9 +20,9 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-shell": "^2.3.5", - "@types/dagre": "^0.7.53", + "@types/dagre": "^0.7.54", "@uiw/react-codemirror": "^4.25.4", - "@xyflow/react": "^12.10.0", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -40,6 +42,8 @@ "@eslint/js": "^9.39.1", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/cli": "^2.10.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -48,11 +52,13 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^28.1.0", "shadcn": "^3.8.4", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1b6d48c..4b52816 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,3 +35,6 @@ rmcp = { version = "0.15", features = ["server", "macros", "transport-streamable axum = "0.8" schemars = "1" tokio-util = "0.7" + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "macros"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7..d860e1e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 99eca49..ad5291c 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -2,10 +2,10 @@ use crate::commands::data::bind_json_value; use crate::commands::queries::pg_value_to_json; use crate::error::{TuskError, TuskResult}; use crate::models::ai::{ - AiProvider, AiSettings, GenerateDataParams, GeneratedDataPreview, GeneratedTableData, - IndexAdvisorReport, IndexRecommendation, IndexStats, - OllamaChatMessage, OllamaChatRequest, OllamaChatResponse, OllamaModel, OllamaTagsResponse, - SlowQuery, TableStats, ValidationRule, ValidationStatus, DataGenProgress, + AiProvider, AiSettings, DataGenProgress, GenerateDataParams, GeneratedDataPreview, + GeneratedTableData, IndexAdvisorReport, IndexRecommendation, IndexStats, OllamaChatMessage, + OllamaChatRequest, OllamaChatResponse, OllamaModel, OllamaTagsResponse, SlowQuery, TableStats, + ValidationRule, ValidationStatus, }; use crate::state::AppState; use crate::utils::{escape_ident, topological_sort_tables}; @@ -20,12 +20,16 @@ use tauri::{AppHandle, Emitter, Manager, State}; const MAX_RETRIES: u32 = 2; const RETRY_DELAY_MS: u64 = 1000; -fn http_client() -> reqwest::Client { - reqwest::Client::builder() - .connect_timeout(Duration::from_secs(5)) - .timeout(Duration::from_secs(300)) - .build() - .unwrap_or_default() +fn http_client() -> &'static reqwest::Client { + use std::sync::LazyLock; + static CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(300)) + .build() + .unwrap_or_default() + }); + &CLIENT } fn get_ai_settings_path(app: &AppHandle) -> TuskResult { @@ -65,11 +69,10 @@ pub async fn save_ai_settings( #[tauri::command] pub async fn list_ollama_models(ollama_url: String) -> TuskResult> { let url = format!("{}/api/tags", ollama_url.trim_end_matches('/')); - let resp = http_client() - .get(&url) - .send() - .await - .map_err(|e| TuskError::Ai(format!("Cannot connect to Ollama at {}: {}", ollama_url, e)))?; + let resp = + http_client().get(&url).send().await.map_err(|e| { + TuskError::Ai(format!("Cannot connect to Ollama at {}: {}", ollama_url, e)) + })?; if !resp.status().is_success() { let status = resp.status(); @@ -119,7 +122,10 @@ where } Err(last_error.unwrap_or_else(|| { - TuskError::Ai(format!("{} failed after {} attempts", operation, MAX_RETRIES)) + TuskError::Ai(format!( + "{} failed after {} attempts", + operation, MAX_RETRIES + )) })) } @@ -164,10 +170,7 @@ async fn call_ollama_chat( } let model = settings.model.clone(); - let url = format!( - "{}/api/chat", - settings.ollama_url.trim_end_matches('/') - ); + let url = format!("{}/api/chat", settings.ollama_url.trim_end_matches('/')); let request = OllamaChatRequest { model: model.clone(), @@ -194,10 +197,7 @@ async fn call_ollama_chat( .send() .await .map_err(|e| { - TuskError::Ai(format!( - "Cannot connect to Ollama at {}: {}", - url, e - )) + TuskError::Ai(format!("Cannot connect to Ollama at {}: {}", url, e)) })?; if !resp.status().is_success() { @@ -379,10 +379,7 @@ pub async fn fix_sql_error( schema_text ); - let user_content = format!( - "SQL query:\n{}\n\nError message:\n{}", - sql, error_message - ); + let user_content = format!("SQL query:\n{}\n\nError message:\n{}", sql, error_message); let raw = call_ollama_chat(&app, &state, system_prompt, user_content).await?; Ok(clean_sql_response(&raw)) @@ -405,9 +402,15 @@ pub(crate) async fn build_schema_context( // Run all metadata queries in parallel for speed let ( - version_res, col_res, fk_res, enum_res, - tbl_comment_res, col_comment_res, unique_res, - varchar_res, jsonb_res, + version_res, + col_res, + fk_res, + enum_res, + tbl_comment_res, + col_comment_res, + unique_res, + varchar_res, + jsonb_res, ) = tokio::join!( sqlx::query_scalar::<_, String>("SELECT version()").fetch_one(&pool), fetch_columns(&pool), @@ -586,10 +589,9 @@ pub(crate) async fn build_schema_context( // Unique constraints for this table let schema_table: Vec<&str> = full_name.splitn(2, '.').collect(); if schema_table.len() == 2 { - if let Some(uqs) = unique_map.get(&( - schema_table[0].to_string(), - schema_table[1].to_string(), - )) { + if let Some(uqs) = + unique_map.get(&(schema_table[0].to_string(), schema_table[1].to_string())) + { for uq_cols in uqs { output.push(format!(" UNIQUE({})", uq_cols)); } @@ -609,7 +611,9 @@ pub(crate) async fn build_schema_context( let result = output.join("\n"); // Cache the result - state.set_schema_cache(connection_id.to_string(), result.clone()).await; + state + .set_schema_cache(connection_id.to_string(), result.clone()) + .await; Ok(result) } @@ -931,10 +935,7 @@ async fn fetch_jsonb_keys( let query = parts.join(" UNION ALL "); - let rows = match sqlx::query(&query) - .fetch_all(pool) - .await - { + let rows = match sqlx::query(&query).fetch_all(pool).await { Ok(r) => r, Err(e) => { log::warn!("Failed to fetch JSONB keys: {}", e); @@ -1033,6 +1034,26 @@ fn simplify_default(raw: &str) -> String { s.to_string() } +fn validate_select_statement(sql: &str) -> TuskResult<()> { + let sql_upper = sql.trim().to_uppercase(); + if !sql_upper.starts_with("SELECT") { + return Err(TuskError::Custom( + "Validation query must be a SELECT statement".to_string(), + )); + } + Ok(()) +} + +fn validate_index_ddl(ddl: &str) -> TuskResult<()> { + let ddl_upper = ddl.trim().to_uppercase(); + if !ddl_upper.starts_with("CREATE INDEX") && !ddl_upper.starts_with("DROP INDEX") { + return Err(TuskError::Custom( + "Only CREATE INDEX and DROP INDEX statements are allowed".to_string(), + )); + } + Ok(()) +} + fn clean_sql_response(raw: &str) -> String { let trimmed = raw.trim(); // Remove markdown code fences @@ -1098,18 +1119,13 @@ pub async fn run_validation_rule( sql: String, sample_limit: Option, ) -> TuskResult { - let sql_upper = sql.trim().to_uppercase(); - if !sql_upper.starts_with("SELECT") { - return Err(TuskError::Custom( - "Validation query must be a SELECT statement".to_string(), - )); - } + validate_select_statement(&sql)?; let pool = state.get_pool(&connection_id).await?; let limit = sample_limit.unwrap_or(10); let _start = Instant::now(); - let mut tx = (&pool).begin().await.map_err(TuskError::Database)?; + let mut tx = pool.begin().await.map_err(TuskError::Database)?; sqlx::query("SET TRANSACTION READ ONLY") .execute(&mut *tx) .await @@ -1199,7 +1215,13 @@ pub async fn suggest_validation_rules( schema_text ); - let raw = call_ollama_chat(&app, &state, system_prompt, "Suggest validation rules".to_string()).await?; + let raw = call_ollama_chat( + &app, + &state, + system_prompt, + "Suggest validation rules".to_string(), + ) + .await?; let cleaned = raw.trim(); let json_start = cleaned.find('[').unwrap_or(0); @@ -1207,7 +1229,10 @@ pub async fn suggest_validation_rules( let json_str = &cleaned[json_start..json_end]; let rules: Vec = serde_json::from_str(json_str).map_err(|e| { - TuskError::Ai(format!("Failed to parse AI response as JSON array: {}. Response: {}", e, cleaned)) + TuskError::Ai(format!( + "Failed to parse AI response as JSON array: {}. Response: {}", + e, cleaned + )) })?; Ok(rules) @@ -1226,13 +1251,16 @@ pub async fn generate_test_data_preview( ) -> TuskResult { let pool = state.get_pool(¶ms.connection_id).await?; - let _ = app.emit("datagen-progress", DataGenProgress { - gen_id: gen_id.clone(), - stage: "schema".to_string(), - percent: 10, - message: "Building schema context...".to_string(), - detail: None, - }); + let _ = app.emit( + "datagen-progress", + DataGenProgress { + gen_id: gen_id.clone(), + stage: "schema".to_string(), + percent: 10, + message: "Building schema context...".to_string(), + detail: None, + }, + ); let schema_text = build_schema_context(&state, ¶ms.connection_id).await?; @@ -1255,7 +1283,14 @@ pub async fn generate_test_data_preview( let fk_edges: Vec<(String, String, String, String)> = fk_rows .iter() - .map(|fk| (fk.schema.clone(), fk.table.clone(), fk.ref_schema.clone(), fk.ref_table.clone())) + .map(|fk| { + ( + fk.schema.clone(), + fk.table.clone(), + fk.ref_schema.clone(), + fk.ref_table.clone(), + ) + }) .collect(); let sorted_tables = topological_sort_tables(&fk_edges, &target_tables); @@ -1266,13 +1301,16 @@ pub async fn generate_test_data_preview( let row_count = params.row_count.min(1000); - let _ = app.emit("datagen-progress", DataGenProgress { - gen_id: gen_id.clone(), - stage: "generating".to_string(), - percent: 30, - message: "AI is generating test data...".to_string(), - detail: None, - }); + let _ = app.emit( + "datagen-progress", + DataGenProgress { + gen_id: gen_id.clone(), + stage: "generating".to_string(), + percent: 30, + message: "AI is generating test data...".to_string(), + detail: None, + }, + ); let tables_desc: Vec = sorted_tables .iter() @@ -1329,13 +1367,16 @@ pub async fn generate_test_data_preview( ) .await?; - let _ = app.emit("datagen-progress", DataGenProgress { - gen_id: gen_id.clone(), - stage: "parsing".to_string(), - percent: 80, - message: "Parsing generated data...".to_string(), - detail: None, - }); + let _ = app.emit( + "datagen-progress", + DataGenProgress { + gen_id: gen_id.clone(), + stage: "parsing".to_string(), + percent: 80, + message: "Parsing generated data...".to_string(), + detail: None, + }, + ); // Parse JSON response let cleaned = raw.trim(); @@ -1343,9 +1384,13 @@ pub async fn generate_test_data_preview( let json_end = cleaned.rfind('}').map(|i| i + 1).unwrap_or(cleaned.len()); let json_str = &cleaned[json_start..json_end]; - let data_map: HashMap>> = - serde_json::from_str(json_str).map_err(|e| { - TuskError::Ai(format!("Failed to parse generated data: {}. Response: {}", e, &cleaned[..cleaned.len().min(500)])) + let data_map: HashMap>> = serde_json::from_str(json_str) + .map_err(|e| { + TuskError::Ai(format!( + "Failed to parse generated data: {}. Response: {}", + e, + &cleaned[..cleaned.len().min(500)] + )) })?; let mut tables = Vec::new(); @@ -1362,7 +1407,12 @@ pub async fn generate_test_data_preview( let rows: Vec> = rows_data .iter() - .map(|row_map| columns.iter().map(|col| row_map.get(col).cloned().unwrap_or(Value::Null)).collect()) + .map(|row_map| { + columns + .iter() + .map(|col| row_map.get(col).cloned().unwrap_or(Value::Null)) + .collect() + }) .collect(); let count = rows.len() as u32; @@ -1378,13 +1428,20 @@ pub async fn generate_test_data_preview( } } - let _ = app.emit("datagen-progress", DataGenProgress { - gen_id: gen_id.clone(), - stage: "done".to_string(), - percent: 100, - message: "Data generation complete".to_string(), - detail: Some(format!("{} rows across {} tables", total_rows, tables.len())), - }); + let _ = app.emit( + "datagen-progress", + DataGenProgress { + gen_id: gen_id.clone(), + stage: "done".to_string(), + percent: 100, + message: "Data generation complete".to_string(), + detail: Some(format!( + "{} rows across {} tables", + total_rows, + tables.len() + )), + }, + ); Ok(GeneratedDataPreview { tables, @@ -1404,7 +1461,7 @@ pub async fn insert_generated_data( } let pool = state.get_pool(&connection_id).await?; - let mut tx = (&pool).begin().await.map_err(TuskError::Database)?; + let mut tx = pool.begin().await.map_err(TuskError::Database)?; // Defer constraints for circular FKs sqlx::query("SET CONSTRAINTS ALL DEFERRED") @@ -1466,20 +1523,38 @@ pub async fn get_index_advisor_report( ) -> TuskResult { let pool = state.get_pool(&connection_id).await?; - // Fetch table stats - let table_stats_rows = sqlx::query( - "SELECT schemaname, relname, seq_scan, idx_scan, n_live_tup, \ - pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS table_size, \ - pg_size_pretty(pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS index_size \ - FROM pg_stat_user_tables \ - ORDER BY seq_scan DESC \ - LIMIT 50" - ) - .fetch_all(&pool) - .await - .map_err(TuskError::Database)?; + // Fetch table stats, index stats, and slow queries concurrently + let (table_stats_res, index_stats_res, slow_queries_res) = tokio::join!( + sqlx::query( + "SELECT schemaname, relname, seq_scan, idx_scan, n_live_tup, \ + pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS table_size, \ + pg_size_pretty(pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS index_size \ + FROM pg_stat_user_tables \ + ORDER BY seq_scan DESC \ + LIMIT 50" + ) + .fetch_all(&pool), + sqlx::query( + "SELECT schemaname, relname, indexrelname, idx_scan, \ + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, \ + pg_get_indexdef(indexrelid) AS definition \ + FROM pg_stat_user_indexes \ + ORDER BY idx_scan ASC \ + LIMIT 50", + ) + .fetch_all(&pool), + sqlx::query( + "SELECT query, calls, total_exec_time, mean_exec_time, rows \ + FROM pg_stat_statements \ + WHERE calls > 0 \ + ORDER BY mean_exec_time DESC \ + LIMIT 20", + ) + .fetch_all(&pool), + ); - let table_stats: Vec = table_stats_rows + let table_stats: Vec = table_stats_res + .map_err(TuskError::Database)? .iter() .map(|r| TableStats { schema: r.get(0), @@ -1492,20 +1567,8 @@ pub async fn get_index_advisor_report( }) .collect(); - // Fetch index stats - let index_stats_rows = sqlx::query( - "SELECT schemaname, relname, indexrelname, idx_scan, \ - pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, \ - pg_get_indexdef(indexrelid) AS definition \ - FROM pg_stat_user_indexes \ - ORDER BY idx_scan ASC \ - LIMIT 50" - ) - .fetch_all(&pool) - .await - .map_err(TuskError::Database)?; - - let index_stats: Vec = index_stats_rows + let index_stats: Vec = index_stats_res + .map_err(TuskError::Database)? .iter() .map(|r| IndexStats { schema: r.get(0), @@ -1517,17 +1580,7 @@ pub async fn get_index_advisor_report( }) .collect(); - // Fetch slow queries (graceful if pg_stat_statements not available) - let (slow_queries, has_pg_stat_statements) = match sqlx::query( - "SELECT query, calls, total_exec_time, mean_exec_time, rows \ - FROM pg_stat_statements \ - WHERE calls > 0 \ - ORDER BY mean_exec_time DESC \ - LIMIT 20" - ) - .fetch_all(&pool) - .await - { + let (slow_queries, has_pg_stat_statements) = match slow_queries_res { Ok(rows) => { let queries: Vec = rows .iter() @@ -1551,7 +1604,13 @@ pub async fn get_index_advisor_report( for ts in &table_stats { stats_text.push_str(&format!( " {}.{}: seq_scan={}, idx_scan={}, rows={}, size={}, idx_size={}\n", - ts.schema, ts.table, ts.seq_scan, ts.idx_scan, ts.n_live_tup, ts.table_size, ts.index_size + ts.schema, + ts.table, + ts.seq_scan, + ts.idx_scan, + ts.n_live_tup, + ts.table_size, + ts.index_size )); } @@ -1568,7 +1627,10 @@ pub async fn get_index_advisor_report( for sq in &slow_queries { stats_text.push_str(&format!( " calls={}, mean={:.1}ms, total={:.1}ms, rows={}: {}\n", - sq.calls, sq.mean_time_ms, sq.total_time_ms, sq.rows, + sq.calls, + sq.mean_time_ms, + sq.total_time_ms, + sq.rows, sq.query.chars().take(200).collect::() )); } @@ -1635,12 +1697,7 @@ pub async fn apply_index_recommendation( return Err(TuskError::ReadOnly); } - let ddl_upper = ddl.trim().to_uppercase(); - if !ddl_upper.starts_with("CREATE INDEX") && !ddl_upper.starts_with("DROP INDEX") { - return Err(TuskError::Custom( - "Only CREATE INDEX and DROP INDEX statements are allowed".to_string(), - )); - } + validate_index_ddl(&ddl)?; let pool = state.get_pool(&connection_id).await?; @@ -1655,3 +1712,151 @@ pub async fn apply_index_recommendation( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + // ── validate_select_statement ───────────────────────────── + + #[test] + fn select_valid_simple() { + assert!(validate_select_statement("SELECT 1").is_ok()); + } + + #[test] + fn select_valid_with_leading_whitespace() { + assert!(validate_select_statement(" SELECT * FROM users").is_ok()); + } + + #[test] + fn select_valid_lowercase() { + assert!(validate_select_statement("select * from users").is_ok()); + } + + #[test] + fn select_rejects_insert() { + assert!(validate_select_statement("INSERT INTO users VALUES (1)").is_err()); + } + + #[test] + fn select_rejects_delete() { + assert!(validate_select_statement("DELETE FROM users").is_err()); + } + + #[test] + fn select_rejects_drop() { + assert!(validate_select_statement("DROP TABLE users").is_err()); + } + + #[test] + fn select_rejects_empty() { + assert!(validate_select_statement("").is_err()); + } + + #[test] + fn select_rejects_whitespace_only() { + assert!(validate_select_statement(" ").is_err()); + } + + // NOTE: This test documents a known weakness — SELECT prefix allows injection + #[test] + fn select_allows_semicolon_after_select() { + // "SELECT 1; DROP TABLE users" starts with SELECT — passes validation + // This is a known limitation documented in the review + assert!(validate_select_statement("SELECT 1; DROP TABLE users").is_ok()); + } + + // ── validate_index_ddl ──────────────────────────────────── + + #[test] + fn ddl_valid_create_index() { + assert!(validate_index_ddl("CREATE INDEX idx_name ON users(email)").is_ok()); + } + + #[test] + fn ddl_valid_create_index_concurrently() { + assert!(validate_index_ddl("CREATE INDEX CONCURRENTLY idx ON t(c)").is_ok()); + } + + #[test] + fn ddl_valid_drop_index() { + assert!(validate_index_ddl("DROP INDEX idx_name").is_ok()); + } + + #[test] + fn ddl_valid_with_leading_whitespace() { + assert!(validate_index_ddl(" CREATE INDEX idx ON t(c)").is_ok()); + } + + #[test] + fn ddl_valid_lowercase() { + assert!(validate_index_ddl("create index idx on t(c)").is_ok()); + } + + #[test] + fn ddl_rejects_create_table() { + assert!(validate_index_ddl("CREATE TABLE evil(id int)").is_err()); + } + + #[test] + fn ddl_rejects_drop_table() { + assert!(validate_index_ddl("DROP TABLE users").is_err()); + } + + #[test] + fn ddl_rejects_alter_table() { + assert!(validate_index_ddl("ALTER TABLE users ADD COLUMN x int").is_err()); + } + + #[test] + fn ddl_rejects_empty() { + assert!(validate_index_ddl("").is_err()); + } + + // NOTE: Documents bypass weakness — semicolon after valid prefix + #[test] + fn ddl_allows_semicolon_injection() { + // "CREATE INDEX x ON t(c); DROP TABLE users" — passes validation + // Mitigated by sqlx single-statement execution + assert!(validate_index_ddl("CREATE INDEX x ON t(c); DROP TABLE users").is_ok()); + } + + // ── clean_sql_response ──────────────────────────────────── + + #[test] + fn clean_sql_plain() { + assert_eq!(clean_sql_response("SELECT 1"), "SELECT 1"); + } + + #[test] + fn clean_sql_with_fences() { + assert_eq!(clean_sql_response("```sql\nSELECT 1\n```"), "SELECT 1"); + } + + #[test] + fn clean_sql_with_generic_fences() { + assert_eq!(clean_sql_response("```\nSELECT 1\n```"), "SELECT 1"); + } + + #[test] + fn clean_sql_with_postgresql_fences() { + assert_eq!( + clean_sql_response("```postgresql\nSELECT 1\n```"), + "SELECT 1" + ); + } + + #[test] + fn clean_sql_with_whitespace() { + assert_eq!(clean_sql_response(" SELECT 1 "), "SELECT 1"); + } + + #[test] + fn clean_sql_no_fences_multiline() { + assert_eq!( + clean_sql_response("SELECT\n *\nFROM users"), + "SELECT\n *\nFROM users" + ); + } +} diff --git a/src-tauri/src/commands/data.rs b/src-tauri/src/commands/data.rs index 9a6aca6..ce5ee37 100644 --- a/src-tauri/src/commands/data.rs +++ b/src-tauri/src/commands/data.rs @@ -10,6 +10,7 @@ use std::time::Instant; use tauri::State; #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn get_table_data( state: State<'_, Arc>, connection_id: String, @@ -55,7 +56,7 @@ pub async fn get_table_data( // Always run table data queries in a read-only transaction to prevent // writable CTEs or other mutation via the raw filter parameter. - let mut tx = (&pool).begin().await.map_err(TuskError::Database)?; + let mut tx = pool.begin().await.map_err(TuskError::Database)?; sqlx::query("SET TRANSACTION READ ONLY") .execute(&mut *tx) .await @@ -129,6 +130,7 @@ pub async fn get_table_data( } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn update_row( state: State<'_, Arc>, connection_id: String, @@ -244,7 +246,7 @@ pub async fn delete_rows( let mut total_affected: u64 = 0; // Wrap all deletes in a transaction for atomicity - let mut tx = (&pool).begin().await.map_err(TuskError::Database)?; + let mut tx = pool.begin().await.map_err(TuskError::Database)?; if pk_columns.is_empty() { // Fallback: use ctids for row identification diff --git a/src-tauri/src/commands/docker.rs b/src-tauri/src/commands/docker.rs index e39df66..62de158 100644 --- a/src-tauri/src/commands/docker.rs +++ b/src-tauri/src/commands/docker.rs @@ -63,10 +63,7 @@ fn shell_escape(s: &str) -> String { /// Validate pg_version matches a safe pattern (e.g. "16", "16.2", "17.1") fn validate_pg_version(version: &str) -> TuskResult<()> { - let is_valid = !version.is_empty() - && version - .chars() - .all(|c| c.is_ascii_digit() || c == '.'); + let is_valid = !version.is_empty() && version.chars().all(|c| c.is_ascii_digit() || c == '.'); if !is_valid { return Err(docker_err(format!( "Invalid pg_version '{}': must contain only digits and dots (e.g. '16', '16.2')", @@ -116,7 +113,9 @@ pub async fn check_docker(state: State<'_, Arc>) -> TuskResult>) -> TuskResult> { +pub async fn list_tusk_containers( + state: State<'_, Arc>, +) -> TuskResult> { let output = docker_cmd(&state) .await .args([ @@ -234,8 +233,8 @@ async fn check_docker_internal(docker_host: &Option) -> TuskResult p, None => find_free_port().await?, }; - emit_progress(app, clone_id, "port", 10, &format!("Using port {}", host_port), None); + emit_progress( + app, + clone_id, + "port", + 10, + &format!("Using port {}", host_port), + None, + ); // Step 3: Create container - emit_progress(app, clone_id, "container", 20, "Creating PostgreSQL container...", None); + emit_progress( + app, + clone_id, + "container", + 20, + "Creating PostgreSQL container...", + None, + ); let pg_password = params.postgres_password.as_deref().unwrap_or("tusk"); let image = format!("postgres:{}", params.pg_version); let create_output = docker_cmd_sync(&docker_host) .args([ - "run", "-d", - "--name", ¶ms.container_name, - "-p", &format!("{}:5432", host_port), - "-e", &format!("POSTGRES_PASSWORD={}", pg_password), - "-l", "tusk.managed=true", - "-l", &format!("tusk.source-db={}", params.source_database), - "-l", &format!("tusk.source-connection={}", params.source_connection_id), - "-l", &format!("tusk.pg-version={}", params.pg_version), + "run", + "-d", + "--name", + ¶ms.container_name, + "-p", + &format!("{}:5432", host_port), + "-e", + &format!("POSTGRES_PASSWORD={}", pg_password), + "-l", + "tusk.managed=true", + "-l", + &format!("tusk.source-db={}", params.source_database), + "-l", + &format!("tusk.source-connection={}", params.source_connection_id), + "-l", + &format!("tusk.pg-version={}", params.pg_version), &image, ]) .output() @@ -306,24 +334,56 @@ async fn do_clone( .map_err(|e| docker_err(format!("Failed to create container: {}", e)))?; if !create_output.status.success() { - let stderr = String::from_utf8_lossy(&create_output.stderr).trim().to_string(); - emit_progress(app, clone_id, "error", 20, &format!("Failed to create container: {}", stderr), None); - return Err(docker_err(format!("Failed to create container: {}", stderr))); + let stderr = String::from_utf8_lossy(&create_output.stderr) + .trim() + .to_string(); + emit_progress( + app, + clone_id, + "error", + 20, + &format!("Failed to create container: {}", stderr), + None, + ); + return Err(docker_err(format!( + "Failed to create container: {}", + stderr + ))); } - let container_id = String::from_utf8_lossy(&create_output.stdout).trim().to_string(); + let container_id = String::from_utf8_lossy(&create_output.stdout) + .trim() + .to_string(); // Step 4: Wait for PostgreSQL to be ready - emit_progress(app, clone_id, "waiting", 30, "Waiting for PostgreSQL to be ready...", None); + emit_progress( + app, + clone_id, + "waiting", + 30, + "Waiting for PostgreSQL to be ready...", + None, + ); wait_for_pg_ready(&docker_host, ¶ms.container_name, 30).await?; emit_progress(app, clone_id, "waiting", 35, "PostgreSQL is ready", None); // Step 5: Create target database - emit_progress(app, clone_id, "database", 35, &format!("Creating database '{}'...", params.source_database), None); + emit_progress( + app, + clone_id, + "database", + 35, + &format!("Creating database '{}'...", params.source_database), + None, + ); let create_db_output = docker_cmd_sync(&docker_host) .args([ - "exec", ¶ms.container_name, - "psql", "-U", "postgres", "-c", + "exec", + ¶ms.container_name, + "psql", + "-U", + "postgres", + "-c", &format!("CREATE DATABASE {}", escape_ident(¶ms.source_database)), ]) .output() @@ -331,20 +391,42 @@ async fn do_clone( .map_err(|e| docker_err(format!("Failed to create database: {}", e)))?; if !create_db_output.status.success() { - let stderr = String::from_utf8_lossy(&create_db_output.stderr).trim().to_string(); + let stderr = String::from_utf8_lossy(&create_db_output.stderr) + .trim() + .to_string(); if !stderr.contains("already exists") { - emit_progress(app, clone_id, "error", 35, &format!("Failed to create database: {}", stderr), None); + emit_progress( + app, + clone_id, + "error", + 35, + &format!("Failed to create database: {}", stderr), + None, + ); return Err(docker_err(format!("Failed to create database: {}", stderr))); } } // Step 6: Get source connection URL (using the specific database to clone) - emit_progress(app, clone_id, "dump", 40, "Preparing data transfer...", None); + emit_progress( + app, + clone_id, + "dump", + 40, + "Preparing data transfer...", + None, + ); let source_config = load_connection_config(app, ¶ms.source_connection_id)?; let source_url = source_config.connection_url_for_db(¶ms.source_database); emit_progress( - app, clone_id, "dump", 40, - &format!("Source: {}@{}:{}/{}", source_config.user, source_config.host, source_config.port, params.source_database), + app, + clone_id, + "dump", + 40, + &format!( + "Source: {}@{}:{}/{}", + source_config.user, source_config.host, source_config.port, params.source_database + ), None, ); @@ -352,23 +434,84 @@ async fn do_clone( match params.clone_mode { CloneMode::SchemaOnly => { emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None); - transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, &docker_host).await?; + transfer_schema_only( + app, + clone_id, + &source_url, + ¶ms.container_name, + ¶ms.source_database, + ¶ms.pg_version, + &docker_host, + ) + .await?; } CloneMode::FullClone => { - emit_progress(app, clone_id, "transfer", 45, "Performing full database clone...", None); - transfer_full_clone(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, &docker_host).await?; + emit_progress( + app, + clone_id, + "transfer", + 45, + "Performing full database clone...", + None, + ); + transfer_full_clone( + app, + clone_id, + &source_url, + ¶ms.container_name, + ¶ms.source_database, + ¶ms.pg_version, + &docker_host, + ) + .await?; } CloneMode::SampleData => { + let has_local = try_local_pg_dump().await; emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None); - transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, &docker_host).await?; - emit_progress(app, clone_id, "transfer", 65, "Copying sample data...", None); + transfer_schema_only_with( + app, + clone_id, + &source_url, + ¶ms.container_name, + ¶ms.source_database, + ¶ms.pg_version, + &docker_host, + has_local, + ) + .await?; + emit_progress( + app, + clone_id, + "transfer", + 65, + "Copying sample data...", + None, + ); let sample_rows = params.sample_rows.unwrap_or(1000); - transfer_sample_data(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, sample_rows, &docker_host).await?; + transfer_sample_data_with( + app, + clone_id, + &source_url, + ¶ms.container_name, + ¶ms.source_database, + ¶ms.pg_version, + sample_rows, + &docker_host, + has_local, + ) + .await?; } } // Step 8: Save connection in Tusk - emit_progress(app, clone_id, "connection", 90, "Saving connection...", None); + emit_progress( + app, + clone_id, + "connection", + 90, + "Saving connection...", + None, + ); let connection_id = uuid::Uuid::new_v4().to_string(); let new_config = ConnectionConfig { id: connection_id.clone(), @@ -407,7 +550,14 @@ async fn do_clone( connection_url, }; - emit_progress(app, clone_id, "done", 100, "Clone completed successfully!", None); + emit_progress( + app, + clone_id, + "done", + 100, + "Clone completed successfully!", + None, + ); Ok(result) } @@ -424,7 +574,11 @@ async fn find_free_port() -> TuskResult { Ok(port) } -async fn wait_for_pg_ready(docker_host: &Option, container_name: &str, timeout_secs: u64) -> TuskResult<()> { +async fn wait_for_pg_ready( + docker_host: &Option, + container_name: &str, + timeout_secs: u64, +) -> TuskResult<()> { let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(timeout_secs); @@ -466,7 +620,13 @@ fn docker_host_flag(docker_host: &Option) -> String { } /// Build the pg_dump portion of a shell command -fn pg_dump_shell_cmd(has_local: bool, pg_version: &str, extra_args: &str, source_url: &str, docker_host: &Option) -> String { +fn pg_dump_shell_cmd( + has_local: bool, + pg_version: &str, + extra_args: &str, + source_url: &str, + docker_host: &Option, +) -> String { let escaped_url = shell_escape(source_url); if has_local { format!("pg_dump {} '{}'", extra_args, escaped_url) @@ -503,7 +663,8 @@ async fn run_pipe_cmd( if !stderr.is_empty() { // Truncate for progress display (full log can be long) let short = if stderr.len() > 500 { - let truncated = stderr.char_indices() + let truncated = stderr + .char_indices() .nth(500) .map(|(i, _)| &stderr[..i]) .unwrap_or(&stderr); @@ -511,33 +672,57 @@ async fn run_pipe_cmd( } else { stderr.clone() }; - emit_progress(app, clone_id, "transfer", 55, &format!("{}: stderr output", label), Some(&short)); + emit_progress( + app, + clone_id, + "transfer", + 55, + &format!("{}: stderr output", label), + Some(&short), + ); } // Count DDL statements in stdout for feedback if !stdout.is_empty() { - let creates = stdout.lines() + let creates = stdout + .lines() .filter(|l| l.starts_with("CREATE") || l.starts_with("ALTER") || l.starts_with("SET")) .count(); if creates > 0 { - emit_progress(app, clone_id, "transfer", 58, &format!("Applied {} SQL statements", creates), None); + emit_progress( + app, + clone_id, + "transfer", + 58, + &format!("Applied {} SQL statements", creates), + None, + ); } } if !output.status.success() { let code = output.status.code().unwrap_or(-1); emit_progress( - app, clone_id, "transfer", 55, + app, + clone_id, + "transfer", + 55, &format!("{} exited with code {}", label, code), Some(&stderr), ); // Only hard-fail on connection / fatal errors - if stderr.contains("FATAL") || stderr.contains("could not connect") - || stderr.contains("No such file") || stderr.contains("password authentication failed") - || stderr.contains("does not exist") || (stdout.is_empty() && stderr.is_empty()) + if stderr.contains("FATAL") + || stderr.contains("could not connect") + || stderr.contains("No such file") + || stderr.contains("password authentication failed") + || stderr.contains("does not exist") + || (stdout.is_empty() && stderr.is_empty()) { - return Err(docker_err(format!("{} failed (exit {}): {}", label, code, stderr))); + return Err(docker_err(format!( + "{} failed (exit {}): {}", + label, code, stderr + ))); } } @@ -554,20 +739,61 @@ async fn transfer_schema_only( docker_host: &Option, ) -> TuskResult<()> { let has_local = try_local_pg_dump().await; - let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" }; - emit_progress(app, clone_id, "transfer", 48, &format!("Using {} for schema...", label), None); + transfer_schema_only_with(app, clone_id, source_url, container_name, database, pg_version, docker_host, has_local).await +} - let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--schema-only --no-owner --no-acl", source_url, docker_host); +#[allow(clippy::too_many_arguments)] +async fn transfer_schema_only_with( + app: &AppHandle, + clone_id: &str, + source_url: &str, + container_name: &str, + database: &str, + pg_version: &str, + docker_host: &Option, + has_local: bool, +) -> TuskResult<()> { + let label = if has_local { + "local pg_dump" + } else { + "Docker-based pg_dump" + }; + emit_progress( + app, + clone_id, + "transfer", + 48, + &format!("Using {} for schema...", label), + None, + ); + + let dump_cmd = pg_dump_shell_cmd( + has_local, + pg_version, + "--schema-only --no-owner --no-acl", + source_url, + docker_host, + ); let escaped_db = shell_escape(database); let host_flag = docker_host_flag(docker_host); let pipe_cmd = format!( "{} | docker {} exec -i '{}' psql -U postgres -d '{}'", - dump_cmd, host_flag, shell_escape(container_name), escaped_db + dump_cmd, + host_flag, + shell_escape(container_name), + escaped_db ); run_pipe_cmd(app, clone_id, &pipe_cmd, "Schema transfer").await?; - emit_progress(app, clone_id, "transfer", 60, "Schema transferred successfully", None); + emit_progress( + app, + clone_id, + "transfer", + 60, + "Schema transferred successfully", + None, + ); Ok(()) } @@ -581,16 +807,36 @@ async fn transfer_full_clone( docker_host: &Option, ) -> TuskResult<()> { let has_local = try_local_pg_dump().await; - let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" }; - emit_progress(app, clone_id, "transfer", 48, &format!("Using {} for full clone...", label), None); + let label = if has_local { + "local pg_dump" + } else { + "Docker-based pg_dump" + }; + emit_progress( + app, + clone_id, + "transfer", + 48, + &format!("Using {} for full clone...", label), + None, + ); // Use plain text format piped to psql (more reliable than -Fc | pg_restore through docker exec) - let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--no-owner --no-acl", source_url, docker_host); + let dump_cmd = pg_dump_shell_cmd( + has_local, + pg_version, + "--no-owner --no-acl", + source_url, + docker_host, + ); let escaped_db = shell_escape(database); let host_flag = docker_host_flag(docker_host); let pipe_cmd = format!( "{} | docker {} exec -i '{}' psql -U postgres -d '{}'", - dump_cmd, host_flag, shell_escape(container_name), escaped_db + dump_cmd, + host_flag, + shell_escape(container_name), + escaped_db ); run_pipe_cmd(app, clone_id, &pipe_cmd, "Full clone").await?; @@ -599,7 +845,8 @@ async fn transfer_full_clone( Ok(()) } -async fn transfer_sample_data( +#[allow(clippy::too_many_arguments)] +async fn transfer_sample_data_with( app: &AppHandle, clone_id: &str, source_url: &str, @@ -608,6 +855,7 @@ async fn transfer_sample_data( pg_version: &str, sample_rows: u32, docker_host: &Option, + has_local: bool, ) -> TuskResult<()> { // List tables from the target (schema already transferred) let target_output = docker_cmd_sync(docker_host) @@ -622,21 +870,37 @@ async fn transfer_sample_data( .map_err(|e| docker_err(format!("Failed to list tables: {}", e)))?; let tables_str = String::from_utf8_lossy(&target_output.stdout); - let tables: Vec<&str> = tables_str.lines().filter(|l| !l.trim().is_empty()).collect(); + let tables: Vec<&str> = tables_str + .lines() + .filter(|l| !l.trim().is_empty()) + .collect(); let total = tables.len(); if total == 0 { - emit_progress(app, clone_id, "transfer", 85, "No tables to copy data for", None); + emit_progress( + app, + clone_id, + "transfer", + 85, + "No tables to copy data for", + None, + ); return Ok(()); } - let has_local = try_local_pg_dump().await; - for (i, qualified_table) in tables.iter().enumerate() { let pct = 65 + ((i * 20) / total.max(1)).min(20) as u8; emit_progress( - app, clone_id, "transfer", pct, - &format!("Copying sample data: {} ({}/{})", qualified_table, i + 1, total), + app, + clone_id, + "transfer", + pct, + &format!( + "Copying sample data: {} ({}/{})", + qualified_table, + i + 1, + total + ), None, ); @@ -680,17 +944,17 @@ async fn transfer_sample_data( source_cmd, host_flag, escaped_container, escaped_db, copy_in_sql ); - let output = Command::new("bash") - .args(["-c", &pipe_cmd]) - .output() - .await; + let output = Command::new("bash").args(["-c", &pipe_cmd]).output().await; match output { Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); if !stderr.is_empty() && (stderr.contains("ERROR") || stderr.contains("FATAL")) { emit_progress( - app, clone_id, "transfer", pct, + app, + clone_id, + "transfer", + pct, &format!("Warning: {}", qualified_table), Some(&stderr), ); @@ -698,7 +962,10 @@ async fn transfer_sample_data( } Err(e) => { emit_progress( - app, clone_id, "transfer", pct, + app, + clone_id, + "transfer", + pct, &format!("Warning: failed to copy {}: {}", qualified_table, e), None, ); @@ -706,7 +973,14 @@ async fn transfer_sample_data( } } - emit_progress(app, clone_id, "transfer", 85, "Sample data transfer completed", None); + emit_progress( + app, + clone_id, + "transfer", + 85, + "Sample data transfer completed", + None, + ); Ok(()) } @@ -776,8 +1050,159 @@ pub async fn remove_container(state: State<'_, Arc>, name: String) -> if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(docker_err(format!("Failed to remove container: {}", stderr))); + return Err(docker_err(format!( + "Failed to remove container: {}", + stderr + ))); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + // ── validate_container_name ─────────────────────────────── + + #[test] + fn container_name_valid_simple() { + assert!(validate_container_name("mycontainer").is_ok()); + } + + #[test] + fn container_name_valid_with_dots_dashes_underscores() { + assert!(validate_container_name("my-container_v1.2").is_ok()); + } + + #[test] + fn container_name_valid_starts_with_digit() { + assert!(validate_container_name("1container").is_ok()); + } + + #[test] + fn container_name_empty() { + assert!(validate_container_name("").is_err()); + } + + #[test] + fn container_name_starts_with_dash() { + assert!(validate_container_name("-bad").is_err()); + } + + #[test] + fn container_name_starts_with_dot() { + assert!(validate_container_name(".bad").is_err()); + } + + #[test] + fn container_name_starts_with_underscore() { + assert!(validate_container_name("_bad").is_err()); + } + + #[test] + fn container_name_with_spaces() { + assert!(validate_container_name("bad name").is_err()); + } + + #[test] + fn container_name_with_unicode() { + assert!(validate_container_name("контейнер").is_err()); + } + + #[test] + fn container_name_with_special_chars() { + assert!(validate_container_name("bad;name").is_err()); + assert!(validate_container_name("bad/name").is_err()); + assert!(validate_container_name("bad:name").is_err()); + assert!(validate_container_name("bad@name").is_err()); + } + + #[test] + fn container_name_with_shell_injection() { + assert!(validate_container_name("x; rm -rf /").is_err()); + assert!(validate_container_name("x$(whoami)").is_err()); + } + + // ── validate_pg_version ─────────────────────────────────── + + #[test] + fn pg_version_valid_major() { + assert!(validate_pg_version("16").is_ok()); + } + + #[test] + fn pg_version_valid_major_minor() { + assert!(validate_pg_version("16.2").is_ok()); + } + + #[test] + fn pg_version_valid_three_parts() { + assert!(validate_pg_version("17.1.0").is_ok()); + } + + #[test] + fn pg_version_empty() { + assert!(validate_pg_version("").is_err()); + } + + #[test] + fn pg_version_with_letters() { + assert!(validate_pg_version("16beta1").is_err()); + } + + #[test] + fn pg_version_with_injection() { + assert!(validate_pg_version("16; rm -rf").is_err()); + } + + #[test] + fn pg_version_only_dots() { + // Current impl allows dots-only — this documents the behavior + assert!(validate_pg_version("...").is_ok()); + } + + // ── shell_escape ────────────────────────────────────────── + + #[test] + fn shell_escape_no_quotes() { + assert_eq!(shell_escape("hello"), "hello"); + } + + #[test] + fn shell_escape_with_single_quote() { + assert_eq!(shell_escape("it's"), "it'\\''s"); + } + + #[test] + fn shell_escape_multiple_quotes() { + assert_eq!(shell_escape("a'b'c"), "a'\\''b'\\''c"); + } + + // ── shell_escape_double ─────────────────────────────────── + + #[test] + fn shell_escape_double_no_special() { + assert_eq!(shell_escape_double("hello"), "hello"); + } + + #[test] + fn shell_escape_double_with_backslash() { + assert_eq!(shell_escape_double(r"a\b"), r"a\\b"); + } + + #[test] + fn shell_escape_double_with_dollar() { + assert_eq!(shell_escape_double("$HOME"), "\\$HOME"); + } + + #[test] + fn shell_escape_double_with_backtick() { + assert_eq!(shell_escape_double("`whoami`"), "\\`whoami\\`"); + } + + #[test] + fn shell_escape_double_with_double_quote() { + assert_eq!(shell_escape_double(r#"say "hi""#), r#"say \"hi\""#); + } +} diff --git a/src-tauri/src/commands/management.rs b/src-tauri/src/commands/management.rs index b6fd918..8347e2c 100644 --- a/src-tauri/src/commands/management.rs +++ b/src-tauri/src/commands/management.rs @@ -336,10 +336,7 @@ pub async fn alter_role( options.push(format!("CONNECTION LIMIT {}", limit)); } if let Some(ref valid_until) = params.valid_until { - options.push(format!( - "VALID UNTIL '{}'", - valid_until.replace('\'', "''") - )); + options.push(format!("VALID UNTIL '{}'", valid_until.replace('\'', "''"))); } if !options.is_empty() { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b75c8f9..8bf6058 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,5 +9,5 @@ pub mod management; pub mod queries; pub mod saved_queries; pub mod schema; -pub mod snapshot; pub mod settings; +pub mod snapshot; diff --git a/src-tauri/src/commands/queries.rs b/src-tauri/src/commands/queries.rs index be5b8a3..b94526f 100644 --- a/src-tauri/src/commands/queries.rs +++ b/src-tauri/src/commands/queries.rs @@ -43,20 +43,16 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value { } "DATE" => try_get!(chrono::NaiveDate), "TIME" => try_get!(chrono::NaiveTime), - "BYTEA" => { - match row.try_get::>, _>(index) { - Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))), - Ok(None) => return Value::Null, - Err(_) => {} - } - } - "OID" => { - match row.try_get::, _>(index) { - Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)), - Ok(None) => return Value::Null, - Err(_) => {} - } - } + "BYTEA" => match row.try_get::>, _>(index) { + Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))), + Ok(None) => return Value::Null, + Err(_) => {} + }, + "OID" => match row.try_get::, _>(index) { + Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)), + Ok(None) => return Value::Null, + Err(_) => {} + }, "VOID" => return Value::Null, // Array types (PG prefixes array type names with underscore) "_BOOL" => try_get!(Vec), @@ -124,7 +120,11 @@ pub async fn execute_query_core( let result_rows: Vec> = rows .iter() - .map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect()) + .map(|row| { + (0..columns.len()) + .map(|i| pg_value_to_json(row, i)) + .collect() + }) .collect(); let row_count = result_rows.len(); diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 4535729..e68e071 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -28,10 +28,7 @@ pub async fn list_databases( Ok(rows.iter().map(|r| r.get::(0)).collect()) } -pub async fn list_schemas_core( - state: &AppState, - connection_id: &str, -) -> TuskResult> { +pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult> { let pool = state.get_pool(connection_id).await?; let flavor = state.get_flavor(connection_id).await; @@ -593,11 +590,13 @@ pub async fn get_schema_erd( let mut tables_map: HashMap = HashMap::new(); for row in &col_rows { let table_name: String = row.get(0); - let entry = tables_map.entry(table_name.clone()).or_insert_with(|| ErdTable { - schema: schema.clone(), - name: table_name, - columns: Vec::new(), - }); + let entry = tables_map + .entry(table_name.clone()) + .or_insert_with(|| ErdTable { + schema: schema.clone(), + name: table_name, + columns: Vec::new(), + }); entry.columns.push(ErdColumn { name: row.get(1), data_type: row.get(2), diff --git a/src-tauri/src/commands/snapshot.rs b/src-tauri/src/commands/snapshot.rs index 8ac093e..fa57d5c 100644 --- a/src-tauri/src/commands/snapshot.rs +++ b/src-tauri/src/commands/snapshot.rs @@ -46,12 +46,10 @@ pub async fn create_snapshot( if params.include_dependencies { for fk in &fk_rows { - for (schema, table) in ¶ms.tables.iter().map(|t| (t.schema.clone(), t.table.clone())).collect::>() { - if &fk.schema == schema && &fk.table == table { - let parent = (fk.ref_schema.clone(), fk.ref_table.clone()); - if !target_tables.contains(&parent) { - target_tables.push(parent); - } + if target_tables.iter().any(|(s, t)| s == &fk.schema && t == &fk.table) { + let parent = (fk.ref_schema.clone(), fk.ref_table.clone()); + if !target_tables.contains(&parent) { + target_tables.push(parent); } } } @@ -60,11 +58,18 @@ pub async fn create_snapshot( // FK-based topological sort let fk_edges: Vec<(String, String, String, String)> = fk_rows .iter() - .map(|fk| (fk.schema.clone(), fk.table.clone(), fk.ref_schema.clone(), fk.ref_table.clone())) + .map(|fk| { + ( + fk.schema.clone(), + fk.table.clone(), + fk.ref_schema.clone(), + fk.ref_table.clone(), + ) + }) .collect(); let sorted_tables = topological_sort_tables(&fk_edges, &target_tables); - let mut tx = (&pool).begin().await.map_err(TuskError::Database)?; + let mut tx = pool.begin().await.map_err(TuskError::Database)?; sqlx::query("SET TRANSACTION READ ONLY") .execute(&mut *tx) .await @@ -107,7 +112,11 @@ pub async fn create_snapshot( let data_rows: Vec> = rows .iter() - .map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect()) + .map(|row| { + (0..columns.len()) + .map(|i| pg_value_to_json(row, i)) + .collect() + }) .collect(); let row_count = data_rows.len() as u64; @@ -207,7 +216,7 @@ pub async fn restore_snapshot( let snapshot: Snapshot = serde_json::from_str(&data)?; let pool = state.get_pool(¶ms.connection_id).await?; - let mut tx = (&pool).begin().await.map_err(TuskError::Database)?; + let mut tx = pool.begin().await.map_err(TuskError::Database)?; sqlx::query("SET CONSTRAINTS ALL DEFERRED") .execute(&mut *tx) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e0dc796..40655c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,9 +57,13 @@ pub fn run() { let mcp_state = state.clone(); tauri::async_runtime::spawn(async move { *mcp_state.mcp_running.write().await = true; - if let Err(e) = - mcp::start_mcp_server(mcp_state.clone(), connections_path, mcp_port, shutdown_rx) - .await + if let Err(e) = mcp::start_mcp_server( + mcp_state.clone(), + connections_path, + mcp_port, + shutdown_rx, + ) + .await { log::error!("MCP server error: {}", e); } diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index d1d8301..33fc2c5 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -79,7 +79,9 @@ impl TuskMcpServer { } } - #[tool(description = "List all configured database connections with their active/read-only status")] + #[tool( + description = "List all configured database connections with their active/read-only status" + )] async fn list_connections(&self) -> Result { let configs: Vec = if self.connections_path.exists() { let data = std::fs::read_to_string(&self.connections_path).map_err(|e| { @@ -110,9 +112,8 @@ impl TuskMcpServer { }) .collect(); - let json = serde_json::to_string_pretty(&statuses).map_err(|e| { - McpError::internal_error(format!("Serialization error: {}", e), None) - })?; + let json = serde_json::to_string_pretty(&statuses) + .map_err(|e| McpError::internal_error(format!("Serialization error: {}", e), None))?; Ok(CallToolResult::success(vec![Content::text(json)])) } @@ -165,7 +166,9 @@ impl TuskMcpServer { } } - #[tool(description = "Describe table columns: name, type, nullable, primary key, default value")] + #[tool( + description = "Describe table columns: name, type, nullable, primary key, default value" + )] async fn describe_table( &self, Parameters(params): Parameters, diff --git a/src-tauri/src/models/ai.rs b/src-tauri/src/models/ai.rs index b26d3a0..00a5fba 100644 --- a/src-tauri/src/models/ai.rs +++ b/src-tauri/src/models/ai.rs @@ -83,16 +83,6 @@ pub struct ValidationRule { pub error: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ValidationReport { - pub rules: Vec, - pub total_rules: usize, - pub passed: usize, - pub failed: usize, - pub errors: usize, - pub execution_time_ms: u128, -} - // --- Wave 2: Data Generator --- #[derive(Debug, Clone, Serialize, Deserialize)] @@ -165,9 +155,12 @@ pub struct SlowQuery { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IndexRecommendationType { - CreateIndex, - DropIndex, - ReplaceIndex, + #[serde(rename = "create_index")] + Create, + #[serde(rename = "drop_index")] + Drop, + #[serde(rename = "replace_index")] + Replace, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs index dca2f03..c8a678c 100644 --- a/src-tauri/src/models/connection.rs +++ b/src-tauri/src/models/connection.rs @@ -36,8 +36,8 @@ impl ConnectionConfig { fn urlencoded(s: &str) -> String { s.chars() .map(|c| match c { - ':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' - | '*' | '+' | ',' | ';' | '=' | '%' | ' ' => { + ':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' + | '+' | ',' | ';' | '=' | '%' | ' ' => { format!("%{:02X}", c as u8) } _ => c.to_string(), diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs index 52e3a7c..f3211c8 100644 --- a/src-tauri/src/models/settings.rs +++ b/src-tauri/src/models/settings.rs @@ -1,20 +1,11 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AppSettings { pub mcp: McpSettings, pub docker: DockerSettings, } -impl Default for AppSettings { - fn default() -> Self { - Self { - mcp: McpSettings::default(), - docker: DockerSettings::default(), - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpSettings { pub enabled: bool, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index d86ae5e..931f914 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,7 +3,6 @@ use crate::models::ai::AiSettings; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::collections::HashMap; -use std::path::PathBuf; use std::time::{Duration, Instant}; use tokio::sync::{watch, RwLock}; @@ -22,7 +21,6 @@ pub struct SchemaCacheEntry { pub struct AppState { pub pools: RwLock>, - pub config_path: RwLock>, pub read_only: RwLock>, pub db_flavors: RwLock>, pub schema_cache: RwLock>, @@ -39,7 +37,6 @@ impl AppState { let (mcp_shutdown_tx, _) = watch::channel(false); Self { pools: RwLock::new(HashMap::new()), - config_path: RwLock::new(None), read_only: RwLock::new(HashMap::new()), db_flavors: RwLock::new(HashMap::new()), schema_cache: RwLock::new(HashMap::new()), @@ -81,6 +78,8 @@ impl AppState { pub async fn set_schema_cache(&self, connection_id: String, schema_text: String) { let mut cache = self.schema_cache.write().await; + // Evict stale entries to prevent unbounded memory growth + cache.retain(|_, entry| entry.cached_at.elapsed() < SCHEMA_CACHE_TTL); cache.insert( connection_id, SchemaCacheEntry { diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index df85413..e1e0df8 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -34,7 +34,11 @@ pub fn topological_sort_tables( continue; } - if graph.entry(parent.clone()).or_default().insert(child.clone()) { + if graph + .entry(parent.clone()) + .or_default() + .insert(child.clone()) + { *in_degree.entry(child).or_insert(0) += 1; } } @@ -73,3 +77,136 @@ pub fn topological_sort_tables( result } + +#[cfg(test)] +mod tests { + use super::*; + + // ── escape_ident ────────────────────────────────────────── + + #[test] + fn escape_ident_simple_name() { + assert_eq!(escape_ident("users"), "\"users\""); + } + + #[test] + fn escape_ident_with_double_quotes() { + assert_eq!(escape_ident(r#"my"table"#), r#""my""table""#); + } + + #[test] + fn escape_ident_empty_string() { + assert_eq!(escape_ident(""), r#""""#); + } + + #[test] + fn escape_ident_with_spaces() { + assert_eq!(escape_ident("my table"), "\"my table\""); + } + + #[test] + fn escape_ident_with_semicolon() { + assert_eq!(escape_ident("users; DROP TABLE"), "\"users; DROP TABLE\""); + } + + #[test] + fn escape_ident_with_single_quotes() { + assert_eq!(escape_ident("it's"), "\"it's\""); + } + + #[test] + fn escape_ident_with_backslash() { + assert_eq!(escape_ident(r"back\slash"), r#""back\slash""#); + } + + #[test] + fn escape_ident_unicode() { + assert_eq!(escape_ident("таблица"), "\"таблица\""); + } + + #[test] + fn escape_ident_multiple_double_quotes() { + assert_eq!(escape_ident(r#"a""b"#), r#""a""""b""#); + } + + #[test] + fn escape_ident_reserved_word() { + assert_eq!(escape_ident("select"), "\"select\""); + } + + #[test] + fn escape_ident_null_byte() { + assert_eq!(escape_ident("a\0b"), "\"a\0b\""); + } + + #[test] + fn escape_ident_newline() { + assert_eq!(escape_ident("a\nb"), "\"a\nb\""); + } + + // ── topological_sort_tables ─────────────────────────────── + + #[test] + fn topo_sort_no_edges() { + let tables = vec![("public".into(), "b".into()), ("public".into(), "a".into())]; + let result = topological_sort_tables(&[], &tables); + assert_eq!(result.len(), 2); + assert!(result.contains(&("public".into(), "a".into()))); + assert!(result.contains(&("public".into(), "b".into()))); + } + + #[test] + fn topo_sort_simple_dependency() { + let edges = vec![( + "public".into(), + "orders".into(), + "public".into(), + "users".into(), + )]; + let tables = vec![ + ("public".into(), "orders".into()), + ("public".into(), "users".into()), + ]; + let result = topological_sort_tables(&edges, &tables); + let user_pos = result.iter().position(|t| t.1 == "users").unwrap(); + let order_pos = result.iter().position(|t| t.1 == "orders").unwrap(); + assert!(user_pos < order_pos, "users must come before orders"); + } + + #[test] + fn topo_sort_self_reference() { + let edges = vec![( + "public".into(), + "employees".into(), + "public".into(), + "employees".into(), + )]; + let tables = vec![("public".into(), "employees".into())]; + let result = topological_sort_tables(&edges, &tables); + assert_eq!(result.len(), 1); + } + + #[test] + fn topo_sort_cycle() { + let edges = vec![ + ("public".into(), "a".into(), "public".into(), "b".into()), + ("public".into(), "b".into(), "public".into(), "a".into()), + ]; + let tables = vec![("public".into(), "a".into()), ("public".into(), "b".into())]; + let result = topological_sort_tables(&edges, &tables); + assert_eq!(result.len(), 2); + } + + #[test] + fn topo_sort_edge_outside_target_set_ignored() { + let edges = vec![( + "public".into(), + "orders".into(), + "public".into(), + "external".into(), + )]; + let tables = vec![("public".into(), "orders".into())]; + let result = topological_sort_tables(&edges, &tables); + assert_eq!(result.len(), 1); + } +} diff --git a/src/App.tsx b/src/App.tsx index b94aeb5..3725fae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,7 +49,7 @@ export default function App() { }, [handleNewQuery, handleCloseTab]); return ( -
+
diff --git a/src/components/ai/AiBar.tsx b/src/components/ai/AiBar.tsx index 86ecdad..5adf802 100644 --- a/src/components/ai/AiBar.tsx +++ b/src/components/ai/AiBar.tsx @@ -52,21 +52,21 @@ export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Prop }; return ( -
- +
+ setPrompt(e.target.value)} onKeyDown={handleKeyDown} placeholder="Describe the query you want..." - className="h-7 min-w-0 flex-1 text-xs" + className="h-7 min-w-0 flex-1 border-tusk-purple/20 bg-tusk-purple/5 text-xs placeholder:text-muted-foreground/40 focus:border-tusk-purple/40 focus:ring-tusk-purple/20" autoFocus disabled={generateMutation.isPending} /> {prompt.trim() && ( )} diff --git a/src/components/connections/ConnectionDialog.tsx b/src/components/connections/ConnectionDialog.tsx index 19c0f59..ab67a19 100644 --- a/src/components/connections/ConnectionDialog.tsx +++ b/src/components/connections/ConnectionDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -99,7 +99,9 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) { const saveMutation = useSaveConnection(); const testMutation = useTestConnection(); - useEffect(() => { + const [prev, setPrev] = useState<{ open: boolean; connection: typeof connection }>({ open: false, connection: null }); + if (open !== prev.open || connection !== prev.connection) { + setPrev({ open, connection }); if (open) { const config = connection ?? { ...emptyConfig, id: crypto.randomUUID() }; setForm(config); @@ -107,7 +109,7 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) { setDsn(connection ? buildDsn(config) : ""); setDsnError(null); } - }, [open, connection]); + } const update = (field: keyof ConnectionConfig, value: string | number) => { setForm((f) => ({ ...f, [field]: value })); diff --git a/src/components/data-generator/GenerateDataDialog.tsx b/src/components/data-generator/GenerateDataDialog.tsx index 0d8a606..0ee4e67 100644 --- a/src/components/data-generator/GenerateDataDialog.tsx +++ b/src/components/data-generator/GenerateDataDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -54,7 +54,9 @@ export function GenerateDataDialog({ reset, } = useDataGenerator(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setStep("config"); setRowCount(10); @@ -62,7 +64,7 @@ export function GenerateDataDialog({ setCustomInstructions(""); reset(); } - }, [open, reset]); + } const handleGenerate = () => { const genId = crypto.randomUUID(); diff --git a/src/components/docker/CloneDatabaseDialog.tsx b/src/components/docker/CloneDatabaseDialog.tsx index 89c5329..9f566bf 100644 --- a/src/components/docker/CloneDatabaseDialog.tsx +++ b/src/components/docker/CloneDatabaseDialog.tsx @@ -112,11 +112,13 @@ export function CloneDatabaseDialog({ useCloneToDocker(); // Reset state when dialog opens - useEffect(() => { + const [prevOpen, setPrevOpen] = useState<{ open: boolean; database: string }>({ open: false, database: "" }); + if (open !== prevOpen.open || database !== prevOpen.database) { + setPrevOpen({ open, database }); if (open) { setStep("config"); setContainerName( - `tusk-${database.replace(/[^a-zA-Z0-9_-]/g, "-")}-${Date.now().toString(36)}` + `tusk-${database.replace(/[^a-zA-Z0-9_-]/g, "-")}-${crypto.randomUUID().slice(0, 8)}` ); setPgVersion("16"); setPortMode("auto"); @@ -127,10 +129,12 @@ export function CloneDatabaseDialog({ setLogOpen(false); reset(); } - }, [open, database, reset]); + } // Accumulate progress events into log - useEffect(() => { + const [prevProgress, setPrevProgress] = useState(progress); + if (progress !== prevProgress) { + setPrevProgress(progress); if (progress) { setLogEntries((prev) => { const last = prev[prev.length - 1]; @@ -143,7 +147,7 @@ export function CloneDatabaseDialog({ setStep("done"); } } - }, [progress]); + } // Auto-scroll log to bottom useEffect(() => { diff --git a/src/components/erd/ErdDiagram.tsx b/src/components/erd/ErdDiagram.tsx index a5e722c..29773f5 100644 --- a/src/components/erd/ErdDiagram.tsx +++ b/src/components/erd/ErdDiagram.tsx @@ -1,4 +1,4 @@ -import { useMemo, useCallback, useEffect, useState } from "react"; +import { useMemo, useCallback, useState } from "react"; import { useTheme } from "next-themes"; import { ReactFlow, @@ -111,12 +111,14 @@ export function ErdDiagram({ connectionId, schema }: Props) { const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); - useEffect(() => { + const [prevLayout, setPrevLayout] = useState(layout); + if (layout !== prevLayout) { + setPrevLayout(layout); if (layout) { setNodes(layout.nodes); setEdges(layout.edges); } - }, [layout]); + } const onNodesChange = useCallback( (changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)), diff --git a/src/components/layout/ReadOnlyToggle.tsx b/src/components/layout/ReadOnlyToggle.tsx index 590cf82..cd82294 100644 --- a/src/components/layout/ReadOnlyToggle.tsx +++ b/src/components/layout/ReadOnlyToggle.tsx @@ -36,24 +36,24 @@ export function ReadOnlyToggle() { diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 06122b3..462cd33 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -11,7 +11,7 @@ import { SchemaTree } from "@/components/schema/SchemaTree"; import { HistoryPanel } from "@/components/history/HistoryPanel"; import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel"; import { AdminPanel } from "@/components/management/AdminPanel"; -import { Search, RefreshCw } from "lucide-react"; +import { Search, RefreshCw, Layers, Clock, Bookmark, Shield } from "lucide-react"; type SidebarView = "schema" | "history" | "saved" | "admin"; @@ -20,6 +20,13 @@ const SCHEMA_QUERY_KEYS = [ "functions", "sequences", "completionSchema", "column-details", ]; +const SIDEBAR_TABS: { id: SidebarView; label: string; icon: React.ReactNode }[] = [ + { id: "schema", label: "Schema", icon: }, + { id: "history", label: "History", icon: }, + { id: "saved", label: "Saved", icon: }, + { id: "admin", label: "Admin", icon: }, +]; + export function Sidebar() { const [view, setView] = useState("schema"); const [search, setSearch] = useState(""); @@ -32,58 +39,33 @@ export function Sidebar() { }; return ( -
-
- - - - +
+ {/* Sidebar navigation tabs */} +
+ {SIDEBAR_TABS.map((tab) => ( + + ))}
{view === "schema" ? ( <>
- + setSearch(e.target.value)} /> @@ -94,6 +76,7 @@ export function Sidebar() { variant="ghost" size="icon-xs" onClick={handleRefreshSchema} + className="text-muted-foreground hover:text-foreground" > diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 3cfd803..be2e37c 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -1,7 +1,6 @@ import { useAppStore } from "@/stores/app-store"; import { useConnections } from "@/hooks/use-connections"; import { useMcpStatus } from "@/hooks/use-settings"; -import { Circle } from "lucide-react"; import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -31,51 +30,67 @@ export function StatusBar({ rowCount, executionTime }: Props) { : false; return ( -
+
- + {activeConn?.color ? ( ) : ( - )} - {activeConn ? activeConn.name : "No connection"} + + {activeConn ? activeConn.name : "No connection"} + {isConnected && activeConnectionId && ( - {(readOnlyMap[activeConnectionId] ?? true) ? "RO" : "RW"} + {(readOnlyMap[activeConnectionId] ?? true) ? "READ" : "WRITE"} )} {pgVersion && ( - {formatDbVersion(pgVersion)} + + {formatDbVersion(pgVersion)} + )}
- {rowCount != null && {rowCount.toLocaleString()} rows} - {executionTime != null && {executionTime} ms} + {rowCount != null && ( + + {rowCount.toLocaleString()} rows + + )} + {executionTime != null && ( + + {executionTime} ms + + )} - + - MCP + MCP diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx index a538203..b66485e 100644 --- a/src/components/layout/TabBar.tsx +++ b/src/components/layout/TabBar.tsx @@ -23,48 +23,55 @@ export function TabBar() { }; return ( -
+
- {tabs.map((tab) => ( -
setActiveTabId(tab.id)} - > - {(() => { - const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color; - return tabColor ? ( + {tabs.map((tab) => { + const isActive = activeTabId === tab.id; + const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color; + + return ( +
setActiveTabId(tab.id)} + > + {tabColor && ( - ) : null; - })()} - {iconMap[tab.type]} - - {tab.title} - {tab.database && ( - - {tab.database} - )} - - -
- ))} + {iconMap[tab.type]} + + {tab.title} + {tab.database && ( + + {tab.database} + + )} + + + + {/* Right separator between tabs */} + {!isActive && ( +
+ )} +
+ ); + })}
diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 8d192bb..435a794 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; import { ConnectionSelector } from "@/components/connections/ConnectionSelector"; import { ConnectionList } from "@/components/connections/ConnectionList"; import { ConnectionDialog } from "@/components/connections/ConnectionDialog"; @@ -61,70 +60,73 @@ export function Toolbar() { return ( <>
- +
- +
- +
diff --git a/src/components/management/AlterRoleDialog.tsx b/src/components/management/AlterRoleDialog.tsx index 347e194..c7f4106 100644 --- a/src/components/management/AlterRoleDialog.tsx +++ b/src/components/management/AlterRoleDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -34,7 +34,9 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop const alterMutation = useAlterRole(); - useEffect(() => { + const [prev, setPrev] = useState<{ open: boolean; role: typeof role }>({ open: false, role: null }); + if (open !== prev.open || role !== prev.role) { + setPrev({ open, role }); if (open && role) { setPassword(""); setLogin(role.can_login); @@ -47,7 +49,7 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop setValidUntil(role.valid_until ?? ""); setRenameTo(""); } - }, [open, role]); + } if (!role) return null; diff --git a/src/components/management/CreateDatabaseDialog.tsx b/src/components/management/CreateDatabaseDialog.tsx index 09ab07e..b769fbe 100644 --- a/src/components/management/CreateDatabaseDialog.tsx +++ b/src/components/management/CreateDatabaseDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -35,7 +35,9 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props const { data: roles } = useRoles(open ? connectionId : null); const createMutation = useCreateDatabase(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setName(""); setOwner("__default__"); @@ -43,7 +45,7 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props setEncoding("UTF8"); setConnectionLimit(-1); } - }, [open]); + } const handleCreate = () => { if (!name.trim()) { diff --git a/src/components/management/CreateRoleDialog.tsx b/src/components/management/CreateRoleDialog.tsx index 91d2506..b4c9778 100644 --- a/src/components/management/CreateRoleDialog.tsx +++ b/src/components/management/CreateRoleDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -34,7 +34,9 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) { const { data: roles } = useRoles(open ? connectionId : null); const createMutation = useCreateRole(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setName(""); setPassword(""); @@ -48,7 +50,7 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) { setValidUntil(""); setInRoles([]); } - }, [open]); + } const handleCreate = () => { if (!name.trim()) { diff --git a/src/components/management/GrantRevokeDialog.tsx b/src/components/management/GrantRevokeDialog.tsx index b63c40c..e68bb7a 100644 --- a/src/components/management/GrantRevokeDialog.tsx +++ b/src/components/management/GrantRevokeDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -61,14 +61,16 @@ export function GrantRevokeDialog({ ); const grantRevokeMutation = useGrantRevoke(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setAction("GRANT"); setRoleName(""); setPrivileges([]); setWithGrantOption(false); } - }, [open]); + } const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE; diff --git a/src/components/results/ResultsTable.tsx b/src/components/results/ResultsTable.tsx index 7986ee7..3bed8ea 100644 --- a/src/components/results/ResultsTable.tsx +++ b/src/components/results/ResultsTable.tsx @@ -55,11 +55,11 @@ export function ResultsTable({ return (
@@ -77,6 +77,7 @@ export function ResultsTable({ [colNames, onCellDoubleClick, highlightedCells] ); + // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ data: rows, columns, @@ -108,7 +109,6 @@ export function ResultsTable({ (colName: string, defaultHandler: ((e: unknown) => void) | undefined) => (e: unknown) => { if (externalSort) { - // Cycle: none → ASC → DESC → none if (externalSort.column !== colName) { externalSort.onSort(colName, "ASC"); } else if (externalSort.direction === "ASC") { @@ -141,16 +141,16 @@ export function ResultsTable({ return (
{/* Header */} -
+
{table.getHeaderGroups().map((headerGroup) => headerGroup.headers.map((header) => (
{flexRender( @@ -158,10 +158,10 @@ export function ResultsTable({ header.getContext() )} {getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && ( - + )} {getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && ( - + )}
{/* Resize handle */} @@ -169,7 +169,7 @@ export function ResultsTable({ onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()} onDoubleClick={() => header.column.resetSize()} - className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary ${ + className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none transition-colors hover:bg-primary/60 ${ header.column.getIsResizing() ? "bg-primary" : "" }`} /> @@ -190,7 +190,7 @@ export function ResultsTable({ return (
{flexRender( diff --git a/src/components/saved-queries/SaveQueryDialog.tsx b/src/components/saved-queries/SaveQueryDialog.tsx index e243dab..bdb27b4 100644 --- a/src/components/saved-queries/SaveQueryDialog.tsx +++ b/src/components/saved-queries/SaveQueryDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -23,9 +23,11 @@ export function SaveQueryDialog({ open, onOpenChange, sql, connectionId }: Props const [name, setName] = useState(""); const saveMutation = useSaveQuery(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) setName(""); - }, [open]); + } const handleSave = () => { if (!name.trim()) { diff --git a/src/components/schema/SchemaTree.tsx b/src/components/schema/SchemaTree.tsx index 4415831..549259b 100644 --- a/src/components/schema/SchemaTree.tsx +++ b/src/components/schema/SchemaTree.tsx @@ -55,7 +55,7 @@ function TableSizeInfo({ item }: { item: SchemaObject }) { if (item.row_count != null) parts.push(formatCount(item.row_count)); if (item.size_bytes != null) parts.push(formatSize(item.size_bytes)); return ( - + {parts.join(", ")} ); @@ -71,15 +71,18 @@ export function SchemaTree() { if (!activeConnectionId) { return ( -
- Connect to a database to browse schema. +
+ +

+ Connect to a database to browse schema +

); } if (!databases || databases.length === 0) { return ( -
+
No databases found.
); @@ -218,22 +221,26 @@ function DatabaseNode({
- {expanded ? ( - - ) : ( - - )} + + {expanded ? ( + + ) : ( + + )} + {name} {isActive && ( - active + + active + )}
@@ -316,7 +323,7 @@ function DatabaseNode({
)} {expanded && !isActive && ( -
+
{isSwitching ? "Switching..." : "Click to switch to this database"}
)} @@ -339,7 +346,7 @@ function SchemasForCurrentDb({ if (!schemas || schemas.length === 0) { return ( -
No schemas found.
+
No schemas found.
); } @@ -379,18 +386,20 @@ function SchemaNode({
setExpanded(!expanded)} > + + {expanded ? ( + + ) : ( + + )} + {expanded ? ( - + ) : ( - - )} - {expanded ? ( - - ) : ( - + )} {schema}
@@ -442,10 +451,10 @@ function SchemaNode({ } const categoryIcons = { - tables: , - views: , - functions: , - sequences: , + tables: , + views: , + functions: , + sequences: , }; function CategoryNode({ @@ -498,18 +507,24 @@ function CategoryNode({ return (
setExpanded(!expanded)} > - {expanded ? ( - - ) : ( - - )} + + {expanded ? ( + + ) : ( + + )} + {icon} - + {label} - {items ? ` (${items.length})` : ""} + {items && ( + + {items.length} + + )}
{expanded && ( @@ -522,12 +537,12 @@ function CategoryNode({
onOpenTable(item.name)} > {icon} - {item.name} + {item.name} {category === "tables" && }
@@ -561,11 +576,11 @@ function CategoryNode({ return (
{icon} - {item.name} + {item.name}
); })} diff --git a/src/components/settings/AppSettingsSheet.tsx b/src/components/settings/AppSettingsSheet.tsx index 65e4b25..ac9f6ec 100644 --- a/src/components/settings/AppSettingsSheet.tsx +++ b/src/components/settings/AppSettingsSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Sheet, SheetContent, @@ -52,21 +52,25 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) { const [copied, setCopied] = useState(false); // Sync form with loaded settings - useEffect(() => { + const [prevAppSettings, setPrevAppSettings] = useState(appSettings); + if (appSettings !== prevAppSettings) { + setPrevAppSettings(appSettings); if (appSettings) { setMcpEnabled(appSettings.mcp.enabled); setMcpPort(appSettings.mcp.port); setDockerHost(appSettings.docker.host); setDockerRemoteUrl(appSettings.docker.remote_url ?? ""); } - }, [appSettings]); + } - useEffect(() => { + const [prevAiSettings, setPrevAiSettings] = useState(aiSettings); + if (aiSettings !== prevAiSettings) { + setPrevAiSettings(aiSettings); if (aiSettings) { setOllamaUrl(aiSettings.ollama_url); setAiModel(aiSettings.model); } - }, [aiSettings]); + } const mcpEndpoint = `http://127.0.0.1:${mcpPort}/mcp`; diff --git a/src/components/snapshots/CreateSnapshotDialog.tsx b/src/components/snapshots/CreateSnapshotDialog.tsx index 17b44bf..5e05187 100644 --- a/src/components/snapshots/CreateSnapshotDialog.tsx +++ b/src/components/snapshots/CreateSnapshotDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -43,7 +43,9 @@ export function CreateSnapshotDialog({ open, onOpenChange, connectionId }: Props const { create, result, error, isCreating, progress, reset } = useCreateSnapshot(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setStep("config"); setName(`snapshot-${new Date().toISOString().slice(0, 10)}`); @@ -51,20 +53,23 @@ export function CreateSnapshotDialog({ open, onOpenChange, connectionId }: Props setIncludeDeps(true); reset(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, reset]); + } - useEffect(() => { + const [prevSchemas, setPrevSchemas] = useState(schemas); + if (schemas !== prevSchemas) { + setPrevSchemas(schemas); if (schemas && schemas.length > 0 && !selectedSchema) { setSelectedSchema(schemas.find((s) => s === "public") || schemas[0]); } - }, [schemas, selectedSchema]); + } - useEffect(() => { + const [prevProgress, setPrevProgress] = useState(progress); + if (progress !== prevProgress) { + setPrevProgress(progress); if (progress?.stage === "done" || progress?.stage === "error") { setStep("done"); } - }, [progress]); + } const handleToggleTable = (tableName: string) => { setSelectedTables((prev) => { diff --git a/src/components/snapshots/RestoreSnapshotDialog.tsx b/src/components/snapshots/RestoreSnapshotDialog.tsx index f713ba5..7c3fe73 100644 --- a/src/components/snapshots/RestoreSnapshotDialog.tsx +++ b/src/components/snapshots/RestoreSnapshotDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -38,7 +38,9 @@ export function RestoreSnapshotDialog({ open, onOpenChange, connectionId }: Prop const readMeta = useReadSnapshotMetadata(); const { restore, rowsRestored, error, isRestoring, progress, reset } = useRestoreSnapshot(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setStep("select"); setFilePath(null); @@ -46,13 +48,15 @@ export function RestoreSnapshotDialog({ open, onOpenChange, connectionId }: Prop setTruncate(false); reset(); } - }, [open, reset]); + } - useEffect(() => { + const [prevProgress, setPrevProgress] = useState(progress); + if (progress !== prevProgress) { + setPrevProgress(progress); if (progress?.stage === "done" || progress?.stage === "error") { setStep("done"); } - }, [progress]); + } const handleSelectFile = async () => { const selected = await openFile({ diff --git a/src/components/table-viewer/TableDataView.tsx b/src/components/table-viewer/TableDataView.tsx index 76730b7..9adf61c 100644 --- a/src/components/table-viewer/TableDataView.tsx +++ b/src/components/table-viewer/TableDataView.tsx @@ -104,7 +104,7 @@ export function TableDataView({ connectionId, schema, table }: Props) { } setIsSaving(true); try { - for (const [_key, change] of pendingChanges) { + for (const [, change] of pendingChanges) { const row = data.rows[change.rowIndex]; const colName = data.columns[change.colIndex]; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index beb56ed..5029afb 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index b5ea4ab..09810ad 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" @@ -5,19 +6,19 @@ import { Slot } from "radix-ui" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-40 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-default", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: "bg-primary text-primary-foreground shadow-sm shadow-primary/20 hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25", destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-destructive text-white shadow-sm shadow-destructive/20 hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + "border border-border/60 bg-transparent shadow-xs hover:bg-accent/50 hover:text-accent-foreground hover:border-border", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + "hover:bg-accent/50 hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 8916905..d0bc39a 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary/20 selection:text-foreground border-border/50 h-9 w-full min-w-0 rounded-md border bg-background/50 px-3 py-1 text-base shadow-xs transition-all duration-150 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40 md:text-sm", + "focus-visible:border-primary/40 focus-visible:ring-primary/15 focus-visible:ring-[3px] focus-visible:bg-background/80", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className )} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 7bf18aa..c2b1f44 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Tabs as TabsPrimitive } from "radix-ui" diff --git a/src/components/workspace/WorkspacePanel.tsx b/src/components/workspace/WorkspacePanel.tsx index 0c954de..c0fcf98 100644 --- a/src/components/workspace/WorkspacePanel.tsx +++ b/src/components/workspace/WorkspacePanel.tsx @@ -255,11 +255,12 @@ export function WorkspacePanel({
-
+ {/* Editor action bar */} +
+ +
+ + {/* AI actions group — purple-branded */} - - - handleExport("csv")}> - Export CSV - - handleExport("json")}> - Export JSON - - - + <> +
+ + + + + + handleExport("csv")}> + Export CSV + + handleExport("json")}> + Export JSON + + + + )} - - Ctrl+Enter to execute + +
+ + + {"\u2318"}Enter {isReadOnly && ( - - - Read-Only + + + READ )}
@@ -389,35 +401,41 @@ export function WorkspacePanel({
{(explainData || result || error || aiExplanation) && ( -
+
{explainData && ( )} {resultView === "results" && result && result.columns.length > 0 && ( -
+