mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
chore: add oxfmt + husky + lint-staged for code formatting and CI format checks
This commit is contained in:
@@ -25,6 +25,9 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Frontend format check
|
||||
run: npx oxfmt --check "src/**/*.{ts,vue}"
|
||||
|
||||
- name: TypeScript check
|
||||
run: npx vue-tsc --noEmit
|
||||
|
||||
@@ -42,6 +45,10 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
|
||||
|
||||
- name: Cargo fmt check
|
||||
working-directory: src-tauri
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Cargo check
|
||||
working-directory: src-tauri
|
||||
run: cargo check
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
npx lint-staged
|
||||
cd src-tauri && cargo fmt && git add -u .
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
+8
-1
@@ -10,7 +10,8 @@
|
||||
"dev:backend": "RUST_LOG=${RUST_LOG:-info} DBX_PASSWORD=${DBX_PASSWORD:-test} cargo watch -x 'run -p dbx-web'",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -53,6 +54,9 @@
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/splitpanes": "^2.2.6",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"oxfmt": "^0.47.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
@@ -62,5 +66,8 @@
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,vue}": "oxfmt"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+373
-8
@@ -110,7 +110,7 @@ importers:
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.4
|
||||
version: 4.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0))
|
||||
version: 4.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4))
|
||||
'@tauri-apps/cli':
|
||||
specifier: ^2.11.0
|
||||
version: 2.11.0
|
||||
@@ -122,7 +122,16 @@ importers:
|
||||
version: 2.2.6
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0))(vue@3.5.33(typescript@5.6.3))
|
||||
version: 5.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4))(vue@3.5.33(typescript@5.6.3))
|
||||
husky:
|
||||
specifier: ^9.1.7
|
||||
version: 9.1.7
|
||||
lint-staged:
|
||||
specifier: ^16.4.0
|
||||
version: 16.4.0
|
||||
oxfmt:
|
||||
specifier: ^0.47.0
|
||||
version: 0.47.0
|
||||
tailwindcss:
|
||||
specifier: ^4.2.4
|
||||
version: 4.2.4
|
||||
@@ -131,7 +140,7 @@ importers:
|
||||
version: 5.6.3
|
||||
vite:
|
||||
specifier: ^6.0.0
|
||||
version: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)
|
||||
version: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4)
|
||||
vue-tsc:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.12(typescript@5.6.3)
|
||||
@@ -625,6 +634,120 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.47.0':
|
||||
resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.47.0':
|
||||
resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.47.0':
|
||||
resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.47.0':
|
||||
resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.47.0':
|
||||
resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
|
||||
resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
|
||||
resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.47.0':
|
||||
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
|
||||
resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
|
||||
resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.47.0':
|
||||
resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.60.2':
|
||||
resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==}
|
||||
cpu: [arm]
|
||||
@@ -1109,6 +1232,10 @@ packages:
|
||||
alien-signals@1.0.13:
|
||||
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
|
||||
|
||||
ansi-escapes@7.3.0:
|
||||
resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1121,6 +1248,10 @@ packages:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -1235,6 +1366,10 @@ packages:
|
||||
resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==}
|
||||
engines: {node: '>=18.20'}
|
||||
|
||||
cli-truncate@5.2.0:
|
||||
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1252,6 +1387,9 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
commander@11.1.0:
|
||||
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -1428,6 +1566,9 @@ packages:
|
||||
electron-to-chromium@1.5.344:
|
||||
resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
@@ -1447,6 +1588,10 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
environment@1.1.0:
|
||||
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1524,6 +1669,9 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
eventsource-parser@3.0.8:
|
||||
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1724,6 +1872,11 @@ packages:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
engines: {node: '>=10.17.0'}
|
||||
|
||||
husky@9.1.7:
|
||||
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1768,6 +1921,10 @@ packages:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1946,6 +2103,15 @@ packages:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
lint-staged@16.4.0:
|
||||
resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==}
|
||||
engines: {node: '>=20.17'}
|
||||
hasBin: true
|
||||
|
||||
listr2@9.0.5:
|
||||
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1963,6 +2129,10 @@ packages:
|
||||
resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
log-update@6.1.0:
|
||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
lru-cache@11.3.5:
|
||||
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
||||
engines: {node: 20 || >=22}
|
||||
@@ -2133,6 +2303,11 @@ packages:
|
||||
resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
oxfmt@0.47.0:
|
||||
resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
p-event@6.0.1:
|
||||
resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==}
|
||||
engines: {node: '>=16.17'}
|
||||
@@ -2416,6 +2591,14 @@ packages:
|
||||
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
slice-ansi@8.0.0:
|
||||
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2453,10 +2636,18 @@ packages:
|
||||
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
string-argv@0.3.2:
|
||||
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string-width@7.2.0:
|
||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
string-width@8.2.1:
|
||||
resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2521,6 +2712,10 @@ packages:
|
||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinypool@2.1.0:
|
||||
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
@@ -2706,6 +2901,10 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
@@ -2716,6 +2915,11 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3256,6 +3460,63 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.60.2':
|
||||
optional: true
|
||||
|
||||
@@ -3396,12 +3657,12 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.4
|
||||
|
||||
'@tailwindcss/vite@4.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0))':
|
||||
'@tailwindcss/vite@4.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.2.4
|
||||
'@tailwindcss/oxide': 4.2.4
|
||||
tailwindcss: 4.2.4
|
||||
vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)
|
||||
vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4)
|
||||
|
||||
'@tanstack/virtual-core@3.14.0': {}
|
||||
|
||||
@@ -3568,9 +3829,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0))(vue@3.5.33(typescript@5.6.3))':
|
||||
'@vitejs/plugin-vue@5.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4))(vue@3.5.33(typescript@5.6.3))':
|
||||
dependencies:
|
||||
vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)
|
||||
vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4)
|
||||
vue: 3.5.33(typescript@5.6.3)
|
||||
|
||||
'@volar/language-core@2.4.15':
|
||||
@@ -3733,6 +3994,10 @@ snapshots:
|
||||
|
||||
alien-signals@1.0.13: {}
|
||||
|
||||
ansi-escapes@7.3.0:
|
||||
dependencies:
|
||||
environment: 1.1.0
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
@@ -3741,6 +4006,8 @@ snapshots:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
@@ -3859,6 +4126,11 @@ snapshots:
|
||||
|
||||
cli-spinners@3.4.0: {}
|
||||
|
||||
cli-truncate@5.2.0:
|
||||
dependencies:
|
||||
slice-ansi: 8.0.0
|
||||
string-width: 8.2.1
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
code-block-writer@13.0.3: {}
|
||||
@@ -3879,6 +4151,8 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
@@ -4014,6 +4288,8 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.344: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
@@ -4027,6 +4303,8 @@ snapshots:
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
environment@1.1.0: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
@@ -4140,6 +4418,8 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
eventsource-parser@3.0.8: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
@@ -4369,6 +4649,8 @@ snapshots:
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -4398,6 +4680,10 @@ snapshots:
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.5.0
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
@@ -4525,6 +4811,24 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
lint-staged@16.4.0:
|
||||
dependencies:
|
||||
commander: 14.0.3
|
||||
listr2: 9.0.5
|
||||
picomatch: 4.0.4
|
||||
string-argv: 0.3.2
|
||||
tinyexec: 1.1.1
|
||||
yaml: 2.8.4
|
||||
|
||||
listr2@9.0.5:
|
||||
dependencies:
|
||||
cli-truncate: 5.2.0
|
||||
colorette: 2.0.20
|
||||
eventemitter3: 5.0.4
|
||||
log-update: 6.1.0
|
||||
rfdc: 1.4.1
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -4540,6 +4844,14 @@ snapshots:
|
||||
is-unicode-supported: 2.1.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
log-update@6.1.0:
|
||||
dependencies:
|
||||
ansi-escapes: 7.3.0
|
||||
cli-cursor: 5.0.0
|
||||
slice-ansi: 7.1.2
|
||||
strip-ansi: 7.2.0
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
lru-cache@11.3.5: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
@@ -4702,6 +5014,30 @@ snapshots:
|
||||
stdin-discarder: 0.3.2
|
||||
string-width: 8.2.1
|
||||
|
||||
oxfmt@0.47.0:
|
||||
dependencies:
|
||||
tinypool: 2.1.0
|
||||
optionalDependencies:
|
||||
'@oxfmt/binding-android-arm-eabi': 0.47.0
|
||||
'@oxfmt/binding-android-arm64': 0.47.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.47.0
|
||||
'@oxfmt/binding-darwin-x64': 0.47.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.47.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.47.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.47.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.47.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.47.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.47.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.47.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.47.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.47.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.47.0
|
||||
|
||||
p-event@6.0.1:
|
||||
dependencies:
|
||||
p-timeout: 6.1.4
|
||||
@@ -5058,6 +5394,16 @@ snapshots:
|
||||
astral-regex: 2.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
is-fullwidth-code-point: 5.1.0
|
||||
|
||||
slice-ansi@8.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
is-fullwidth-code-point: 5.1.0
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-resolve@0.6.0:
|
||||
@@ -5084,12 +5430,20 @@ snapshots:
|
||||
|
||||
stdin-discarder@0.3.2: {}
|
||||
|
||||
string-argv@0.3.2: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 10.6.0
|
||||
get-east-asian-width: 1.5.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string-width@8.2.1:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.5.0
|
||||
@@ -5162,6 +5516,8 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinypool@2.1.0: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
@@ -5217,7 +5573,7 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0):
|
||||
vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
@@ -5231,6 +5587,7 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.32.0
|
||||
stylus: 0.57.0
|
||||
yaml: 2.8.4
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
@@ -5323,6 +5680,12 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
@@ -5331,6 +5694,8 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@2.8.4: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yocto-spinner@1.1.0:
|
||||
|
||||
+245
-137
@@ -41,10 +41,18 @@ const settingsStore = useSettingsStore();
|
||||
const { message: toastMessage, visible: toastVisible, toast } = useToast();
|
||||
const { isDark, applyTheme, toggleTheme } = useTheme();
|
||||
const {
|
||||
checkingUpdates, updateInfo, updateCheckMessage, showUpdateDialog,
|
||||
isDownloadingUpdate, downloadProgress, updateReady,
|
||||
openUrl, checkUpdates, openLatestRelease,
|
||||
downloadAndInstallUpdate, restartApp,
|
||||
checkingUpdates,
|
||||
updateInfo,
|
||||
updateCheckMessage,
|
||||
showUpdateDialog,
|
||||
isDownloadingUpdate,
|
||||
downloadProgress,
|
||||
updateReady,
|
||||
openUrl,
|
||||
checkUpdates,
|
||||
openLatestRelease,
|
||||
downloadAndInstallUpdate,
|
||||
restartApp,
|
||||
} = useAppUpdater();
|
||||
const { setupFileDrop } = useFileDrop();
|
||||
|
||||
@@ -56,10 +64,8 @@ const showConnectionDialog = ref(false);
|
||||
const showSettingsDialog = ref(false);
|
||||
const showHistory = ref(false);
|
||||
const showAiPanel = ref(localStorage.getItem("dbx-ai-panel-open") !== "false");
|
||||
const {
|
||||
sidebarWidth, aiPanelWidth, historyWidth,
|
||||
startSidebarResize, startAiPanelResize, startHistoryResize,
|
||||
} = usePanelResize();
|
||||
const { sidebarWidth, aiPanelWidth, historyWidth, startSidebarResize, startAiPanelResize, startHistoryResize } =
|
||||
usePanelResize();
|
||||
const aiAssistantRef = ref<InstanceType<typeof AiAssistant> | null>(null);
|
||||
|
||||
const selectedSql = ref("");
|
||||
@@ -67,9 +73,7 @@ const cursorPos = ref(0);
|
||||
const formatSqlRequestId = ref(0);
|
||||
const activeOutputView = ref<"result" | "explain">("result");
|
||||
|
||||
const activeTab = computed(() =>
|
||||
queryStore.tabs.find((t) => t.id === queryStore.activeTabId)
|
||||
);
|
||||
const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
|
||||
|
||||
const activeConnection = computed(() => {
|
||||
const tab = activeTab.value;
|
||||
@@ -78,16 +82,17 @@ const activeConnection = computed(() => {
|
||||
|
||||
const executableSql = computed(() => {
|
||||
const tab = activeTab.value;
|
||||
return tab ? resolveExecutableSql(tab.sql, selectedSql.value, {
|
||||
mode: settingsStore.editorSettings.executeMode,
|
||||
cursorPos: cursorPos.value,
|
||||
}) : "";
|
||||
return tab
|
||||
? resolveExecutableSql(tab.sql, selectedSql.value, {
|
||||
mode: settingsStore.editorSettings.executeMode,
|
||||
cursorPos: cursorPos.value,
|
||||
})
|
||||
: "";
|
||||
});
|
||||
|
||||
const {
|
||||
dangerSql, showDangerDialog,
|
||||
tryExecute, cancelActiveExecution, tryExplain, onDangerConfirm,
|
||||
} = useSqlExecution({ activeTab, activeConnection, executableSql, activeOutputView });
|
||||
const { dangerSql, showDangerDialog, tryExecute, cancelActiveExecution, tryExplain, onDangerConfirm } = useSqlExecution(
|
||||
{ activeTab, activeConnection, executableSql, activeOutputView },
|
||||
);
|
||||
|
||||
const dialogs = useDialogSources();
|
||||
const { getDatabaseOptions } = useDatabaseOptions();
|
||||
@@ -99,7 +104,7 @@ const { setupTauriListeners } = useTauriEvents({ openTableTarget });
|
||||
const appVersion = ref("");
|
||||
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
|
||||
const hasSqlFileConnections = computed(() =>
|
||||
connectionStore.connections.some((c) => !sqlFileUnsupportedTypes.has(c.db_type))
|
||||
connectionStore.connections.some((c) => !sqlFileUnsupportedTypes.has(c.db_type)),
|
||||
);
|
||||
const connectionStats = computed(() => ({
|
||||
total: connectionStore.connections.length,
|
||||
@@ -108,10 +113,13 @@ const connectionStats = computed(() => ({
|
||||
}));
|
||||
const recentConnections = computed(() => connectionStore.connections.slice(0, 5));
|
||||
|
||||
watch(() => queryStore.activeTabId, () => {
|
||||
selectedSql.value = "";
|
||||
activeOutputView.value = "result";
|
||||
});
|
||||
watch(
|
||||
() => queryStore.activeTabId,
|
||||
() => {
|
||||
selectedSql.value = "";
|
||||
activeOutputView.value = "result";
|
||||
},
|
||||
);
|
||||
|
||||
function toggleAiPanel() {
|
||||
showAiPanel.value = !showAiPanel.value;
|
||||
@@ -139,7 +147,10 @@ async function saveSqlToFile() {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({ defaultPath: `${tab.title}.sql`, filters: [{ name: "SQL", extensions: ["sql"] }] });
|
||||
const path = await save({
|
||||
defaultPath: `${tab.title}.sql`,
|
||||
filters: [{ name: "SQL", extensions: ["sql"] }],
|
||||
});
|
||||
if (path) {
|
||||
await writeTextFile(path, tab.sql);
|
||||
toast(t("toolbar.sqlSaved"), 2000);
|
||||
@@ -224,9 +235,15 @@ function changeActiveDatabase(database: string) {
|
||||
if (tab) queryStore.updateDatabase(tab.id, database);
|
||||
}
|
||||
|
||||
function toggleLocale() { setLocale(currentLocale() === "zh-CN" ? "en" : "zh-CN"); }
|
||||
function openGitHub() { openUrl("https://github.com/t8y2/dbx"); }
|
||||
function openMcpGuide() { openUrl("https://github.com/t8y2/dbx/blob/main/docs/mcp-guide.md"); }
|
||||
function toggleLocale() {
|
||||
setLocale(currentLocale() === "zh-CN" ? "en" : "zh-CN");
|
||||
}
|
||||
function openGitHub() {
|
||||
openUrl("https://github.com/t8y2/dbx");
|
||||
}
|
||||
function openMcpGuide() {
|
||||
openUrl("https://github.com/t8y2/dbx/blob/main/docs/mcp-guide.md");
|
||||
}
|
||||
|
||||
function ensureQueryTab(): string {
|
||||
const tab = activeTab.value;
|
||||
@@ -253,8 +270,12 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
if (queryStore.activeTabId) queryStore.closeTab(queryStore.activeTabId);
|
||||
return;
|
||||
}
|
||||
if (activeTab.value?.mode === "query" && isExecuteSqlShortcut(e)
|
||||
&& e.target instanceof Element && e.target.closest("[data-query-editor-root]")) {
|
||||
if (
|
||||
activeTab.value?.mode === "query" &&
|
||||
isExecuteSqlShortcut(e) &&
|
||||
e.target instanceof Element &&
|
||||
e.target.closest("[data-query-editor-root]")
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
tryExecute();
|
||||
@@ -268,17 +289,20 @@ function onLoginSuccess() {
|
||||
}
|
||||
|
||||
function initApp() {
|
||||
connectionStore.initFromDisk().then(() => {
|
||||
reconnectRestoredTabs();
|
||||
}).catch((e: any) => {
|
||||
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
|
||||
});
|
||||
connectionStore
|
||||
.initFromDisk()
|
||||
.then(() => {
|
||||
reconnectRestoredTabs();
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
|
||||
});
|
||||
settingsStore.initAiConfig();
|
||||
}
|
||||
|
||||
async function reconnectRestoredTabs() {
|
||||
if (isDesktop) return;
|
||||
const connectionIds = new Set(queryStore.tabs.map(t => t.connectionId).filter(Boolean));
|
||||
const connectionIds = new Set(queryStore.tabs.map((t) => t.connectionId).filter(Boolean));
|
||||
for (const id of connectionIds) {
|
||||
try {
|
||||
await connectionStore.ensureConnected(id);
|
||||
@@ -300,136 +324,220 @@ onMounted(async () => {
|
||||
const data = await res.json();
|
||||
needsAuth.value = data.required;
|
||||
authenticated.value = data.authenticated;
|
||||
} catch { /* server unreachable */ }
|
||||
} catch {
|
||||
/* server unreachable */
|
||||
}
|
||||
if (needsAuth.value && !authenticated.value) {
|
||||
history.replaceState(null, "", "/login");
|
||||
}
|
||||
if (!needsAuth.value || authenticated.value) initApp();
|
||||
api.checkForUpdates().then((info) => { appVersion.value = info.current_version; }).catch(() => {});
|
||||
api
|
||||
.checkForUpdates()
|
||||
.then((info) => {
|
||||
appVersion.value = info.current_version;
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
initApp();
|
||||
setupFileDrop().catch(() => {});
|
||||
checkUpdates({ silent: true });
|
||||
import("@tauri-apps/api/app").then(({ getVersion }) => {
|
||||
getVersion().then((v) => { appVersion.value = v; }).catch(() => {});
|
||||
}).catch(() => {});
|
||||
import("@tauri-apps/api/app")
|
||||
.then(({ getVersion }) => {
|
||||
getVersion()
|
||||
.then((v) => {
|
||||
appVersion.value = v;
|
||||
})
|
||||
.catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
setupTauriListeners();
|
||||
});
|
||||
|
||||
onUnmounted(() => { window.removeEventListener("keydown", handleKeydown, true); });
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", handleKeydown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginPage v-if="needsAuth && !authenticated" @authenticated="onLoginSuccess" />
|
||||
<div v-show="!needsAuth || authenticated">
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<div class="h-screen w-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<AppToolbar
|
||||
:is-dark="isDark" :show-ai-panel="showAiPanel" :show-history="showHistory"
|
||||
:checking-updates="checkingUpdates"
|
||||
:has-connections="connectionStore.connections.length > 0"
|
||||
:has-sql-file-connections="hasSqlFileConnections"
|
||||
@new-connection="showConnectionDialog = true" @new-query="newQuery"
|
||||
@toggle-theme="toggleTheme" @toggle-locale="toggleLocale"
|
||||
@toggle-ai="toggleAiPanel" @toggle-history="showHistory = !showHistory"
|
||||
@open-github="openGitHub" @open-settings="showSettingsDialog = true"
|
||||
@check-updates="checkUpdates()"
|
||||
@open-transfer="dialogs.showTransferDialog.value = true"
|
||||
@open-sql-file="dialogs.showSqlFileDialog.value = true"
|
||||
/>
|
||||
|
||||
<div class="flex-1 flex min-h-0">
|
||||
<AppSidebar
|
||||
:sidebar-width="sidebarWidth"
|
||||
@import="dialogs.onImportClick" @export="dialogs.onExportClick"
|
||||
@start-resize="startSidebarResize"
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<div class="h-screen w-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<AppToolbar
|
||||
:is-dark="isDark"
|
||||
:show-ai-panel="showAiPanel"
|
||||
:show-history="showHistory"
|
||||
:checking-updates="checkingUpdates"
|
||||
:has-connections="connectionStore.connections.length > 0"
|
||||
:has-sql-file-connections="hasSqlFileConnections"
|
||||
@new-connection="showConnectionDialog = true"
|
||||
@new-query="newQuery"
|
||||
@toggle-theme="toggleTheme"
|
||||
@toggle-locale="toggleLocale"
|
||||
@toggle-ai="toggleAiPanel"
|
||||
@toggle-history="showHistory = !showHistory"
|
||||
@open-github="openGitHub"
|
||||
@open-settings="showSettingsDialog = true"
|
||||
@check-updates="checkUpdates()"
|
||||
@open-transfer="dialogs.showTransferDialog.value = true"
|
||||
@open-sql-file="dialogs.showSqlFileDialog.value = true"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="h-full flex flex-col min-w-0">
|
||||
<AppTabBar />
|
||||
<div v-if="activeTab" class="flex flex-col flex-1 min-h-0">
|
||||
<EditorToolbar
|
||||
v-if="activeTab.mode === 'query' && !isPreviewTab(activeTab)"
|
||||
:active-tab="activeTab" :active-connection="activeConnection" :executable-sql="executableSql"
|
||||
@execute="tryExecute()" @cancel="cancelActiveExecution()" @explain="tryExplain()"
|
||||
@format-sql="formatActiveSql"
|
||||
@save-sql="saveSqlToFile" @open-sql="openSqlFile"
|
||||
@change-connection="changeActiveConnection" @change-database="changeActiveDatabase"
|
||||
/>
|
||||
<ContentArea
|
||||
:active-tab="activeTab" :active-connection="activeConnection" :executable-sql="executableSql"
|
||||
:active-output-view="activeOutputView" :format-sql-request-id="formatSqlRequestId"
|
||||
:selected-sql="selectedSql" :cursor-pos="cursorPos"
|
||||
@update:active-output-view="activeOutputView = $event"
|
||||
@fix-with-ai="fixWithAi" @execute="tryExecute()" @cancel="cancelActiveExecution()"
|
||||
@explain="tryExplain()"
|
||||
@editor-update="(v: string) => { if (queryStore.activeTabId) queryStore.updateSql(queryStore.activeTabId, v) }"
|
||||
@editor-selection-change="(v: string) => selectedSql = v"
|
||||
@editor-cursor-change="(p: number) => cursorPos = p"
|
||||
@format-error="toast(t('toolbar.formatSqlFailed'))"
|
||||
@reload="onReloadData" @paginate="onPaginate" @sort="onSort" @execute-sql="onExecuteSql"
|
||||
<div class="flex-1 flex min-h-0">
|
||||
<AppSidebar
|
||||
:sidebar-width="sidebarWidth"
|
||||
@import="dialogs.onImportClick"
|
||||
@export="dialogs.onExportClick"
|
||||
@start-resize="startSidebarResize"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="h-full flex flex-col min-w-0">
|
||||
<AppTabBar />
|
||||
<div v-if="activeTab" class="flex flex-col flex-1 min-h-0">
|
||||
<EditorToolbar
|
||||
v-if="activeTab.mode === 'query' && !isPreviewTab(activeTab)"
|
||||
:active-tab="activeTab"
|
||||
:active-connection="activeConnection"
|
||||
:executable-sql="executableSql"
|
||||
@execute="tryExecute()"
|
||||
@cancel="cancelActiveExecution()"
|
||||
@explain="tryExplain()"
|
||||
@format-sql="formatActiveSql"
|
||||
@save-sql="saveSqlToFile"
|
||||
@open-sql="openSqlFile"
|
||||
@change-connection="changeActiveConnection"
|
||||
@change-database="changeActiveDatabase"
|
||||
/>
|
||||
<ContentArea
|
||||
:active-tab="activeTab"
|
||||
:active-connection="activeConnection"
|
||||
:executable-sql="executableSql"
|
||||
:active-output-view="activeOutputView"
|
||||
:format-sql-request-id="formatSqlRequestId"
|
||||
:selected-sql="selectedSql"
|
||||
:cursor-pos="cursorPos"
|
||||
@update:active-output-view="activeOutputView = $event"
|
||||
@fix-with-ai="fixWithAi"
|
||||
@execute="tryExecute()"
|
||||
@cancel="cancelActiveExecution()"
|
||||
@explain="tryExplain()"
|
||||
@editor-update="
|
||||
(v: string) => {
|
||||
if (queryStore.activeTabId) queryStore.updateSql(queryStore.activeTabId, v);
|
||||
}
|
||||
"
|
||||
@editor-selection-change="(v: string) => (selectedSql = v)"
|
||||
@editor-cursor-change="(p: number) => (cursorPos = p)"
|
||||
@format-error="toast(t('toolbar.formatSqlFailed'))"
|
||||
@reload="onReloadData"
|
||||
@paginate="onPaginate"
|
||||
@sort="onSort"
|
||||
@execute-sql="onExecuteSql"
|
||||
/>
|
||||
</div>
|
||||
<WelcomeScreen
|
||||
v-else
|
||||
:connection-stats="connectionStats"
|
||||
:recent-connections="recentConnections"
|
||||
:app-version="appVersion"
|
||||
:has-connections="connectionStore.connections.length > 0"
|
||||
@open-connection-query="openConnectionQuery"
|
||||
@new-connection="showConnectionDialog = true"
|
||||
@new-query="newQuery"
|
||||
@show-history="showHistory = true"
|
||||
@import-config="dialogs.onImportClick"
|
||||
@open-github="openGitHub"
|
||||
@open-mcp-guide="openMcpGuide"
|
||||
/>
|
||||
</div>
|
||||
<WelcomeScreen
|
||||
v-else
|
||||
:connection-stats="connectionStats" :recent-connections="recentConnections"
|
||||
:app-version="appVersion" :has-connections="connectionStore.connections.length > 0"
|
||||
@open-connection-query="openConnectionQuery" @new-connection="showConnectionDialog = true"
|
||||
@new-query="newQuery" @show-history="showHistory = true"
|
||||
@import-config="dialogs.onImportClick" @open-github="openGitHub" @open-mcp-guide="openMcpGuide"
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showAiPanel"
|
||||
class="h-full shrink-0 relative bg-background"
|
||||
:style="{ width: aiPanelWidth + 'px' }"
|
||||
>
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden">
|
||||
<AiAssistant
|
||||
ref="aiAssistantRef"
|
||||
:tab="activeTab"
|
||||
:connection="activeConnection"
|
||||
@replace-sql="onAiReplaceSql"
|
||||
@execute-sql="onAiExecuteSql"
|
||||
@close="toggleAiPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showHistory"
|
||||
class="h-full shrink-0 relative bg-background"
|
||||
:style="{ width: historyWidth + 'px' }"
|
||||
>
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
|
||||
<QueryHistory
|
||||
@restore="
|
||||
(sql: string) => {
|
||||
if (queryStore.activeTabId) queryStore.updateSql(queryStore.activeTabId, sql);
|
||||
}
|
||||
"
|
||||
@close="showHistory = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAiPanel" class="h-full shrink-0 relative bg-background" :style="{ width: aiPanelWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden">
|
||||
<AiAssistant ref="aiAssistantRef" :tab="activeTab" :connection="activeConnection"
|
||||
@replace-sql="onAiReplaceSql"
|
||||
@execute-sql="onAiExecuteSql" @close="toggleAiPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AppDialogs
|
||||
:show-connection-dialog="showConnectionDialog"
|
||||
:show-settings-dialog="showSettingsDialog"
|
||||
:show-danger-dialog="showDangerDialog"
|
||||
:danger-sql="dangerSql"
|
||||
@update:show-connection-dialog="showConnectionDialog = $event"
|
||||
@update:show-settings-dialog="showSettingsDialog = $event"
|
||||
@update:show-danger-dialog="showDangerDialog = $event"
|
||||
@danger-confirm="onDangerConfirm"
|
||||
@connect-started="(name: string) => toast(t('connection.connecting', { name }), 30000)"
|
||||
@connect-succeeded="(name: string) => toast(t('connection.connectSuccess', { name }), 2000)"
|
||||
@connect-failed="(msg: string) => toast(t('connection.connectFailed', { message: msg }), 5000)"
|
||||
@structure-editor-saved="onStructureEditorSaved(onReloadData, toast)"
|
||||
@open-lineage-target="openLineageTarget"
|
||||
@open-database-search-target="openDatabaseSearchTarget"
|
||||
/>
|
||||
<UpdateDialog
|
||||
v-model:open="showUpdateDialog"
|
||||
:update-info="updateInfo"
|
||||
:update-check-message="updateCheckMessage"
|
||||
:is-downloading-update="isDownloadingUpdate"
|
||||
:download-progress="downloadProgress"
|
||||
:update-ready="updateReady"
|
||||
@open-latest-release="openLatestRelease"
|
||||
@download-and-install="downloadAndInstallUpdate"
|
||||
@restart="restartApp"
|
||||
/>
|
||||
|
||||
<div v-if="showHistory" class="h-full shrink-0 relative bg-background" :style="{ width: historyWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
|
||||
<QueryHistory @restore="(sql: string) => { if (queryStore.activeTabId) queryStore.updateSql(queryStore.activeTabId, sql) }" @close="showHistory = false" />
|
||||
</div>
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg"
|
||||
>
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<AppDialogs
|
||||
:show-connection-dialog="showConnectionDialog" :show-settings-dialog="showSettingsDialog"
|
||||
:show-danger-dialog="showDangerDialog" :danger-sql="dangerSql"
|
||||
@update:show-connection-dialog="showConnectionDialog = $event"
|
||||
@update:show-settings-dialog="showSettingsDialog = $event"
|
||||
@update:show-danger-dialog="showDangerDialog = $event"
|
||||
@danger-confirm="onDangerConfirm"
|
||||
@connect-started="(name: string) => toast(t('connection.connecting', { name }), 30000)"
|
||||
@connect-succeeded="(name: string) => toast(t('connection.connectSuccess', { name }), 2000)"
|
||||
@connect-failed="(msg: string) => toast(t('connection.connectFailed', { message: msg }), 5000)"
|
||||
@structure-editor-saved="onStructureEditorSaved(onReloadData, toast)"
|
||||
@open-lineage-target="openLineageTarget"
|
||||
@open-database-search-target="openDatabaseSearchTarget"
|
||||
/>
|
||||
<UpdateDialog
|
||||
v-model:open="showUpdateDialog" :update-info="updateInfo" :update-check-message="updateCheckMessage"
|
||||
:is-downloading-update="isDownloadingUpdate" :download-progress="downloadProgress" :update-ready="updateReady"
|
||||
@open-latest-release="openLatestRelease" @download-and-install="downloadAndInstallUpdate" @restart="restartApp"
|
||||
/>
|
||||
|
||||
<Transition name="toast">
|
||||
<div v-if="toastVisible" class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg">
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active, .toast-leave-active { transition: all 0.25s ease; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 8px); }
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,9 @@ async function login() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center h-screen bg-gradient-to-br from-background via-background to-blue-950/20">
|
||||
<div
|
||||
class="flex items-center justify-center h-screen bg-gradient-to-br from-background via-background to-blue-950/20"
|
||||
>
|
||||
<div class="w-[360px] space-y-8">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<img src="/logo.png" alt="DBX" class="w-20 h-20 rounded-2xl shadow-lg shadow-blue-500/20" />
|
||||
@@ -63,9 +65,7 @@ async function login() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-xs text-muted-foreground/50">
|
||||
Powered by DBX
|
||||
</p>
|
||||
<p class="text-center text-xs text-muted-foreground/50">Powered by DBX</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
"confirm": [passphrase: string];
|
||||
confirm: [passphrase: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -61,17 +61,17 @@ const displayError = computed(() => error.value || props.externalError || "");
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Lock class="h-5 w-5" />
|
||||
{{ mode === 'export' ? t('configExport.passphraseTitle') : t('configExport.passphraseImportTitle') }}
|
||||
{{ mode === "export" ? t("configExport.passphraseTitle") : t("configExport.passphraseImportTitle") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ mode === 'export' ? t('configExport.passphraseExportHint') : t('configExport.passphraseImportHint') }}
|
||||
{{ mode === "export" ? t("configExport.passphraseExportHint") : t("configExport.passphraseImportHint") }}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('configExport.passphrase') }}</Label>
|
||||
<Label>{{ t("configExport.passphrase") }}</Label>
|
||||
<Input
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
@@ -81,7 +81,7 @@ const displayError = computed(() => error.value || props.externalError || "");
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'export'" class="grid gap-2">
|
||||
<Label>{{ t('configExport.passphraseConfirm') }}</Label>
|
||||
<Label>{{ t("configExport.passphraseConfirm") }}</Label>
|
||||
<Input
|
||||
v-model="passphraseConfirm"
|
||||
type="password"
|
||||
@@ -95,7 +95,7 @@ const displayError = computed(() => error.value || props.externalError || "");
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="confirm">
|
||||
{{ mode === 'export' ? t('configExport.exportEncrypted') : t('configExport.decryptImport') }}
|
||||
{{ mode === "export" ? t("configExport.exportEncrypted") : t("configExport.decryptImport") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -88,34 +85,104 @@ const colorOptions = [
|
||||
{ value: "#a855f7", class: "bg-purple-500", labelKey: "connection.colorPurple" },
|
||||
];
|
||||
|
||||
const driverProfiles: Record<string, { type: DatabaseType; port: number; user: string; label: string; icon: string; urlParams?: string }> = {
|
||||
mysql: { type: "mysql", port: 3306, user: "root", label: "MySQL", icon: "mysql", urlParams: "" },
|
||||
postgres: { type: "postgres", port: 5432, user: "postgres", label: "PostgreSQL", icon: "postgres", urlParams: "" },
|
||||
redis: { type: "redis", port: 6379, user: "", label: "Redis", icon: "redis" },
|
||||
sqlite: { type: "sqlite", port: 0, user: "", label: "SQLite", icon: "sqlite" },
|
||||
duckdb: { type: "duckdb", port: 0, user: "", label: "DuckDB", icon: "duckdb" },
|
||||
mongodb: { type: "mongodb", port: 27017, user: "", label: "MongoDB", icon: "mongodb" },
|
||||
clickhouse: { type: "clickhouse", port: 8123, user: "default", label: "ClickHouse", icon: "clickhouse" },
|
||||
sqlserver: { type: "sqlserver", port: 1433, user: "sa", label: "SQL Server", icon: "sqlserver" },
|
||||
oracle: { type: "oracle", port: 1521, user: "system", label: "Oracle", icon: "oracle" },
|
||||
elasticsearch: { type: "elasticsearch", port: 9200, user: "", label: "Elasticsearch", icon: "elasticsearch" },
|
||||
mariadb: { type: "mysql", port: 3306, user: "root", label: "MariaDB", icon: "mariadb" },
|
||||
tidb: { type: "mysql", port: 4000, user: "root", label: "TiDB", icon: "tidb" },
|
||||
oceanbase: { type: "mysql", port: 2881, user: "root", label: "OceanBase", icon: "oceanbase" },
|
||||
goldendb: { type: "mysql", port: 3306, user: "root", label: "GoldenDB", icon: "goldendb" },
|
||||
opengauss: { type: "postgres", port: 5432, user: "gaussdb", label: "openGauss", icon: "opengauss", urlParams: "sslmode=disable" },
|
||||
gaussdb: { type: "postgres", port: 5432, user: "gaussdb", label: "GaussDB", icon: "gaussdb" },
|
||||
kingbase: { type: "postgres", port: 54321, user: "system", label: "KingBase", icon: "kingbase" },
|
||||
vastbase: { type: "postgres", port: 5432, user: "vastbase", label: "Vastbase", icon: "vastbase" },
|
||||
doris: { type: "mysql", port: 9030, user: "root", label: "Doris", icon: "doris", urlParams: "" },
|
||||
selectdb: { type: "mysql", port: 9030, user: "root", label: "SelectDB", icon: "selectdb", urlParams: "" },
|
||||
starrocks: { type: "mysql", port: 9030, user: "root", label: "StarRocks", icon: "starrocks", urlParams: "" },
|
||||
redshift: { type: "postgres", port: 5439, user: "awsuser", label: "Redshift", icon: "redshift" },
|
||||
cockroachdb:{ type: "postgres", port: 26257, user: "root", label: "CockroachDB", icon: "cockroachdb" },
|
||||
dm: { type: "postgres", port: 5236, user: "SYSDBA", label: "DM (Dameng)", icon: "dm" },
|
||||
tdengine: { type: "mysql", port: 6030, user: "root", label: "TDengine", icon: "tdengine" },
|
||||
custom_mysql: { type: "mysql", port: 3306, user: "root", label: "Custom", icon: "mysql", urlParams: "" },
|
||||
custom_postgres: { type: "postgres", port: 5432, user: "postgres", label: "Custom", icon: "postgres", urlParams: "" },
|
||||
const driverProfiles: Record<
|
||||
string,
|
||||
{
|
||||
type: DatabaseType;
|
||||
port: number;
|
||||
user: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
urlParams?: string;
|
||||
}
|
||||
> = {
|
||||
mysql: { type: "mysql", port: 3306, user: "root", label: "MySQL", icon: "mysql", urlParams: "" },
|
||||
postgres: {
|
||||
type: "postgres",
|
||||
port: 5432,
|
||||
user: "postgres",
|
||||
label: "PostgreSQL",
|
||||
icon: "postgres",
|
||||
urlParams: "",
|
||||
},
|
||||
redis: { type: "redis", port: 6379, user: "", label: "Redis", icon: "redis" },
|
||||
sqlite: { type: "sqlite", port: 0, user: "", label: "SQLite", icon: "sqlite" },
|
||||
duckdb: { type: "duckdb", port: 0, user: "", label: "DuckDB", icon: "duckdb" },
|
||||
mongodb: { type: "mongodb", port: 27017, user: "", label: "MongoDB", icon: "mongodb" },
|
||||
clickhouse: {
|
||||
type: "clickhouse",
|
||||
port: 8123,
|
||||
user: "default",
|
||||
label: "ClickHouse",
|
||||
icon: "clickhouse",
|
||||
},
|
||||
sqlserver: { type: "sqlserver", port: 1433, user: "sa", label: "SQL Server", icon: "sqlserver" },
|
||||
oracle: { type: "oracle", port: 1521, user: "system", label: "Oracle", icon: "oracle" },
|
||||
elasticsearch: {
|
||||
type: "elasticsearch",
|
||||
port: 9200,
|
||||
user: "",
|
||||
label: "Elasticsearch",
|
||||
icon: "elasticsearch",
|
||||
},
|
||||
mariadb: { type: "mysql", port: 3306, user: "root", label: "MariaDB", icon: "mariadb" },
|
||||
tidb: { type: "mysql", port: 4000, user: "root", label: "TiDB", icon: "tidb" },
|
||||
oceanbase: { type: "mysql", port: 2881, user: "root", label: "OceanBase", icon: "oceanbase" },
|
||||
goldendb: { type: "mysql", port: 3306, user: "root", label: "GoldenDB", icon: "goldendb" },
|
||||
opengauss: {
|
||||
type: "postgres",
|
||||
port: 5432,
|
||||
user: "gaussdb",
|
||||
label: "openGauss",
|
||||
icon: "opengauss",
|
||||
urlParams: "sslmode=disable",
|
||||
},
|
||||
gaussdb: { type: "postgres", port: 5432, user: "gaussdb", label: "GaussDB", icon: "gaussdb" },
|
||||
kingbase: { type: "postgres", port: 54321, user: "system", label: "KingBase", icon: "kingbase" },
|
||||
vastbase: { type: "postgres", port: 5432, user: "vastbase", label: "Vastbase", icon: "vastbase" },
|
||||
doris: { type: "mysql", port: 9030, user: "root", label: "Doris", icon: "doris", urlParams: "" },
|
||||
selectdb: {
|
||||
type: "mysql",
|
||||
port: 9030,
|
||||
user: "root",
|
||||
label: "SelectDB",
|
||||
icon: "selectdb",
|
||||
urlParams: "",
|
||||
},
|
||||
starrocks: {
|
||||
type: "mysql",
|
||||
port: 9030,
|
||||
user: "root",
|
||||
label: "StarRocks",
|
||||
icon: "starrocks",
|
||||
urlParams: "",
|
||||
},
|
||||
redshift: { type: "postgres", port: 5439, user: "awsuser", label: "Redshift", icon: "redshift" },
|
||||
cockroachdb: {
|
||||
type: "postgres",
|
||||
port: 26257,
|
||||
user: "root",
|
||||
label: "CockroachDB",
|
||||
icon: "cockroachdb",
|
||||
},
|
||||
dm: { type: "postgres", port: 5236, user: "SYSDBA", label: "DM (Dameng)", icon: "dm" },
|
||||
tdengine: { type: "mysql", port: 6030, user: "root", label: "TDengine", icon: "tdengine" },
|
||||
custom_mysql: {
|
||||
type: "mysql",
|
||||
port: 3306,
|
||||
user: "root",
|
||||
label: "Custom",
|
||||
icon: "mysql",
|
||||
urlParams: "",
|
||||
},
|
||||
custom_postgres: {
|
||||
type: "postgres",
|
||||
port: 5432,
|
||||
user: "postgres",
|
||||
label: "Custom",
|
||||
icon: "postgres",
|
||||
urlParams: "",
|
||||
},
|
||||
};
|
||||
|
||||
function profileForConfig(config: ConnectionConfig) {
|
||||
@@ -139,7 +206,7 @@ function applyProfile(val: string, preserveConnectionFields = false) {
|
||||
form.value.db_type = profile.type;
|
||||
form.value.driver_profile = val;
|
||||
form.value.driver_label = isCustomCompatibleProfile()
|
||||
? (customDriverName.value.trim() || profile.label)
|
||||
? customDriverName.value.trim() || profile.label
|
||||
: profile.label;
|
||||
|
||||
if (!preserveConnectionFields) {
|
||||
@@ -149,51 +216,59 @@ function applyProfile(val: string, preserveConnectionFields = false) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.editConfig, (config) => {
|
||||
if (config) {
|
||||
const profile = profileForConfig(config);
|
||||
editingId.value = config.id;
|
||||
form.value = {
|
||||
name: config.name,
|
||||
db_type: config.db_type,
|
||||
driver_profile: profile,
|
||||
driver_label: config.driver_label || driverProfiles[profile]?.label || config.db_type,
|
||||
url_params: config.url_params || "",
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
color: config.color || "",
|
||||
ssh_enabled: config.ssh_enabled || false,
|
||||
ssh_host: config.ssh_host || "",
|
||||
ssh_port: config.ssh_port || 22,
|
||||
ssh_user: config.ssh_user || "",
|
||||
ssh_password: config.ssh_password || "",
|
||||
ssh_key_path: config.ssh_key_path || "",
|
||||
ssh_key_passphrase: config.ssh_key_passphrase || "",
|
||||
ssh_expose_lan: config.ssh_expose_lan || false,
|
||||
ssl: config.ssl || false,
|
||||
connection_string: config.connection_string,
|
||||
};
|
||||
selectedType.value = profile;
|
||||
mongoUseUrl.value = !!config.connection_string;
|
||||
customDriverName.value = isCustomCompatibleProfile() ? (config.driver_label || "") : "";
|
||||
dialogStep.value = "config";
|
||||
configTab.value = "connection";
|
||||
} else {
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
selectedType.value = "mysql";
|
||||
customDriverName.value = "";
|
||||
dialogStep.value = "select";
|
||||
configTab.value = "connection";
|
||||
}
|
||||
resetTestState();
|
||||
});
|
||||
watch(
|
||||
() => props.editConfig,
|
||||
(config) => {
|
||||
if (config) {
|
||||
const profile = profileForConfig(config);
|
||||
editingId.value = config.id;
|
||||
form.value = {
|
||||
name: config.name,
|
||||
db_type: config.db_type,
|
||||
driver_profile: profile,
|
||||
driver_label: config.driver_label || driverProfiles[profile]?.label || config.db_type,
|
||||
url_params: config.url_params || "",
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
color: config.color || "",
|
||||
ssh_enabled: config.ssh_enabled || false,
|
||||
ssh_host: config.ssh_host || "",
|
||||
ssh_port: config.ssh_port || 22,
|
||||
ssh_user: config.ssh_user || "",
|
||||
ssh_password: config.ssh_password || "",
|
||||
ssh_key_path: config.ssh_key_path || "",
|
||||
ssh_key_passphrase: config.ssh_key_passphrase || "",
|
||||
ssh_expose_lan: config.ssh_expose_lan || false,
|
||||
ssl: config.ssl || false,
|
||||
connection_string: config.connection_string,
|
||||
};
|
||||
selectedType.value = profile;
|
||||
mongoUseUrl.value = !!config.connection_string;
|
||||
customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : "";
|
||||
dialogStep.value = "config";
|
||||
configTab.value = "connection";
|
||||
} else {
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
selectedType.value = "mysql";
|
||||
customDriverName.value = "";
|
||||
dialogStep.value = "select";
|
||||
configTab.value = "connection";
|
||||
}
|
||||
resetTestState();
|
||||
},
|
||||
);
|
||||
|
||||
const isEditing = ref(false);
|
||||
watch(() => editingId.value, (v) => { isEditing.value = !!v; });
|
||||
watch(
|
||||
() => editingId.value,
|
||||
(v) => {
|
||||
isEditing.value = !!v;
|
||||
},
|
||||
);
|
||||
|
||||
function onDbTypeChange(val: string) {
|
||||
customDriverName.value = "";
|
||||
@@ -202,15 +277,33 @@ function onDbTypeChange(val: string) {
|
||||
}
|
||||
|
||||
const iconTypeMap: Record<string, string> = {
|
||||
mysql: "mysql", postgres: "postgres", sqlite: "sqlite", redis: "redis",
|
||||
mongodb: "mongodb", duckdb: "duckdb", clickhouse: "clickhouse", sqlserver: "sqlserver",
|
||||
mysql: "mysql",
|
||||
postgres: "postgres",
|
||||
sqlite: "sqlite",
|
||||
redis: "redis",
|
||||
mongodb: "mongodb",
|
||||
duckdb: "duckdb",
|
||||
clickhouse: "clickhouse",
|
||||
sqlserver: "sqlserver",
|
||||
oracle: "oracle",
|
||||
elasticsearch: "elasticsearch",
|
||||
mariadb: "mariadb", tidb: "tidb", oceanbase: "oceanbase", goldendb: "goldendb",
|
||||
opengauss: "opengauss", gaussdb: "gaussdb", kingbase: "kingbase", vastbase: "vastbase",
|
||||
doris: "doris", selectdb: "selectdb", starrocks: "starrocks", redshift: "redshift",
|
||||
cockroachdb: "cockroachdb", tdengine: "tdengine", dm: "dm",
|
||||
custom_mysql: "mysql", custom_postgres: "postgres",
|
||||
mariadb: "mariadb",
|
||||
tidb: "tidb",
|
||||
oceanbase: "oceanbase",
|
||||
goldendb: "goldendb",
|
||||
opengauss: "opengauss",
|
||||
gaussdb: "gaussdb",
|
||||
kingbase: "kingbase",
|
||||
vastbase: "vastbase",
|
||||
doris: "doris",
|
||||
selectdb: "selectdb",
|
||||
starrocks: "starrocks",
|
||||
redshift: "redshift",
|
||||
cockroachdb: "cockroachdb",
|
||||
tdengine: "tdengine",
|
||||
dm: "dm",
|
||||
custom_mysql: "mysql",
|
||||
custom_postgres: "postgres",
|
||||
};
|
||||
|
||||
const dbOptions = [
|
||||
@@ -264,13 +357,11 @@ const filteredDbCategories = computed<DbCategory[]>(() => {
|
||||
...category,
|
||||
options: category.options.filter((option) => {
|
||||
const profile = driverProfiles[option.value];
|
||||
return [
|
||||
option.label,
|
||||
option.value,
|
||||
profile?.label,
|
||||
profile?.type,
|
||||
category.title,
|
||||
].some((value) => String(value || "").toLowerCase().includes(keyword));
|
||||
return [option.label, option.value, profile?.label, profile?.type, category.title].some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
}),
|
||||
}))
|
||||
.filter((category) => category.options.length > 0);
|
||||
@@ -379,7 +470,8 @@ async function save() {
|
||||
open.value = false;
|
||||
await nextTick();
|
||||
emit("connectStarted", config.name);
|
||||
void store.connect(config)
|
||||
void store
|
||||
.connect(config)
|
||||
.then(() => {
|
||||
emit("connectSucceeded", config.name);
|
||||
})
|
||||
@@ -398,7 +490,7 @@ async function save() {
|
||||
|
||||
const dialogTitle = ref("");
|
||||
watch([() => editingId.value, () => open.value], () => {
|
||||
dialogTitle.value = editingId.value ? t('connection.editTitle') : t('connection.title');
|
||||
dialogTitle.value = editingId.value ? t("connection.editTitle") : t("connection.title");
|
||||
});
|
||||
|
||||
async function browseSshKeyPath() {
|
||||
@@ -419,7 +511,7 @@ async function browseSshKeyPath() {
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="dialogStep === 'select' ? 'sm:max-w-[760px]' : 'sm:max-w-[560px]'">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ editingId ? t('connection.editTitle') : t('connection.title') }}</DialogTitle>
|
||||
<DialogTitle>{{ editingId ? t("connection.editTitle") : t("connection.title") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<template v-if="dialogStep === 'select'">
|
||||
@@ -471,12 +563,18 @@ async function browseSshKeyPath() {
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="group flex min-h-24 flex-col items-center justify-center gap-2 rounded-xl border bg-background/70 p-3 text-center transition hover:-translate-y-0.5 hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="selectedType === opt.value ? 'border-primary bg-primary/10 shadow-sm ring-1 ring-primary/30' : 'border-border'"
|
||||
:class="
|
||||
selectedType === opt.value
|
||||
? 'border-primary bg-primary/10 shadow-sm ring-1 ring-primary/30'
|
||||
: 'border-border'
|
||||
"
|
||||
:aria-pressed="selectedType === opt.value"
|
||||
@click="onDbTypeChange(opt.value)"
|
||||
@dblclick="goToConnectionStep(opt.value)"
|
||||
>
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-muted/60 transition group-hover:bg-background">
|
||||
<span
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-muted/60 transition group-hover:bg-background"
|
||||
>
|
||||
<DatabaseIcon :db-type="iconTypeMap[opt.value]" class="h-6 w-6" />
|
||||
</span>
|
||||
<span class="max-w-full truncate text-sm font-medium">{{ opt.label }}</span>
|
||||
@@ -489,7 +587,9 @@ async function browseSshKeyPath() {
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="flex items-center gap-3 rounded-lg border bg-background px-3 py-2 text-left transition hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="selectedType === opt.value ? 'border-primary bg-primary/10 ring-1 ring-primary/30' : 'border-border'"
|
||||
:class="
|
||||
selectedType === opt.value ? 'border-primary bg-primary/10 ring-1 ring-primary/30' : 'border-border'
|
||||
"
|
||||
:aria-pressed="selectedType === opt.value"
|
||||
@click="onDbTypeChange(opt.value)"
|
||||
@dblclick="goToConnectionStep(opt.value)"
|
||||
@@ -501,8 +601,11 @@ async function browseSshKeyPath() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="!hasDbPickerResults" class="rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
|
||||
{{ t('connection.noDatabaseMatches') }}
|
||||
<div
|
||||
v-if="!hasDbPickerResults"
|
||||
class="rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t("connection.noDatabaseMatches") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -510,259 +613,334 @@ async function browseSshKeyPath() {
|
||||
<DialogFooter class="flex items-center gap-2">
|
||||
<div class="mr-auto flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<DatabaseIcon :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
|
||||
<span class="truncate">{{ t('connection.selectedDatabase') }}: {{ selectedProfile().label }}</span>
|
||||
<span class="truncate">{{ t("connection.selectedDatabase") }}: {{ selectedProfile().label }}</span>
|
||||
</div>
|
||||
<Button :disabled="!hasDbPickerResults" @click="goToConnectionStep()">
|
||||
{{ t('connection.next') }}
|
||||
{{ t("connection.next") }}
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<Tabs v-model="configTab" class="min-h-0">
|
||||
<div class="flex items-center justify-between border-b pb-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="connection">{{ t('connection.basicTab') }}</TabsTrigger>
|
||||
<TabsTrigger v-if="canUseSsh" value="ssh">{{ t('connection.sshTunnel') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="connection" class="m-0">
|
||||
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.name') }}</Label>
|
||||
<Input v-model="form.name" class="col-span-3" :placeholder="t('connection.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.type') }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<DatabaseIcon :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm">{{ selectedProfile().label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isCustomCompatibleProfile()" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.driverName') }}</Label>
|
||||
<Input v-model="customDriverName" class="col-span-3" :placeholder="t('connection.driverNamePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.color') }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1.5">
|
||||
<button
|
||||
v-for="color in colorOptions"
|
||||
:key="color.value || 'none'"
|
||||
type="button"
|
||||
class="h-6 w-6 rounded-full border ring-offset-background transition hover:scale-105"
|
||||
:class="[color.class, form.color === color.value ? 'ring-2 ring-ring ring-offset-2' : 'border-border']"
|
||||
:title="t(color.labelKey)"
|
||||
@click="form.color = color.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQLite / DuckDB: file path only -->
|
||||
<template v-if="form.db_type === 'sqlite' || form.db_type === 'duckdb'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.filePath') }}</Label>
|
||||
<Input v-model="form.host" class="col-span-3" placeholder="/path/to/database.db" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Redis: host, port, user, password, ssl -->
|
||||
<template v-else-if="form.db_type === 'redis'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.host') }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.user') }}</Label>
|
||||
<Input v-model="form.username" class="col-span-3" placeholder="default" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.password') }}</Label>
|
||||
<Input v-model="form.password" type="password" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">SSL/TLS</Label>
|
||||
<div class="col-span-3">
|
||||
<input type="checkbox" v-model="form.ssl" class="mr-2" />
|
||||
<span class="text-xs text-muted-foreground">{{ t('connection.sshEnable') }}</span>
|
||||
<div class="space-y-3">
|
||||
<Tabs v-model="configTab" class="min-h-0">
|
||||
<div class="flex items-center justify-between border-b pb-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="connection">{{ t("connection.basicTab") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="canUseSsh" value="ssh">{{ t("connection.sshTunnel") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- MongoDB: URL or form -->
|
||||
<template v-else-if="form.db_type === 'mongodb'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.mode') }}</Label>
|
||||
<div class="col-span-3 flex gap-2">
|
||||
<Button size="sm" :variant="mongoUseUrl ? 'outline' : 'default'" @click="mongoUseUrl = false">{{ t('connection.modeForm') }}</Button>
|
||||
<Button size="sm" :variant="mongoUseUrl ? 'default' : 'outline'" @click="mongoUseUrl = true">URL</Button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="mongoUseUrl">
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-right mt-2">URL</Label>
|
||||
<textarea
|
||||
v-model="form.connection_string"
|
||||
class="col-span-3 flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="mongodb+srv://user:pass@cluster.mongodb.net/mydb"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.host') }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.user') }}</Label>
|
||||
<Input v-model="form.username" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.password') }}</Label>
|
||||
<Input v-model="form.password" type="password" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.database') }}</Label>
|
||||
<Input v-model="form.database" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<TabsContent value="connection" class="m-0">
|
||||
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.name") }}</Label>
|
||||
<Input v-model="form.name" class="col-span-3" :placeholder="t('connection.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<!-- MySQL / PostgreSQL: host, port, user, password, database -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.host') }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.type") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<DatabaseIcon :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm">{{ selectedProfile().label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.user') }}</Label>
|
||||
<Input v-model="form.username" class="col-span-3" />
|
||||
</div>
|
||||
<div v-if="isCustomCompatibleProfile()" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.driverName") }}</Label>
|
||||
<Input
|
||||
v-model="customDriverName"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.driverNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.password') }}</Label>
|
||||
<Input v-model="form.password" type="password" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.color") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1.5">
|
||||
<button
|
||||
v-for="color in colorOptions"
|
||||
:key="color.value || 'none'"
|
||||
type="button"
|
||||
class="h-6 w-6 rounded-full border ring-offset-background transition hover:scale-105"
|
||||
:class="[
|
||||
color.class,
|
||||
form.color === color.value ? 'ring-2 ring-ring ring-offset-2' : 'border-border',
|
||||
]"
|
||||
:title="t(color.labelKey)"
|
||||
@click="form.color = color.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.database') }}</Label>
|
||||
<Input v-model="form.database" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
|
||||
</div>
|
||||
<!-- SQLite / DuckDB: file path only -->
|
||||
<template v-if="form.db_type === 'sqlite' || form.db_type === 'duckdb'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.filePath") }}</Label>
|
||||
<Input v-model="form.host" class="col-span-3" placeholder="/path/to/database.db" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="selectedType === 'dm'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs text-muted-foreground">{{ t('connection.dmCompatHint') }}</p>
|
||||
</div>
|
||||
<!-- Redis: host, port, user, password, ssl -->
|
||||
<template v-else-if="form.db_type === 'redis'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.host") }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.user") }}</Label>
|
||||
<Input v-model="form.username" class="col-span-3" placeholder="default" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.password") }}</Label>
|
||||
<Input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.databasePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">SSL/TLS</Label>
|
||||
<div class="col-span-3">
|
||||
<input type="checkbox" v-model="form.ssl" class="mr-2" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.sshEnable") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="form.db_type === 'mysql' || form.db_type === 'postgres'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.urlParams') }}</Label>
|
||||
<Input v-model="form.url_params" class="col-span-3" :placeholder="form.db_type === 'postgres' ? 'sslmode=disable' : 'charset=utf8mb4'" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- MongoDB: URL or form -->
|
||||
<template v-else-if="form.db_type === 'mongodb'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.mode") }}</Label>
|
||||
<div class="col-span-3 flex gap-2">
|
||||
<Button size="sm" :variant="mongoUseUrl ? 'outline' : 'default'" @click="mongoUseUrl = false">{{
|
||||
t("connection.modeForm")
|
||||
}}</Button>
|
||||
<Button size="sm" :variant="mongoUseUrl ? 'default' : 'outline'" @click="mongoUseUrl = true"
|
||||
>URL</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="mongoUseUrl">
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-right mt-2">URL</Label>
|
||||
<textarea
|
||||
v-model="form.connection_string"
|
||||
class="col-span-3 flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="mongodb+srv://user:pass@cluster.mongodb.net/mydb"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.host") }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.user") }}</Label>
|
||||
<Input v-model="form.username" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.password") }}</Label>
|
||||
<Input v-model="form.password" type="password" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.database") }}</Label>
|
||||
<Input
|
||||
v-model="form.database"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.databasePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</TabsContent>
|
||||
<!-- MySQL / PostgreSQL: host, port, user, password, database -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.host") }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
|
||||
<TabsContent v-if="canUseSsh" value="ssh" class="m-0">
|
||||
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshTunnel') }}</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.ssh_enabled" class="mr-0" />
|
||||
<span class="text-xs text-muted-foreground">{{ t('connection.sshEnable') }}</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.user") }}</Label>
|
||||
<Input v-model="form.username" class="col-span-3" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.password") }}</Label>
|
||||
<Input v-model="form.password" type="password" class="col-span-3" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.database") }}</Label>
|
||||
<Input
|
||||
v-model="form.database"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.databasePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedType === 'dm'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs text-muted-foreground">
|
||||
{{ t("connection.dmCompatHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.db_type === 'mysql' || form.db_type === 'postgres'"
|
||||
class="grid grid-cols-4 items-center gap-4"
|
||||
>
|
||||
<Label class="text-right">{{ t("connection.urlParams") }}</Label>
|
||||
<Input
|
||||
v-model="form.url_params"
|
||||
class="col-span-3"
|
||||
:placeholder="form.db_type === 'postgres' ? 'sslmode=disable' : 'charset=utf8mb4'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshHost') }}</Label>
|
||||
<Input v-model="form.ssh_host" class="col-span-2" placeholder="ssh.example.com" :disabled="!form.ssh_enabled" />
|
||||
<Input v-model.number="form.ssh_port" type="number" class="col-span-1" :disabled="!form.ssh_enabled" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshUser') }}</Label>
|
||||
<Input v-model="form.ssh_user" class="col-span-3" placeholder="root" :disabled="!form.ssh_enabled" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshPassword') }}</Label>
|
||||
<Input v-model="form.ssh_password" type="password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" :disabled="!form.ssh_enabled" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshKeyPath') }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="form.ssh_key_path" class="flex-1" placeholder="~/.ssh/id_rsa" :disabled="!form.ssh_enabled" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="!form.ssh_enabled" @click="browseSshKeyPath">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('connection.sshKeyPathBrowse') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="canUseSsh" value="ssh" class="m-0">
|
||||
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshTunnel") }}</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.ssh_enabled" class="mr-0" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.sshEnable") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshHost") }}</Label>
|
||||
<Input
|
||||
v-model="form.ssh_host"
|
||||
class="col-span-2"
|
||||
placeholder="ssh.example.com"
|
||||
:disabled="!form.ssh_enabled"
|
||||
/>
|
||||
<Input
|
||||
v-model.number="form.ssh_port"
|
||||
type="number"
|
||||
class="col-span-1"
|
||||
:disabled="!form.ssh_enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshUser") }}</Label>
|
||||
<Input v-model="form.ssh_user" class="col-span-3" placeholder="root" :disabled="!form.ssh_enabled" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshPassword") }}</Label>
|
||||
<Input
|
||||
v-model="form.ssh_password"
|
||||
type="password"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.sshPasswordPlaceholder')"
|
||||
:disabled="!form.ssh_enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshKeyPath") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input
|
||||
v-model="form.ssh_key_path"
|
||||
class="flex-1"
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
:disabled="!form.ssh_enabled"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="!form.ssh_enabled"
|
||||
@click="browseSshKeyPath"
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.sshKeyPathBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshKeyPassphrase") }}</Label>
|
||||
<Input
|
||||
v-model="form.ssh_key_passphrase"
|
||||
type="password"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.sshKeyPassphrasePlaceholder')"
|
||||
:disabled="!form.ssh_enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<label
|
||||
class="col-span-3 flex items-center gap-2"
|
||||
:class="form.ssh_enabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<input type="checkbox" v-model="form.ssh_expose_lan" class="mr-0" :disabled="!form.ssh_enabled" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.sshExposeLan") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshKeyPassphrase') }}</Label>
|
||||
<Input v-model="form.ssh_key_passphrase" type="password" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" :disabled="!form.ssh_enabled" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<label
|
||||
class="col-span-3 flex items-center gap-2"
|
||||
:class="form.ssh_enabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<input type="checkbox" v-model="form.ssh_expose_lan" class="mr-0" :disabled="!form.ssh_enabled" />
|
||||
<span class="text-xs text-muted-foreground">{{ t('connection.sshExposeLan') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex min-w-0 items-center gap-2 sm:flex-nowrap">
|
||||
<div class="mr-auto flex min-w-0 flex-1 basis-0 items-center gap-2 overflow-hidden">
|
||||
<Button v-if="!editingId" variant="outline" class="shrink-0" :disabled="isSaving" @click="backToDatabasePicker">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
{{ t('connection.back') }}
|
||||
</Button>
|
||||
<template v-if="testResult">
|
||||
<span
|
||||
class="block min-w-0 flex-1 basis-0 truncate text-xs"
|
||||
:class="testResult.ok ? 'text-green-600' : 'text-red-600'"
|
||||
:title="testResultMessage"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ testResultMessage }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
class="h-5 w-5 shrink-0"
|
||||
:title="t('connection.copyTestResult')"
|
||||
:aria-label="t('connection.copyTestResult')"
|
||||
@click="copyTestResult"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</template>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Button variant="outline" class="shrink-0" :disabled="isTesting || isSaving" @click="testConnection">
|
||||
{{ isTesting ? t('connection.testing') : t('connection.test') }}
|
||||
</Button>
|
||||
<Button class="shrink-0" @click="save" :disabled="isSaving || !form.name || (!form.host && !(mongoUseUrl && form.connection_string))">
|
||||
{{ isSaving ? t('common.loading') : (editingId ? t('connection.save') : t('connection.saveAndConnect')) }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter class="flex min-w-0 items-center gap-2 sm:flex-nowrap">
|
||||
<div class="mr-auto flex min-w-0 flex-1 basis-0 items-center gap-2 overflow-hidden">
|
||||
<Button
|
||||
v-if="!editingId"
|
||||
variant="outline"
|
||||
class="shrink-0"
|
||||
:disabled="isSaving"
|
||||
@click="backToDatabasePicker"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
{{ t("connection.back") }}
|
||||
</Button>
|
||||
<template v-if="testResult">
|
||||
<span
|
||||
class="block min-w-0 flex-1 basis-0 truncate text-xs"
|
||||
:class="testResult.ok ? 'text-green-600' : 'text-red-600'"
|
||||
:title="testResultMessage"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ testResultMessage }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
class="h-5 w-5 shrink-0"
|
||||
:title="t('connection.copyTestResult')"
|
||||
:aria-label="t('connection.copyTestResult')"
|
||||
@click="copyTestResult"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<Button variant="outline" class="shrink-0" :disabled="isTesting || isSaving" @click="testConnection">
|
||||
{{ isTesting ? t("connection.testing") : t("connection.test") }}
|
||||
</Button>
|
||||
<Button
|
||||
class="shrink-0"
|
||||
@click="save"
|
||||
:disabled="isSaving || !form.name || (!form.host && !(mongoUseUrl && form.connection_string))"
|
||||
>
|
||||
{{ isSaving ? t("common.loading") : editingId ? t("connection.save") : t("connection.saveAndConnect") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
@@ -23,19 +19,20 @@ import {
|
||||
type DiagramTable,
|
||||
} from "@/lib/erDiagram";
|
||||
import { buildEngineeringDiagram } from "@/lib/engineeringDiagram";
|
||||
import { buildEngineeringDiagramSvg, buildTableDiagramSvg, diagramSvgFileName } from "@/lib/diagramSvgExport";
|
||||
import { clampDiagramZoom, zoomFromGestureScale, zoomFromWheelDelta } from "@/lib/diagramZoom";
|
||||
import {
|
||||
buildEngineeringDiagramSvg,
|
||||
buildTableDiagramSvg,
|
||||
diagramSvgFileName,
|
||||
} from "@/lib/diagramSvgExport";
|
||||
import {
|
||||
clampDiagramZoom,
|
||||
zoomFromGestureScale,
|
||||
zoomFromWheelDelta,
|
||||
} from "@/lib/diagramZoom";
|
||||
import {
|
||||
Download, KeyRound, Link2, Loader2, Maximize2, Network, RefreshCw, Search, Table2,
|
||||
ZoomIn, ZoomOut,
|
||||
Download,
|
||||
KeyRound,
|
||||
Link2,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Table2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-vue-next";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
@@ -90,16 +87,12 @@ const dragging = ref<{
|
||||
originY: number;
|
||||
} | null>(null);
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((connection) => SQL_TYPES.includes(connection.db_type)),
|
||||
);
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => SQL_TYPES.includes(connection.db_type)));
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
connectionId.value ? store.getConfig(connectionId.value) : undefined,
|
||||
);
|
||||
const selectedConnection = computed(() => (connectionId.value ? store.getConfig(connectionId.value) : undefined));
|
||||
|
||||
const isSchemaAware = computed(() =>
|
||||
!!selectedConnection.value && SCHEMA_AWARE_TYPES.includes(selectedConnection.value.db_type),
|
||||
const isSchemaAware = computed(
|
||||
() => !!selectedConnection.value && SCHEMA_AWARE_TYPES.includes(selectedConnection.value.db_type),
|
||||
);
|
||||
|
||||
const allRelationships = computed(() => buildDiagramRelationships(tables.value));
|
||||
@@ -124,16 +117,12 @@ const visibleTables = computed(() => {
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const visibleTableMap = computed(() =>
|
||||
new Map(visibleTables.value.map((table) => [table.name, table])),
|
||||
);
|
||||
const visibleTableMap = computed(() => new Map(visibleTables.value.map((table) => [table.name, table])));
|
||||
|
||||
const visibleRelationships = computed(() =>
|
||||
buildDiagramRelationships(visibleTables.value),
|
||||
);
|
||||
const visibleRelationships = computed(() => buildDiagramRelationships(visibleTables.value));
|
||||
|
||||
const diagramReady = computed(() =>
|
||||
!!connectionId.value && !!database.value && (!isSchemaAware.value || !!schema.value),
|
||||
const diagramReady = computed(
|
||||
() => !!connectionId.value && !!database.value && (!isSchemaAware.value || !!schema.value),
|
||||
);
|
||||
|
||||
const loadingText = computed(() =>
|
||||
@@ -257,22 +246,24 @@ function tableRects(): TableRect[] {
|
||||
function isVerticalRouteBlocked(routeX: number, y1: number, y2: number, ignoredTables: Set<string>): boolean {
|
||||
const top = Math.min(y1, y2);
|
||||
const bottom = Math.max(y1, y2);
|
||||
return tableRects().some((rect) =>
|
||||
!ignoredTables.has(rect.name) &&
|
||||
routeX >= rect.x - ROUTE_BLOCK_MARGIN &&
|
||||
routeX <= rect.x + rect.width + ROUTE_BLOCK_MARGIN &&
|
||||
rangesOverlap(top, bottom, rect.y - ROUTE_BLOCK_MARGIN, rect.y + rect.height + ROUTE_BLOCK_MARGIN)
|
||||
return tableRects().some(
|
||||
(rect) =>
|
||||
!ignoredTables.has(rect.name) &&
|
||||
routeX >= rect.x - ROUTE_BLOCK_MARGIN &&
|
||||
routeX <= rect.x + rect.width + ROUTE_BLOCK_MARGIN &&
|
||||
rangesOverlap(top, bottom, rect.y - ROUTE_BLOCK_MARGIN, rect.y + rect.height + ROUTE_BLOCK_MARGIN),
|
||||
);
|
||||
}
|
||||
|
||||
function isHorizontalRouteBlocked(y: number, x1: number, x2: number, ignoredTables: Set<string>): boolean {
|
||||
const left = Math.min(x1, x2);
|
||||
const right = Math.max(x1, x2);
|
||||
return tableRects().some((rect) =>
|
||||
!ignoredTables.has(rect.name) &&
|
||||
y >= rect.y - ROUTE_BLOCK_MARGIN &&
|
||||
y <= rect.y + rect.height + ROUTE_BLOCK_MARGIN &&
|
||||
rangesOverlap(left, right, rect.x - ROUTE_BLOCK_MARGIN, rect.x + rect.width + ROUTE_BLOCK_MARGIN)
|
||||
return tableRects().some(
|
||||
(rect) =>
|
||||
!ignoredTables.has(rect.name) &&
|
||||
y >= rect.y - ROUTE_BLOCK_MARGIN &&
|
||||
y <= rect.y + rect.height + ROUTE_BLOCK_MARGIN &&
|
||||
rangesOverlap(left, right, rect.x - ROUTE_BLOCK_MARGIN, rect.x + rect.width + ROUTE_BLOCK_MARGIN),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -293,8 +284,7 @@ function candidateRouteXs(source: TableRect, target: TableRect): number[] {
|
||||
candidates.add((targetRight + source.x) / 2);
|
||||
}
|
||||
|
||||
const columns = [...new Set(tableRects().map((rect) => rect.x))]
|
||||
.sort((left, right) => left - right);
|
||||
const columns = [...new Set(tableRects().map((rect) => rect.x))].sort((left, right) => left - right);
|
||||
for (let index = 0; index < columns.length - 1; index++) {
|
||||
const leftRight = columns[index] + CARD_WIDTH;
|
||||
const rightLeft = columns[index + 1];
|
||||
@@ -308,8 +298,11 @@ function candidateRouteXs(source: TableRect, target: TableRect): number[] {
|
||||
const leftTargetX = routeSideX(target, left);
|
||||
const rightSourceX = routeSideX(source, right);
|
||||
const rightTargetX = routeSideX(target, right);
|
||||
return (Math.abs(left - leftSourceX) + Math.abs(left - leftTargetX)) -
|
||||
(Math.abs(right - rightSourceX) + Math.abs(right - rightTargetX));
|
||||
return (
|
||||
Math.abs(left - leftSourceX) +
|
||||
Math.abs(left - leftTargetX) -
|
||||
(Math.abs(right - rightSourceX) + Math.abs(right - rightTargetX))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -323,13 +316,18 @@ function relationshipPath(relationship: DiagramRelationship): string {
|
||||
const ignoredTables = new Set([source.name, target.name]);
|
||||
const candidates = candidateRouteXs(source, target);
|
||||
|
||||
const routeX = candidates.find((candidate) => {
|
||||
const x1 = routeSideX(source, candidate);
|
||||
const x2 = routeSideX(target, candidate);
|
||||
return !isVerticalRouteBlocked(candidate, y1, y2, ignoredTables) &&
|
||||
!isHorizontalRouteBlocked(y1, x1, candidate, ignoredTables) &&
|
||||
!isHorizontalRouteBlocked(y2, candidate, x2, ignoredTables);
|
||||
}) ?? candidates[0] ?? Math.max(source.x + source.width, target.x + target.width) + ROUTE_PADDING;
|
||||
const routeX =
|
||||
candidates.find((candidate) => {
|
||||
const x1 = routeSideX(source, candidate);
|
||||
const x2 = routeSideX(target, candidate);
|
||||
return (
|
||||
!isVerticalRouteBlocked(candidate, y1, y2, ignoredTables) &&
|
||||
!isHorizontalRouteBlocked(y1, x1, candidate, ignoredTables) &&
|
||||
!isHorizontalRouteBlocked(y2, candidate, x2, ignoredTables)
|
||||
);
|
||||
}) ??
|
||||
candidates[0] ??
|
||||
Math.max(source.x + source.width, target.x + target.width) + ROUTE_PADDING;
|
||||
|
||||
const x1 = routeSideX(source, routeX, 2);
|
||||
const x2 = routeSideX(target, routeX, 2);
|
||||
@@ -338,17 +336,28 @@ function relationshipPath(relationship: DiagramRelationship): string {
|
||||
|
||||
function engineeringEntityCenter(tableName: string): DiagramPosition {
|
||||
const entity = engineeringDiagram.value.entities.find((item) => item.name === tableName);
|
||||
return entity
|
||||
? { x: entity.x + entity.width / 2, y: entity.y + entity.height / 2 }
|
||||
: { x: 0, y: 0 };
|
||||
return entity ? { x: entity.x + entity.width / 2, y: entity.y + entity.height / 2 } : { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
function engineeringAttributeCenter(attribute: { x: number; y: number; width: number; height: number }): DiagramPosition {
|
||||
function engineeringAttributeCenter(attribute: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): DiagramPosition {
|
||||
return { x: attribute.x + attribute.width / 2, y: attribute.y + attribute.height / 2 };
|
||||
}
|
||||
|
||||
function engineeringRelationshipCenter(relationship: { x: number; y: number; width: number; height: number }): DiagramPosition {
|
||||
return { x: relationship.x + relationship.width / 2, y: relationship.y + relationship.height / 2 };
|
||||
function engineeringRelationshipCenter(relationship: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): DiagramPosition {
|
||||
return {
|
||||
x: relationship.x + relationship.width / 2,
|
||||
y: relationship.y + relationship.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function engineeringCardinalityPoint(from: DiagramPosition, to: DiagramPosition): DiagramPosition {
|
||||
@@ -386,9 +395,12 @@ async function loadSchemas() {
|
||||
try {
|
||||
const names = await api.listSchemas(connectionId.value, database.value);
|
||||
schemas.value = names;
|
||||
schema.value = props.prefillSchema && names.includes(props.prefillSchema)
|
||||
? props.prefillSchema
|
||||
: names.includes("public") ? "public" : (names[0] ?? "");
|
||||
schema.value =
|
||||
props.prefillSchema && names.includes(props.prefillSchema)
|
||||
? props.prefillSchema
|
||||
: names.includes("public")
|
||||
? "public"
|
||||
: (names[0] ?? "");
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
@@ -458,9 +470,7 @@ async function loadDiagram() {
|
||||
const loadedTables: DiagramTable[] = [];
|
||||
for (let index = 0; index < baseTables.length; index += METADATA_BATCH_SIZE) {
|
||||
const batch = baseTables.slice(index, index + METADATA_BATCH_SIZE);
|
||||
const batchTables = await Promise.all(
|
||||
batch.map((table) => loadTableDiagramData(table.name, querySchema)),
|
||||
);
|
||||
const batchTables = await Promise.all(batch.map((table) => loadTableDiagramData(table.name, querySchema)));
|
||||
loadedTables.push(...batchTables);
|
||||
loadedTableCount.value = loadedTables.length;
|
||||
}
|
||||
@@ -498,9 +508,10 @@ async function initialize() {
|
||||
if (props.prefillConnectionId) {
|
||||
connectionId.value = props.prefillConnectionId;
|
||||
await loadDatabases(props.prefillConnectionId);
|
||||
const initialDatabase = props.prefillDatabase && databases.value.includes(props.prefillDatabase)
|
||||
? props.prefillDatabase
|
||||
: (props.prefillDatabase || databases.value[0] || "");
|
||||
const initialDatabase =
|
||||
props.prefillDatabase && databases.value.includes(props.prefillDatabase)
|
||||
? props.prefillDatabase
|
||||
: props.prefillDatabase || databases.value[0] || "";
|
||||
if (initialDatabase) await setDatabase(initialDatabase);
|
||||
return;
|
||||
}
|
||||
@@ -572,14 +583,8 @@ function currentDiagramSvg(): string {
|
||||
|
||||
async function exportSvg() {
|
||||
try {
|
||||
const scopeName = isSchemaAware.value && schema.value
|
||||
? `${database.value}-${schema.value}`
|
||||
: database.value;
|
||||
const defaultPath = diagramSvgFileName(
|
||||
selectedConnection.value?.name ?? "",
|
||||
scopeName,
|
||||
diagramMode.value,
|
||||
);
|
||||
const scopeName = isSchemaAware.value && schema.value ? `${database.value}-${schema.value}` : database.value;
|
||||
const defaultPath = diagramSvgFileName(selectedConnection.value?.name ?? "", scopeName, diagramMode.value);
|
||||
const svgContent = currentDiagramSvg();
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
@@ -662,20 +667,25 @@ watch(open, (value) => {
|
||||
if (value) void initialize();
|
||||
});
|
||||
|
||||
watch(() => visibleTables.value.map((table) => table.name).join("\n"), () => {
|
||||
resetLayout();
|
||||
});
|
||||
watch(
|
||||
() => visibleTables.value.map((table) => table.name).join("\n"),
|
||||
() => {
|
||||
resetLayout();
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(stopDrag);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="w-[94vw] max-w-[94vw] sm:max-w-[94vw] md:max-w-[94vw] lg:max-w-[94vw] xl:max-w-[94vw] h-[86vh] max-h-[86vh] gap-0 p-0 overflow-hidden flex flex-col">
|
||||
<DialogContent
|
||||
class="w-[94vw] max-w-[94vw] sm:max-w-[94vw] md:max-w-[94vw] lg:max-w-[94vw] xl:max-w-[94vw] h-[86vh] max-h-[86vh] gap-0 p-0 overflow-hidden flex flex-col"
|
||||
>
|
||||
<DialogHeader class="px-4 py-3 border-b">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Network class="w-4 h-4" />
|
||||
{{ t('diagram.title') }}
|
||||
{{ t("diagram.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -698,7 +708,11 @@ onUnmounted(stopDrag);
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select :model-value="database" :disabled="!databases.length || loadingDatabases" @update:model-value="(value: any) => setDatabase(String(value))">
|
||||
<Select
|
||||
:model-value="database"
|
||||
:disabled="!databases.length || loadingDatabases"
|
||||
@update:model-value="(value: any) => setDatabase(String(value))"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-44 text-xs">
|
||||
<SelectValue :placeholder="loadingDatabases ? t('common.loading') : t('diagram.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
@@ -735,7 +749,7 @@ onUnmounted(stopDrag);
|
||||
@click="diagramMode = 'table'"
|
||||
>
|
||||
<Table2 class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t('diagram.tableMode') }}
|
||||
{{ t("diagram.tableMode") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -745,7 +759,7 @@ onUnmounted(stopDrag);
|
||||
@click="diagramMode = 'engineering'"
|
||||
>
|
||||
<Network class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t('diagram.engineeringMode') }}
|
||||
{{ t("diagram.engineeringMode") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -756,20 +770,34 @@ onUnmounted(stopDrag);
|
||||
class="h-8 px-2 text-xs"
|
||||
@click="showAllTables = !showAllTables"
|
||||
>
|
||||
{{ showAllTables ? t('diagram.relatedTables') : t('diagram.allTables') }}
|
||||
{{ showAllTables ? t("diagram.relatedTables") : t("diagram.allTables") }}
|
||||
</Button>
|
||||
|
||||
<Badge variant="secondary" class="h-6 shrink-0">
|
||||
{{ t('diagram.tablesCount', { count: visibleTables.length }) }}
|
||||
{{ t("diagram.tablesCount", { count: visibleTables.length }) }}
|
||||
</Badge>
|
||||
<Badge variant="secondary" class="h-6 shrink-0">
|
||||
{{ t('diagram.relationshipsCount', { count: visibleRelationships.length }) }}
|
||||
{{ t("diagram.relationshipsCount", { count: visibleRelationships.length }) }}
|
||||
</Badge>
|
||||
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="loadingDiagram || visibleTables.length === 0" :title="t('diagram.exportSvg')" @click="exportSvg">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="loadingDiagram || visibleTables.length === 0"
|
||||
:title="t('diagram.exportSvg')"
|
||||
@click="exportSvg"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="!diagramReady || loadingDiagram" :title="t('diagram.refresh')" @click="loadDiagram">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="!diagramReady || loadingDiagram"
|
||||
:title="t('diagram.refresh')"
|
||||
@click="loadDiagram"
|
||||
>
|
||||
<Loader2 v-if="loadingDiagram" class="h-4 w-4 animate-spin" />
|
||||
<RefreshCw v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -779,7 +807,13 @@ onUnmounted(stopDrag);
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :title="t('diagram.zoomIn')" @click="zoomIn">
|
||||
<ZoomIn class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :title="t('diagram.resetLayout')" @click="resetZoomAndLayout">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="t('diagram.resetLayout')"
|
||||
@click="resetZoomAndLayout"
|
||||
>
|
||||
<Maximize2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -790,13 +824,19 @@ onUnmounted(stopDrag);
|
||||
{{ loadingText }}
|
||||
</div>
|
||||
<div v-else-if="!diagramReady" class="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t('diagram.selectTarget') }}
|
||||
{{ t("diagram.selectTarget") }}
|
||||
</div>
|
||||
<div v-else-if="tables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t('diagram.empty') }}
|
||||
<div
|
||||
v-else-if="tables.length === 0"
|
||||
class="h-full flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t("diagram.empty") }}
|
||||
</div>
|
||||
<div v-else-if="visibleTables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t('diagram.noMatches') }}
|
||||
<div
|
||||
v-else-if="visibleTables.length === 0"
|
||||
class="h-full flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t("diagram.noMatches") }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -807,67 +847,82 @@ onUnmounted(stopDrag);
|
||||
@gesturechange="onDiagramGestureChange"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
:style="{ width: `${activeCanvasSize.width * zoom}px`, height: `${activeCanvasSize.height * zoom}px` }"
|
||||
class="relative"
|
||||
:style="{
|
||||
width: `${activeCanvasSize.width * zoom}px`,
|
||||
height: `${activeCanvasSize.height * zoom}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 top-0 origin-top-left"
|
||||
:style="{ width: `${activeCanvasSize.width}px`, height: `${activeCanvasSize.height}px`, transform: `scale(${zoom})` }"
|
||||
:style="{
|
||||
width: `${activeCanvasSize.width}px`,
|
||||
height: `${activeCanvasSize.height}px`,
|
||||
transform: `scale(${zoom})`,
|
||||
}"
|
||||
>
|
||||
<template v-if="diagramMode === 'table'">
|
||||
<svg class="absolute inset-0 h-full w-full overflow-visible pointer-events-none">
|
||||
<defs>
|
||||
<marker id="diagram-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" class="fill-primary/70" />
|
||||
</marker>
|
||||
</defs>
|
||||
<path
|
||||
v-for="relationship in visibleRelationships"
|
||||
:key="relationship.id"
|
||||
:d="relationshipPath(relationship)"
|
||||
class="fill-none stroke-primary/55"
|
||||
stroke-width="1.6"
|
||||
marker-end="url(#diagram-arrow)"
|
||||
>
|
||||
<title>{{ relationshipTitle(relationship) }}</title>
|
||||
</path>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-for="table in visibleTables"
|
||||
:key="table.name"
|
||||
class="absolute overflow-hidden rounded-md border bg-background shadow-sm"
|
||||
:class="table.name === focusTableName ? 'border-primary ring-1 ring-primary/30' : 'border-border'"
|
||||
:style="{
|
||||
width: `${CARD_WIDTH}px`,
|
||||
transform: `translate(${positions[table.name]?.x ?? 0}px, ${positions[table.name]?.y ?? 0}px)`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing"
|
||||
@mousedown="startDrag(table.name, $event)"
|
||||
>
|
||||
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ table.name }}</span>
|
||||
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ table.columns.length }}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-for="column in visibleColumns(table)"
|
||||
:key="column.name"
|
||||
class="flex h-6 items-center gap-1.5 border-b border-border/40 px-3 text-xs last:border-b-0"
|
||||
<svg class="absolute inset-0 h-full w-full overflow-visible pointer-events-none">
|
||||
<defs>
|
||||
<marker
|
||||
id="diagram-arrow"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" class="fill-primary/70" />
|
||||
</marker>
|
||||
</defs>
|
||||
<path
|
||||
v-for="relationship in visibleRelationships"
|
||||
:key="relationship.id"
|
||||
:d="relationshipPath(relationship)"
|
||||
class="fill-none stroke-primary/55"
|
||||
stroke-width="1.6"
|
||||
marker-end="url(#diagram-arrow)"
|
||||
>
|
||||
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<Link2 v-else-if="isForeignKeyColumn(table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
|
||||
<span v-else class="h-3 w-3 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ column.name }}</span>
|
||||
<span class="max-w-24 truncate text-[10px] text-muted-foreground">{{ column.data_type }}</span>
|
||||
<title>{{ relationshipTitle(relationship) }}</title>
|
||||
</path>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-for="table in visibleTables"
|
||||
:key="table.name"
|
||||
class="absolute overflow-hidden rounded-md border bg-background shadow-sm"
|
||||
:class="table.name === focusTableName ? 'border-primary ring-1 ring-primary/30' : 'border-border'"
|
||||
:style="{
|
||||
width: `${CARD_WIDTH}px`,
|
||||
transform: `translate(${positions[table.name]?.x ?? 0}px, ${positions[table.name]?.y ?? 0}px)`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing"
|
||||
@mousedown="startDrag(table.name, $event)"
|
||||
>
|
||||
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ table.name }}</span>
|
||||
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ table.columns.length }}</Badge>
|
||||
</div>
|
||||
<div v-if="hiddenColumnCount(table) > 0" class="h-6 px-3 text-xs leading-6 text-muted-foreground">
|
||||
{{ t('diagram.moreColumns', { count: hiddenColumnCount(table) }) }}
|
||||
<div>
|
||||
<div
|
||||
v-for="column in visibleColumns(table)"
|
||||
:key="column.name"
|
||||
class="flex h-6 items-center gap-1.5 border-b border-border/40 px-3 text-xs last:border-b-0"
|
||||
>
|
||||
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<Link2 v-else-if="isForeignKeyColumn(table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
|
||||
<span v-else class="h-3 w-3 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ column.name }}</span>
|
||||
<span class="max-w-24 truncate text-[10px] text-muted-foreground">{{ column.data_type }}</span>
|
||||
</div>
|
||||
<div v-if="hiddenColumnCount(table) > 0" class="h-6 px-3 text-xs leading-6 text-muted-foreground">
|
||||
{{ t("diagram.moreColumns", { count: hiddenColumnCount(table) }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -899,15 +954,35 @@ onUnmounted(stopDrag);
|
||||
/>
|
||||
<text
|
||||
class="fill-foreground text-[13px] font-semibold"
|
||||
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).x"
|
||||
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).y"
|
||||
:x="
|
||||
engineeringCardinalityPoint(
|
||||
engineeringRelationshipCenter(relationship),
|
||||
engineeringEntityCenter(relationship.sourceTable),
|
||||
).x
|
||||
"
|
||||
:y="
|
||||
engineeringCardinalityPoint(
|
||||
engineeringRelationshipCenter(relationship),
|
||||
engineeringEntityCenter(relationship.sourceTable),
|
||||
).y
|
||||
"
|
||||
>
|
||||
{{ relationship.sourceCardinality }}
|
||||
</text>
|
||||
<text
|
||||
class="fill-foreground text-[13px] font-semibold"
|
||||
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).x"
|
||||
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).y"
|
||||
:x="
|
||||
engineeringCardinalityPoint(
|
||||
engineeringRelationshipCenter(relationship),
|
||||
engineeringEntityCenter(relationship.targetTable),
|
||||
).x
|
||||
"
|
||||
:y="
|
||||
engineeringCardinalityPoint(
|
||||
engineeringRelationshipCenter(relationship),
|
||||
engineeringEntityCenter(relationship.targetTable),
|
||||
).y
|
||||
"
|
||||
>
|
||||
{{ relationship.targetCardinality }}
|
||||
</text>
|
||||
@@ -941,7 +1016,10 @@ onUnmounted(stopDrag);
|
||||
}"
|
||||
:title="`${relationship.sourceTable} -> ${relationship.targetTable}`"
|
||||
>
|
||||
<div class="absolute inset-0 border border-red-500/70 bg-red-100/80 dark:bg-red-950/35" style="clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%)" />
|
||||
<div
|
||||
class="absolute inset-0 border border-red-500/70 bg-red-100/80 dark:bg-red-950/35"
|
||||
style="clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"
|
||||
/>
|
||||
<span class="relative max-w-[70px] truncate">{{ relationship.label }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Dialog, DialogHeader, DialogTitle,
|
||||
DialogFooter, DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogContent } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
diffColumns, diffIndexes, diffTables, generateSyncSql,
|
||||
type TableDiff,
|
||||
} from "@/lib/schemaDiff";
|
||||
import { diffColumns, diffIndexes, diffTables, generateSyncSql, type TableDiff } from "@/lib/schemaDiff";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { Loader2, Copy, Play, GitCompareArrows } from "lucide-vue-next";
|
||||
|
||||
@@ -47,14 +39,11 @@ const syncSql = ref("");
|
||||
const executing = ref(false);
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((c) =>
|
||||
!["redis", "mongodb", "elasticsearch"].includes(c.db_type),
|
||||
),
|
||||
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
|
||||
);
|
||||
|
||||
const canCompare = computed(() =>
|
||||
sourceConnectionId.value && sourceDatabase.value &&
|
||||
targetConnectionId.value && targetDatabase.value,
|
||||
const canCompare = computed(
|
||||
() => sourceConnectionId.value && sourceDatabase.value && targetConnectionId.value && targetDatabase.value,
|
||||
);
|
||||
|
||||
function connectionIconType(connectionId: string) {
|
||||
@@ -83,7 +72,11 @@ async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
|
||||
async function resolveSchema(connectionId: string, database: string): Promise<string> {
|
||||
const config = store.getConfig(connectionId);
|
||||
const needsSchema = config?.db_type === "postgres" || config?.db_type === "sqlserver" || config?.db_type === "oracle" || config?.db_type === "redshift";
|
||||
const needsSchema =
|
||||
config?.db_type === "postgres" ||
|
||||
config?.db_type === "sqlserver" ||
|
||||
config?.db_type === "oracle" ||
|
||||
config?.db_type === "redshift";
|
||||
if (needsSchema) {
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
return schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
||||
@@ -232,7 +225,7 @@ watch(open, async (val) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<GitCompareArrows class="w-4 h-4" />
|
||||
{{ t('diff.title') }}
|
||||
{{ t("diff.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -240,11 +233,18 @@ watch(open, async (val) => {
|
||||
<!-- Source / Target Selection -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t('diff.source') }}</Label>
|
||||
<Select :model-value="sourceConnectionId" @update:model-value="(v: any) => sourceConnectionId = String(v)">
|
||||
<Label class="text-xs font-medium">{{ t("diff.source") }}</Label>
|
||||
<Select
|
||||
:model-value="sourceConnectionId"
|
||||
@update:model-value="(v: any) => (sourceConnectionId = String(v))"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-3.5 h-3.5" />
|
||||
<DatabaseIcon
|
||||
v-if="sourceConnectionId"
|
||||
:db-type="connectionIconType(sourceConnectionId)"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
@@ -257,7 +257,11 @@ watch(open, async (val) => {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-if="sourceDatabases.length" :model-value="sourceDatabase" @update:model-value="(v: any) => sourceDatabase = String(v)">
|
||||
<Select
|
||||
v-if="sourceDatabases.length"
|
||||
:model-value="sourceDatabase"
|
||||
@update:model-value="(v: any) => (sourceDatabase = String(v))"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
@@ -268,11 +272,18 @@ watch(open, async (val) => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t('diff.target') }}</Label>
|
||||
<Select :model-value="targetConnectionId" @update:model-value="(v: any) => targetConnectionId = String(v)">
|
||||
<Label class="text-xs font-medium">{{ t("diff.target") }}</Label>
|
||||
<Select
|
||||
:model-value="targetConnectionId"
|
||||
@update:model-value="(v: any) => (targetConnectionId = String(v))"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-3.5 h-3.5" />
|
||||
<DatabaseIcon
|
||||
v-if="targetConnectionId"
|
||||
:db-type="connectionIconType(targetConnectionId)"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
@@ -285,7 +296,11 @@ watch(open, async (val) => {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-if="targetDatabases.length" :model-value="targetDatabase" @update:model-value="(v: any) => targetDatabase = String(v)">
|
||||
<Select
|
||||
v-if="targetDatabases.length"
|
||||
:model-value="targetDatabase"
|
||||
@update:model-value="(v: any) => (targetDatabase = String(v))"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
@@ -298,19 +313,19 @@ watch(open, async (val) => {
|
||||
|
||||
<Button v-if="step === 'select'" size="sm" :disabled="!canCompare" @click="startCompare">
|
||||
<GitCompareArrows class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t('diff.compare') }}
|
||||
{{ t("diff.compare") }}
|
||||
</Button>
|
||||
|
||||
<!-- Comparing -->
|
||||
<div v-if="step === 'comparing'" class="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
<Loader2 class="w-4 h-4 animate-spin mr-2" />
|
||||
{{ t('diff.comparing') }}
|
||||
{{ t("diff.comparing") }}
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<template v-if="step === 'result'">
|
||||
<div v-if="diffs.length === 0" class="py-6 text-center text-sm text-muted-foreground">
|
||||
{{ t('diff.noDifferences') }}
|
||||
{{ t("diff.noDifferences") }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
@@ -320,9 +335,9 @@ watch(open, async (val) => {
|
||||
<table class="w-full text-xs table-fixed">
|
||||
<thead class="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2 font-medium w-1/4">{{ t('diff.table') }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium w-16">{{ t('diff.status') }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium">{{ t('diff.details') }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium w-1/4">{{ t("diff.table") }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium w-16">{{ t("diff.status") }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium">{{ t("diff.details") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -336,16 +351,19 @@ watch(open, async (val) => {
|
||||
<td class="px-3 py-1.5 text-muted-foreground">
|
||||
<template v-if="d.type === 'modified' && d.columns">
|
||||
<span v-for="(col, ci) in d.columns" :key="ci">
|
||||
<span :class="{
|
||||
'text-green-500': col.type === 'added',
|
||||
'text-red-500': col.type === 'removed',
|
||||
'text-yellow-500': col.type === 'modified',
|
||||
}">{{ col.type === 'added' ? '+' : col.type === 'removed' ? '-' : '~' }}{{ col.name }}</span>
|
||||
<span
|
||||
:class="{
|
||||
'text-green-500': col.type === 'added',
|
||||
'text-red-500': col.type === 'removed',
|
||||
'text-yellow-500': col.type === 'modified',
|
||||
}"
|
||||
>{{ col.type === "added" ? "+" : col.type === "removed" ? "-" : "~" }}{{ col.name }}</span
|
||||
>
|
||||
<span v-if="ci < d.columns!.length - 1">, </span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else-if="d.type === 'added'" class="text-green-500">{{ t('diff.newTable') }}</span>
|
||||
<span v-else-if="d.type === 'removed'" class="text-red-500">{{ t('diff.dropTable') }}</span>
|
||||
<span v-else-if="d.type === 'added'" class="text-green-500">{{ t("diff.newTable") }}</span>
|
||||
<span v-else-if="d.type === 'removed'" class="text-red-500">{{ t("diff.dropTable") }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -355,7 +373,7 @@ watch(open, async (val) => {
|
||||
|
||||
<!-- SQL Preview -->
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs font-medium">{{ t('diff.generatedSql') }}</Label>
|
||||
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
|
||||
<textarea
|
||||
v-model="syncSql"
|
||||
class="w-full h-48 rounded-lg border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
@@ -367,12 +385,12 @@ watch(open, async (val) => {
|
||||
|
||||
<DialogFooter v-if="step === 'result' && diffs.length > 0" class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="copySql">
|
||||
<Copy class="w-3 h-3 mr-1" /> {{ t('diff.copySql') }}
|
||||
<Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!syncSql.trim() || executing" @click="executeSql">
|
||||
<Loader2 v-if="executing" class="w-3 h-3 animate-spin mr-1" />
|
||||
<Play v-else class="w-3 h-3 mr-1" />
|
||||
{{ t('diff.executeSync') }}
|
||||
{{ t("diff.executeSync") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,19 +2,37 @@
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
ArrowUp, ArrowRightLeft, Bot, Check, ChevronRight, Copy, Database, HelpCircle, History,
|
||||
Loader2, MessageSquarePlus, Replace, Server, Settings, Play, Square, Trash2,
|
||||
Wand2, Wrench, X, Zap, TestTube,
|
||||
ArrowUp,
|
||||
ArrowRightLeft,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Database,
|
||||
HelpCircle,
|
||||
History,
|
||||
Loader2,
|
||||
MessageSquarePlus,
|
||||
Replace,
|
||||
Server,
|
||||
Settings,
|
||||
Play,
|
||||
Square,
|
||||
Trash2,
|
||||
Wand2,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
TestTube,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -26,8 +44,12 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { buildAiContext, runAiStream, type AiAction } from "@/lib/ai";
|
||||
import {
|
||||
aiTestConnection, aiCancelStream,
|
||||
saveAiConversation, loadAiConversations, deleteAiConversation, type AiConversation,
|
||||
aiTestConnection,
|
||||
aiCancelStream,
|
||||
saveAiConversation,
|
||||
loadAiConversations,
|
||||
deleteAiConversation,
|
||||
type AiConversation,
|
||||
} from "@/lib/api";
|
||||
import type { AiMessage } from "@/lib/api";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
@@ -99,7 +121,6 @@ const isWaitingForFirstDelta = computed(() => {
|
||||
|
||||
const activePlaceholder = computed(() => t(`ai.placeholders.${activeAction.value}`));
|
||||
|
||||
|
||||
const { databaseOptions: allDbOptions, loadDatabaseOptions } = useDatabaseOptions();
|
||||
|
||||
const dbOptions = computed(() => {
|
||||
@@ -256,16 +277,22 @@ async function send() {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
await runAiStream({
|
||||
config: settings.aiConfig,
|
||||
action: activeAction.value,
|
||||
instruction: text,
|
||||
context,
|
||||
}, history, (delta) => {
|
||||
appendAssistantDelta(assistantIdx, delta);
|
||||
}, sessionId, (reasoningDelta) => {
|
||||
appendAssistantReasoning(assistantIdx, reasoningDelta);
|
||||
});
|
||||
await runAiStream(
|
||||
{
|
||||
config: settings.aiConfig,
|
||||
action: activeAction.value,
|
||||
instruction: text,
|
||||
context,
|
||||
},
|
||||
history,
|
||||
(delta) => {
|
||||
appendAssistantDelta(assistantIdx, delta);
|
||||
},
|
||||
sessionId,
|
||||
(reasoningDelta) => {
|
||||
appendAssistantReasoning(assistantIdx, reasoningDelta);
|
||||
},
|
||||
);
|
||||
} catch (e: any) {
|
||||
messages.value[assistantIdx].content = `Error: ${e.message || e}`;
|
||||
} finally {
|
||||
@@ -298,7 +325,9 @@ const copiedIndex = ref("");
|
||||
async function copyCode(code: string, key: string) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
copiedIndex.value = key;
|
||||
setTimeout(() => { if (copiedIndex.value === key) copiedIndex.value = ""; }, 2000);
|
||||
setTimeout(() => {
|
||||
if (copiedIndex.value === key) copiedIndex.value = "";
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
@@ -315,7 +344,11 @@ async function persistConversation() {
|
||||
title: first ? first.content.slice(0, 50) : "Untitled",
|
||||
connectionName: props.connection.name,
|
||||
database: props.tab?.database || "",
|
||||
messages: messages.value.map((m) => ({ role: m.role, content: m.content, ...(m.reasoning ? { reasoning: m.reasoning } : {}) })),
|
||||
messages: messages.value.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
...(m.reasoning ? { reasoning: m.reasoning } : {}),
|
||||
})),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).catch(() => {});
|
||||
@@ -412,7 +445,14 @@ function formatInlineText(text: string): string {
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="startNewChat" :title="t('ai.newChat')">
|
||||
<MessageSquarePlus class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :class="{ 'bg-accent': showConversationList }" @click="loadConversationList" :title="t('history.title')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="{ 'bg-accent': showConversationList }"
|
||||
@click="loadConversationList"
|
||||
:title="t('history.title')"
|
||||
>
|
||||
<History class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="clearMessages" :title="t('ai.clear')">
|
||||
@@ -428,7 +468,7 @@ function formatInlineText(text: string): string {
|
||||
|
||||
<div v-if="showConversationList" class="border-b max-h-48 overflow-auto">
|
||||
<div v-if="!conversations.length" class="p-3 text-xs text-muted-foreground text-center">
|
||||
{{ t('history.empty') }}
|
||||
{{ t("history.empty") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
@@ -438,19 +478,24 @@ function formatInlineText(text: string): string {
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<span class="flex-1 truncate">{{ conv.title }}</span>
|
||||
<button class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive" @click.stop="deleteConversation(conv.id)">
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
@click.stop="deleteConversation(conv.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length === 0" class="flex-1 min-h-0 flex flex-col items-center justify-center text-center text-muted-foreground">
|
||||
<div
|
||||
v-if="messages.length === 0"
|
||||
class="flex-1 min-h-0 flex flex-col items-center justify-center text-center text-muted-foreground"
|
||||
>
|
||||
<Bot class="h-10 w-10 mb-3 opacity-30" />
|
||||
<p class="text-sm">{{ t('ai.welcome') }}</p>
|
||||
<p class="text-sm">{{ t("ai.welcome") }}</p>
|
||||
</div>
|
||||
<ScrollArea v-else ref="scrollRef" class="min-h-0 flex-1 overflow-hidden">
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="flex justify-end">
|
||||
<div class="max-w-[85%] rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
|
||||
@@ -470,16 +515,20 @@ function formatInlineText(text: string): string {
|
||||
:class="{ 'rotate-90': expandedReasoning.has(i) || msg.isThinking }"
|
||||
/>
|
||||
<Loader2 v-if="msg.isThinking" class="h-3 w-3 animate-spin" />
|
||||
<span>{{ t('ai.reasoningProcess') }}</span>
|
||||
<span>{{ t("ai.reasoningProcess") }}</span>
|
||||
</button>
|
||||
<div
|
||||
class="overflow-hidden transition-all duration-200 ease-in-out"
|
||||
:style="{
|
||||
maxHeight: (expandedReasoning.has(i) || msg.isThinking) ? '2000px' : '0px',
|
||||
opacity: (expandedReasoning.has(i) || msg.isThinking) ? '1' : '0',
|
||||
maxHeight: expandedReasoning.has(i) || msg.isThinking ? '2000px' : '0px',
|
||||
opacity: expandedReasoning.has(i) || msg.isThinking ? '1' : '0',
|
||||
}"
|
||||
>
|
||||
<div class="mt-1.5 pl-4 border-l-2 border-muted-foreground/20 text-[11px] text-muted-foreground whitespace-pre-wrap">{{ msg.reasoning }}</div>
|
||||
<div
|
||||
class="mt-1.5 pl-4 border-l-2 border-muted-foreground/20 text-[11px] text-muted-foreground whitespace-pre-wrap"
|
||||
>
|
||||
{{ msg.reasoning }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-for="(seg, j) in parseMessage(msg.content)" :key="j">
|
||||
@@ -487,24 +536,40 @@ function formatInlineText(text: string): string {
|
||||
<span v-html="formatInlineText(seg.content)" />
|
||||
</div>
|
||||
<div v-else class="my-2 rounded-md overflow-hidden bg-zinc-900 dark:bg-zinc-900">
|
||||
<div class="flex items-center px-3 py-1.5 text-[10px] text-zinc-400 font-medium border-b border-zinc-700/50">
|
||||
<div
|
||||
class="flex items-center px-3 py-1.5 text-[10px] text-zinc-400 font-medium border-b border-zinc-700/50"
|
||||
>
|
||||
<Database class="h-3 w-3 mr-1.5" />
|
||||
<span>{{ seg.lang }}</span>
|
||||
<span class="flex-1" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :title="t('ai.executeSql')" @click="executeSql(seg.content)">
|
||||
<button
|
||||
class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
:title="t('ai.executeSql')"
|
||||
@click="executeSql(seg.content)"
|
||||
>
|
||||
<Play class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :title="t('ai.apply')" @click="applySql(seg.content)">
|
||||
<button
|
||||
class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
:title="t('ai.apply')"
|
||||
@click="applySql(seg.content)"
|
||||
>
|
||||
<Replace class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :title="copiedIndex === `${i}-${j}` ? t('ai.copied') : t('ai.copySql')" @click="copyCode(seg.content, `${i}-${j}`)">
|
||||
<button
|
||||
class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
:title="copiedIndex === `${i}-${j}` ? t('ai.copied') : t('ai.copySql')"
|
||||
@click="copyCode(seg.content, `${i}-${j}`)"
|
||||
>
|
||||
<Check v-if="copiedIndex === `${i}-${j}`" class="h-3.5 w-3.5 text-green-400" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-zinc-100"><code>{{ seg.content }}</code></pre>
|
||||
<pre
|
||||
class="whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-zinc-100"
|
||||
><code>{{ seg.content }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -513,7 +578,7 @@ function formatInlineText(text: string): string {
|
||||
|
||||
<div v-if="isWaitingForFirstDelta" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
<span>{{ t('ai.thinking') }}</span>
|
||||
<span>{{ t("ai.thinking") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -524,8 +589,12 @@ function formatInlineText(text: string): string {
|
||||
<DatabaseIcon v-if="connection" :db-type="connectionIconType(connection)" class="h-3 w-3 shrink-0" />
|
||||
<Server v-else class="h-3 w-3 shrink-0" />
|
||||
<Select :model-value="connection?.id || ''" @update:model-value="(v: any) => changeConnection(v)">
|
||||
<SelectTrigger class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3">
|
||||
<SelectValue :placeholder="t('editor.selectConnection')">{{ connection?.name || t('editor.selectConnection') }}</SelectValue>
|
||||
<SelectTrigger
|
||||
class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3"
|
||||
>
|
||||
<SelectValue :placeholder="t('editor.selectConnection')">{{
|
||||
connection?.name || t("editor.selectConnection")
|
||||
}}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="min-w-48">
|
||||
<SelectItem v-for="conn in connectionStore.connections" :key="conn.id" :value="conn.id">
|
||||
@@ -538,13 +607,27 @@ function formatInlineText(text: string): string {
|
||||
</Select>
|
||||
<template v-if="connection">
|
||||
<Database class="h-3 w-3 shrink-0 text-foreground/40" />
|
||||
<Select :model-value="tab?.database || ''" @update:model-value="(v: any) => changeDatabase(v)" @update:open="(open: boolean) => { if (open) loadDatabases() }">
|
||||
<SelectTrigger class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3">
|
||||
<SelectValue :placeholder="t('editor.selectDatabase')">{{ tab?.database || t('editor.selectDatabase') }}</SelectValue>
|
||||
<Select
|
||||
:model-value="tab?.database || ''"
|
||||
@update:model-value="(v: any) => changeDatabase(v)"
|
||||
@update:open="
|
||||
(open: boolean) => {
|
||||
if (open) loadDatabases();
|
||||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3"
|
||||
>
|
||||
<SelectValue :placeholder="t('editor.selectDatabase')">{{
|
||||
tab?.database || t("editor.selectDatabase")
|
||||
}}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="db in dbOptions" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
<SelectItem v-if="!dbOptions.length && tab?.database" :value="tab.database">{{ tab.database }}</SelectItem>
|
||||
<SelectItem v-if="!dbOptions.length && tab?.database" :value="tab.database">{{
|
||||
tab.database
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
@@ -560,14 +643,32 @@ function formatInlineText(text: string): string {
|
||||
<div class="flex items-center gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<component :is="actionButtons.find(b => b.action === activeAction)?.icon" class="h-3 w-3" />
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<component :is="actionButtons.find((b) => b.action === activeAction)?.icon" class="h-3 w-3" />
|
||||
<span>{{ t(`ai.actions.${activeAction}`) }}</span>
|
||||
<svg class="h-3 w-3 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
<svg
|
||||
class="h-3 w-3 opacity-50"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="w-max min-w-0">
|
||||
<DropdownMenuItem v-for="btn in actionButtons" :key="btn.action" class="text-xs gap-1.5" @click="selectAction(btn.action)">
|
||||
<DropdownMenuItem
|
||||
v-for="btn in actionButtons"
|
||||
:key="btn.action"
|
||||
class="text-xs gap-1.5"
|
||||
@click="selectAction(btn.action)"
|
||||
>
|
||||
<component :is="btn.icon" class="h-3 w-3" />
|
||||
<span>{{ t(btn.key) }}</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -598,11 +699,11 @@ function formatInlineText(text: string): string {
|
||||
<Dialog v-model:open="showSettings">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('ai.settings') }}</DialogTitle>
|
||||
<DialogTitle>{{ t("ai.settings") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3 py-2">
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t('ai.provider') }}</Label>
|
||||
<Label class="text-right text-xs">{{ t("ai.provider") }}</Label>
|
||||
<Select :model-value="tempProvider" @update:model-value="(v: any) => selectProvider(v)">
|
||||
<SelectTrigger class="col-span-2 h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -618,7 +719,12 @@ function formatInlineText(text: string): string {
|
||||
</div>
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">Endpoint</Label>
|
||||
<Input v-model="tempEndpoint" placeholder="https://api.openai.com/v1" autocomplete="off" class="col-span-2 h-8 text-xs" />
|
||||
<Input
|
||||
v-model="tempEndpoint"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
autocomplete="off"
|
||||
class="col-span-2 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">Model</Label>
|
||||
@@ -627,21 +733,49 @@ function formatInlineText(text: string): string {
|
||||
<div v-if="tempProvider !== 'claude'" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">API</Label>
|
||||
<div class="col-span-2 flex gap-2">
|
||||
<Button size="sm" variant="outline" class="h-8 flex-1 text-xs" :class="{ 'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'completions' }" @click="tempApiStyle = 'completions'">/chat/completions</Button>
|
||||
<Button size="sm" variant="outline" class="h-8 flex-1 text-xs" :class="{ 'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'responses' }" @click="tempApiStyle = 'responses'">/responses</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-8 flex-1 text-xs"
|
||||
:class="{
|
||||
'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'completions',
|
||||
}"
|
||||
@click="tempApiStyle = 'completions'"
|
||||
>/chat/completions</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-8 flex-1 text-xs"
|
||||
:class="{
|
||||
'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'responses',
|
||||
}"
|
||||
@click="tempApiStyle = 'responses'"
|
||||
>/responses</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter class="flex items-center gap-2">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" :disabled="testingAi || !tempApiKey?.trim() || !tempEndpoint?.trim() || !tempModel?.trim()" @click="testAiConnection">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="testingAi || !tempApiKey?.trim() || !tempEndpoint?.trim() || !tempModel?.trim()"
|
||||
@click="testAiConnection"
|
||||
>
|
||||
<Loader2 v-if="testingAi" class="h-3 w-3 animate-spin mr-1" />
|
||||
{{ t('connection.test') }}
|
||||
{{ t("connection.test") }}
|
||||
</Button>
|
||||
<span v-if="testResult === 'success'" class="text-xs text-green-500">{{ t('connection.testSuccess') }}</span>
|
||||
<span v-else-if="testResult === 'error'" class="text-xs text-destructive truncate max-w-[200px]" :title="testError">{{ testError }}</span>
|
||||
<span v-if="testResult === 'success'" class="text-xs text-green-500">{{ t("connection.testSuccess") }}</span>
|
||||
<span
|
||||
v-else-if="testResult === 'error'"
|
||||
class="text-xs text-destructive truncate max-w-[200px]"
|
||||
:title="testError"
|
||||
>{{ testError }}</span
|
||||
>
|
||||
</div>
|
||||
<Button size="sm" @click="saveSettings">{{ t('grid.save') }}</Button>
|
||||
<Button size="sm" @click="saveSettings">{{ t("grid.save") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,27 +2,28 @@
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
withDefaults(defineProps<{
|
||||
sql?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
details?: string;
|
||||
confirmLabel?: string;
|
||||
}>(), {
|
||||
sql: "",
|
||||
title: "",
|
||||
message: "",
|
||||
details: "",
|
||||
confirmLabel: "",
|
||||
});
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
sql?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
details?: string;
|
||||
confirmLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
sql: "",
|
||||
title: "",
|
||||
message: "",
|
||||
details: "",
|
||||
confirmLabel: "",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [];
|
||||
@@ -40,18 +41,22 @@ function onConfirm() {
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle class="h-5 w-5" />
|
||||
{{ title || t('dangerDialog.title') }}
|
||||
{{ title || t("dangerDialog.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="py-4">
|
||||
<p class="text-sm text-muted-foreground mb-3">{{ message || t('dangerDialog.message') }}</p>
|
||||
<pre v-if="details || sql" class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap">{{ details || sql }}</pre>
|
||||
<p class="text-sm text-muted-foreground mb-3">{{ message || t("dangerDialog.message") }}</p>
|
||||
<pre
|
||||
v-if="details || sql"
|
||||
class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap"
|
||||
>{{ details || sql }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button variant="destructive" @click="onConfirm">{{ confirmLabel || t('dangerDialog.confirm') }}</Button>
|
||||
<Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" @click="onConfirm">{{ confirmLabel || t("dangerDialog.confirm") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,17 +4,11 @@ import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Settings } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
useSettingsStore, EDITOR_THEMES, FONT_FAMILIES, DEFAULT_EDITOR_SETTINGS,
|
||||
} from "@/stores/settingsStore";
|
||||
import { useSettingsStore, EDITOR_THEMES, FONT_FAMILIES, DEFAULT_EDITOR_SETTINGS } from "@/stores/settingsStore";
|
||||
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -35,14 +29,17 @@ const editTheme = ref(settingsStore.editorSettings.theme);
|
||||
const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
|
||||
|
||||
// Sync from store when dialog opens
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
editFontFamily.value = settingsStore.editorSettings.fontFamily;
|
||||
editFontSize.value = settingsStore.editorSettings.fontSize;
|
||||
editTheme.value = settingsStore.editorSettings.theme;
|
||||
editExecuteMode.value = settingsStore.editorSettings.executeMode;
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
editFontFamily.value = settingsStore.editorSettings.fontFamily;
|
||||
editFontSize.value = settingsStore.editorSettings.fontSize;
|
||||
editTheme.value = settingsStore.editorSettings.theme;
|
||||
editExecuteMode.value = settingsStore.editorSettings.executeMode;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function hasChanges(): boolean {
|
||||
return (
|
||||
@@ -75,11 +72,11 @@ function onExecuteModeChange(v: any) {
|
||||
}
|
||||
|
||||
function onFontFamilyChange(v: any) {
|
||||
if (typeof v === 'string') editFontFamily.value = v;
|
||||
if (typeof v === "string") editFontFamily.value = v;
|
||||
}
|
||||
|
||||
function onThemeChange(v: any) {
|
||||
if (typeof v === 'string') editTheme.value = v as typeof DEFAULT_EDITOR_SETTINGS.theme;
|
||||
if (typeof v === "string") editTheme.value = v as typeof DEFAULT_EDITOR_SETTINGS.theme;
|
||||
}
|
||||
|
||||
// ---------- CodeMirror preview ----------
|
||||
@@ -100,19 +97,21 @@ let fontThemeComp: import("@codemirror/state").Compartment | null = null;
|
||||
let themeComp: import("@codemirror/state").Compartment | null = null;
|
||||
let editorViewModule: typeof import("@codemirror/view") | null = null;
|
||||
|
||||
watch(previewSettings, async (ss) => {
|
||||
if (!previewView.value || !fontThemeComp || !themeComp || !editorViewModule) return;
|
||||
watch(
|
||||
previewSettings,
|
||||
async (ss) => {
|
||||
if (!previewView.value || !fontThemeComp || !themeComp || !editorViewModule) return;
|
||||
|
||||
const themeExt = await loadEditorTheme(ss.theme);
|
||||
previewView.value.dispatch({
|
||||
effects: [
|
||||
themeComp.reconfigure(themeExt),
|
||||
fontThemeComp.reconfigure(
|
||||
editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily)
|
||||
),
|
||||
],
|
||||
});
|
||||
}, { deep: true });
|
||||
const themeExt = await loadEditorTheme(ss.theme);
|
||||
previewView.value.dispatch({
|
||||
effects: [
|
||||
themeComp.reconfigure(themeExt),
|
||||
fontThemeComp.reconfigure(editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily)),
|
||||
],
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
let previewInitialized = false;
|
||||
|
||||
@@ -121,12 +120,7 @@ watch(previewRef, async (el) => {
|
||||
previewInitialized = true;
|
||||
if (previewView.value) return;
|
||||
|
||||
const [
|
||||
{ EditorView },
|
||||
{ EditorState, Compartment },
|
||||
{ sql, MySQL },
|
||||
{ basicSetup },
|
||||
] = await Promise.all([
|
||||
const [{ EditorView }, { EditorState, Compartment }, { sql, MySQL }, { basicSetup }] = await Promise.all([
|
||||
import("@codemirror/view"),
|
||||
import("@codemirror/state"),
|
||||
import("@codemirror/lang-sql"),
|
||||
@@ -146,25 +140,26 @@ watch(previewRef, async (el) => {
|
||||
basicSetup,
|
||||
sql({ dialect: MySQL }),
|
||||
themeComp.of(themeExt),
|
||||
fontThemeComp.of(
|
||||
editorFontTheme(EditorView, ss.fontSize, ss.fontFamily)
|
||||
),
|
||||
fontThemeComp.of(editorFontTheme(EditorView, ss.fontSize, ss.fontFamily)),
|
||||
],
|
||||
});
|
||||
|
||||
previewView.value = new EditorView({ state, parent: previewRef.value });
|
||||
});
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (!open && previewView.value) {
|
||||
previewView.value.destroy();
|
||||
previewView.value = null;
|
||||
previewInitialized = false;
|
||||
fontThemeComp = null;
|
||||
themeComp = null;
|
||||
editorViewModule = null;
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open && previewView.value) {
|
||||
previewView.value.destroy();
|
||||
previewView.value = null;
|
||||
previewInitialized = false;
|
||||
fontThemeComp = null;
|
||||
themeComp = null;
|
||||
editorViewModule = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -173,14 +168,14 @@ watch(() => props.open, (open) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Settings class="h-4 w-4" />
|
||||
{{ t('settings.title') }}
|
||||
{{ t("settings.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-5 py-2">
|
||||
<!-- Font Family -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.fontFamily') }}</Label>
|
||||
<Label>{{ t("settings.fontFamily") }}</Label>
|
||||
<Select :model-value="editFontFamily" @update:model-value="onFontFamilyChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('settings.selectFont')" />
|
||||
@@ -206,7 +201,7 @@ watch(() => props.open, (open) => {
|
||||
<!-- Font Size -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ t('settings.fontSize') }}</Label>
|
||||
<Label>{{ t("settings.fontSize") }}</Label>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ editFontSize }}px</span>
|
||||
</div>
|
||||
<input
|
||||
@@ -229,21 +224,21 @@ watch(() => props.open, (open) => {
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.theme') }}</Label>
|
||||
<Label>{{ t("settings.theme") }}</Label>
|
||||
<Select :model-value="editTheme" @update:model-value="onThemeChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('settings.selectTheme')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="theme in EDITOR_THEMES"
|
||||
:key="theme.value"
|
||||
:value="theme.value"
|
||||
>
|
||||
<SelectItem v-for="theme in EDITOR_THEMES" :key="theme.value" :value="theme.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-3 w-3 rounded-full border"
|
||||
:class="theme.dark ? 'bg-foreground border-foreground/20' : 'bg-muted-foreground/30 border-muted-foreground/40'"
|
||||
:class="
|
||||
theme.dark
|
||||
? 'bg-foreground border-foreground/20'
|
||||
: 'bg-muted-foreground/30 border-muted-foreground/40'
|
||||
"
|
||||
/>
|
||||
{{ theme.label }}
|
||||
</div>
|
||||
@@ -253,14 +248,14 @@ watch(() => props.open, (open) => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.executeMode') }}</Label>
|
||||
<Label>{{ t("settings.executeMode") }}</Label>
|
||||
<Select :model-value="editExecuteMode" @update:model-value="onExecuteModeChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('settings.executeMode')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{{ t('settings.executeModeAll') }}</SelectItem>
|
||||
<SelectItem value="current">{{ t('settings.executeModeCurrent') }}</SelectItem>
|
||||
<SelectItem value="all">{{ t("settings.executeModeAll") }}</SelectItem>
|
||||
<SelectItem value="current">{{ t("settings.executeModeCurrent") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -269,10 +264,14 @@ watch(() => props.open, (open) => {
|
||||
|
||||
<!-- Live Preview -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.preview') }}</Label>
|
||||
<Label>{{ t("settings.preview") }}</Label>
|
||||
<div
|
||||
class="rounded-md border overflow-auto max-w-full"
|
||||
:class="editTheme === 'vscode-light' || editTheme === 'duotone-light' || editTheme === 'xcode' ? 'border-border' : 'border-border/50'"
|
||||
:class="
|
||||
editTheme === 'vscode-light' || editTheme === 'duotone-light' || editTheme === 'xcode'
|
||||
? 'border-border'
|
||||
: 'border-border/50'
|
||||
"
|
||||
>
|
||||
<div ref="previewRef" style="min-width: 100%" />
|
||||
</div>
|
||||
@@ -281,14 +280,14 @@ watch(() => props.open, (open) => {
|
||||
|
||||
<DialogFooter class="gap-2 sm:gap-0">
|
||||
<Button variant="outline" @click="resetDefaults">
|
||||
{{ t('settings.resetDefaults') }}
|
||||
{{ t("settings.resetDefaults") }}
|
||||
</Button>
|
||||
<div class="flex-1" />
|
||||
<Button variant="outline" @click="emit('update:open', false)">
|
||||
{{ t('common.close') }}
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
<Button :disabled="!hasChanges()" @click="applySettings">
|
||||
{{ t('settings.apply') }}
|
||||
{{ t("settings.apply") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,10 +6,7 @@ import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
||||
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import {
|
||||
buildSqlCompletionItemsFromContext,
|
||||
getSqlCompletionContext,
|
||||
} from "@/lib/sqlCompletion";
|
||||
import { buildSqlCompletionItemsFromContext, getSqlCompletionContext } from "@/lib/sqlCompletion";
|
||||
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -23,9 +20,9 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
"selectionChange": [value: string];
|
||||
"cursorChange": [pos: number];
|
||||
"formatError": [message: string];
|
||||
selectionChange: [value: string];
|
||||
cursorChange: [pos: number];
|
||||
formatError: [message: string];
|
||||
execute: [sql: string];
|
||||
}>();
|
||||
|
||||
@@ -51,7 +48,7 @@ function setFontSize(size: number) {
|
||||
editorFontTheme(editorViewModule.EditorView, next, ss.fontFamily, {
|
||||
fixedHeight: true,
|
||||
scrollable: true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -104,10 +101,7 @@ async function formatCurrentSql() {
|
||||
}
|
||||
}
|
||||
|
||||
async function provideSqlCompletions(
|
||||
currentState: import("@codemirror/state").EditorState,
|
||||
position: number,
|
||||
) {
|
||||
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number) {
|
||||
if (!props.connectionId || !props.database) return null;
|
||||
|
||||
const completionContext = getSqlCompletionContext(currentState.doc.toString(), position);
|
||||
@@ -116,20 +110,24 @@ async function provideSqlCompletions(
|
||||
|
||||
if (completionContext.suggestColumns) {
|
||||
const relatedTables = completionContext.qualifier
|
||||
? completionContext.referencedTables.filter((table) => table.alias === completionContext.qualifier || table.name === completionContext.qualifier)
|
||||
? completionContext.referencedTables.filter(
|
||||
(table) => table.alias === completionContext.qualifier || table.name === completionContext.qualifier,
|
||||
)
|
||||
: completionContext.referencedTables;
|
||||
|
||||
await Promise.all(relatedTables.map(async (table) => {
|
||||
const cacheKey = table.schema ? `${table.schema}.${table.name}` : table.name;
|
||||
if (columnsByTable.has(cacheKey)) return;
|
||||
const columns = await connectionStore.listCompletionColumns(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
table.name,
|
||||
table.schema,
|
||||
);
|
||||
columnsByTable.set(cacheKey, columns);
|
||||
}));
|
||||
await Promise.all(
|
||||
relatedTables.map(async (table) => {
|
||||
const cacheKey = table.schema ? `${table.schema}.${table.name}` : table.name;
|
||||
if (columnsByTable.has(cacheKey)) return;
|
||||
const columns = await connectionStore.listCompletionColumns(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
table.name,
|
||||
table.schema,
|
||||
);
|
||||
columnsByTable.set(cacheKey, columns);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const items = buildSqlCompletionItemsFromContext(completionContext, {
|
||||
@@ -174,7 +172,8 @@ onMounted(async () => {
|
||||
const ss = settingsStore.editorSettings;
|
||||
|
||||
const baseDialect = props.dialect === "postgres" ? PostgreSQL : MySQL;
|
||||
const extraKeywords = "PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
|
||||
const extraKeywords =
|
||||
"PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
|
||||
const dialect = SQLDialect.define({
|
||||
...baseDialect.spec,
|
||||
keywords: (baseDialect.spec.keywords || "") + " " + extraKeywords,
|
||||
@@ -227,9 +226,7 @@ onMounted(async () => {
|
||||
sql({ dialect }),
|
||||
autocompletion({
|
||||
activateOnTyping: true,
|
||||
override: [
|
||||
async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos),
|
||||
],
|
||||
override: [async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos)],
|
||||
}),
|
||||
codeMirrorTheme.of(theme),
|
||||
runKeymap,
|
||||
@@ -253,7 +250,7 @@ onMounted(async () => {
|
||||
editorFontTheme(EditorView, ss.fontSize, ss.fontFamily, {
|
||||
fixedHeight: true,
|
||||
scrollable: true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
EditorView.domEventHandlers({
|
||||
wheel(event) {
|
||||
@@ -278,14 +275,14 @@ watch(
|
||||
changes: { from: 0, to: view.value.state.doc.length, insert: val },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.formatRequestId,
|
||||
(val, oldVal) => {
|
||||
if (val && val !== oldVal) formatCurrentSql();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Reactively apply editor settings changes (theme, font family, font size)
|
||||
@@ -301,12 +298,12 @@ watch(
|
||||
editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily, {
|
||||
fixedHeight: true,
|
||||
scrollable: true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -3,9 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, Trash2, Clock, X } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { shouldClearHistory, shouldDeleteHistoryEntry } from "@/lib/historyActions";
|
||||
|
||||
@@ -22,8 +20,11 @@ const searchText = ref("");
|
||||
const filtered = computed(() => {
|
||||
if (!searchText.value) return store.entries;
|
||||
const q = searchText.value.toLowerCase();
|
||||
return store.entries.filter((e) =>
|
||||
e.sql.toLowerCase().includes(q) || e.connection_name.toLowerCase().includes(q) || e.database.toLowerCase().includes(q)
|
||||
return store.entries.filter(
|
||||
(e) =>
|
||||
e.sql.toLowerCase().includes(q) ||
|
||||
e.connection_name.toLowerCase().includes(q) ||
|
||||
e.database.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -65,7 +66,7 @@ onMounted(() => store.load());
|
||||
<div class="h-full flex flex-col overflow-hidden border-l">
|
||||
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0 bg-muted/20">
|
||||
<Clock class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span class="text-xs font-medium">{{ t('history.title') }}</span>
|
||||
<span class="text-xs font-medium">{{ t("history.title") }}</span>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
@@ -102,20 +103,22 @@ onMounted(() => store.load());
|
||||
<div class="font-mono text-muted-foreground truncate">{{ truncateSql(entry.sql) }}</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span :class="entry.success ? 'text-green-500' : 'text-red-500'">
|
||||
{{ entry.success ? `${entry.execution_time_ms}ms` : t('history.failed') }}
|
||||
{{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="w-40">
|
||||
<ContextMenuItem @click="restore(entry.sql)">{{ t('history.restore') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySql(entry.sql)">{{ t('history.copy') }}</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{ t('history.delete') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="restore(entry.sql)">{{ t("history.restore") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySql(entry.sql)">{{ t("history.copy") }}</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{
|
||||
t("history.delete")
|
||||
}}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<div v-if="filtered.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t('history.empty') }}
|
||||
{{ t("history.empty") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,16 @@ defineProps<{
|
||||
<div class="truncate text-sm font-medium">{{ node.title }}</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span class="rounded bg-muted px-1.5 py-0.5">{{ node.nodeType }}</span>
|
||||
<span v-if="node.relation" class="rounded bg-blue-50 px-1.5 py-0.5 text-blue-700 dark:bg-blue-950 dark:text-blue-300">{{ node.relation }}</span>
|
||||
<span v-if="node.index" class="rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">{{ node.index }}</span>
|
||||
<span
|
||||
v-if="node.relation"
|
||||
class="rounded bg-blue-50 px-1.5 py-0.5 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
|
||||
>{{ node.relation }}</span
|
||||
>
|
||||
<span
|
||||
v-if="node.index"
|
||||
class="rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
>{{ node.index }}</span
|
||||
>
|
||||
<span v-if="node.cost">{{ node.cost }}</span>
|
||||
<span v-if="node.rows">{{ node.rows }} rows</span>
|
||||
</div>
|
||||
@@ -30,12 +38,7 @@ defineProps<{
|
||||
</div>
|
||||
|
||||
<div v-if="node.children.length" class="space-y-2 border-l pl-4">
|
||||
<ExplainPlanNodeTree
|
||||
v-for="child in node.children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:depth="(depth || 0) + 1"
|
||||
/>
|
||||
<ExplainPlanNodeTree v-for="child in node.children" :key="child.id" :node="child" :depth="(depth || 0) + 1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,8 +28,8 @@ const flatRows = computed(() => {
|
||||
return rows;
|
||||
});
|
||||
|
||||
const rawJson = computed(() => props.plan ? JSON.stringify(props.plan.raw, null, 2) : "");
|
||||
const nodeCount = computed(() => props.plan ? flattenExplainPlanNodes(props.plan.nodes).length : 0);
|
||||
const rawJson = computed(() => (props.plan ? JSON.stringify(props.plan.raw, null, 2) : ""));
|
||||
const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.plan.nodes).length : 0));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,20 +37,37 @@ const nodeCount = computed(() => props.plan ? flattenExplainPlanNodes(props.plan
|
||||
<div class="h-9 shrink-0 border-b px-3 flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1 rounded border bg-muted px-2 py-0.5 font-medium">
|
||||
<GitBranch class="h-3.5 w-3.5" />
|
||||
{{ t('explain.title') }}
|
||||
{{ t("explain.title") }}
|
||||
</span>
|
||||
<span v-if="plan" class="text-muted-foreground">{{ plan.databaseType.toUpperCase() }} · {{ t('explain.nodeCount', { count: nodeCount }) }}</span>
|
||||
<span v-if="plan" class="text-muted-foreground"
|
||||
>{{ plan.databaseType.toUpperCase() }} · {{ t("explain.nodeCount", { count: nodeCount }) }}</span
|
||||
>
|
||||
<span class="flex-1" />
|
||||
<div v-if="plan" class="inline-flex rounded-md border bg-muted/40 p-0.5">
|
||||
<Button size="sm" :variant="activeView === 'tree' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'tree'">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'tree' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
@click="activeView = 'tree'"
|
||||
>
|
||||
<GitBranch class="h-3.5 w-3.5" />
|
||||
{{ t('explain.tree') }}
|
||||
{{ t("explain.tree") }}
|
||||
</Button>
|
||||
<Button size="sm" :variant="activeView === 'summary' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'summary'">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'summary' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
@click="activeView = 'summary'"
|
||||
>
|
||||
<Table2 class="h-3.5 w-3.5" />
|
||||
{{ t('explain.summary') }}
|
||||
{{ t("explain.summary") }}
|
||||
</Button>
|
||||
<Button size="sm" :variant="activeView === 'json' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'json'">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'json' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
@click="activeView = 'json'"
|
||||
>
|
||||
<Braces class="h-3.5 w-3.5" />
|
||||
JSON
|
||||
</Button>
|
||||
@@ -58,18 +75,20 @@ const nodeCount = computed(() => props.plan ? flattenExplainPlanNodes(props.plan
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex-1 min-h-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t('explain.running') }}
|
||||
{{ t("explain.running") }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex-1 min-h-0 flex items-center justify-center">
|
||||
<div class="flex max-w-xl items-start gap-2 rounded border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<div
|
||||
class="flex max-w-xl items-start gap-2 rounded border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!plan" class="flex-1 min-h-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t('explain.empty') }}
|
||||
{{ t("explain.empty") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 min-h-0 overflow-auto">
|
||||
@@ -82,22 +101,26 @@ const nodeCount = computed(() => props.plan ? flattenExplainPlanNodes(props.plan
|
||||
<table class="w-full min-w-[760px] text-left text-xs">
|
||||
<thead class="bg-muted/70 text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t('explain.node') }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t('explain.relation') }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t('explain.index') }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t('explain.cost') }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t('explain.rows') }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t('explain.details') }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t("explain.node") }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t("explain.relation") }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t("explain.index") }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t("explain.cost") }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t("explain.rows") }}</th>
|
||||
<th class="px-2 py-1.5 font-medium">{{ t("explain.details") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in flatRows" :key="row.node.id" class="border-t">
|
||||
<td class="px-2 py-1.5 font-medium" :style="{ paddingLeft: `${8 + row.depth * 18}px` }">{{ row.node.title }}</td>
|
||||
<td class="px-2 py-1.5 text-muted-foreground">{{ row.node.relation || '-' }}</td>
|
||||
<td class="px-2 py-1.5 text-muted-foreground">{{ row.node.index || '-' }}</td>
|
||||
<td class="px-2 py-1.5 tabular-nums">{{ row.node.cost || '-' }}</td>
|
||||
<td class="px-2 py-1.5 tabular-nums">{{ row.node.rows || '-' }}</td>
|
||||
<td class="px-2 py-1.5 text-muted-foreground">{{ row.node.details.join('; ') || '-' }}</td>
|
||||
<td class="px-2 py-1.5 font-medium" :style="{ paddingLeft: `${8 + row.depth * 18}px` }">
|
||||
{{ row.node.title }}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-muted-foreground">{{ row.node.relation || "-" }}</td>
|
||||
<td class="px-2 py-1.5 text-muted-foreground">{{ row.node.index || "-" }}</td>
|
||||
<td class="px-2 py-1.5 tabular-nums">{{ row.node.cost || "-" }}</td>
|
||||
<td class="px-2 py-1.5 tabular-nums">{{ row.node.rows || "-" }}</td>
|
||||
<td class="px-2 py-1.5 text-muted-foreground">
|
||||
{{ row.node.details.join("; ") || "-" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+381
-233
@@ -7,18 +7,42 @@ const globalDdlOpen = ref(false);
|
||||
import { computed, nextTick, onUnmounted, watch } from "vue";
|
||||
import { useElementSize } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowUp, ArrowDown, Download, Plus, Trash2, Save, ChevronLeft, ChevronRight, Search, Inbox, SearchX, Code2, Copy, Loader2, X, Undo2, WrapText, Info, Rows3 } from "lucide-vue-next";
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Download,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Inbox,
|
||||
SearchX,
|
||||
Code2,
|
||||
Copy,
|
||||
Loader2,
|
||||
X,
|
||||
Undo2,
|
||||
WrapText,
|
||||
Info,
|
||||
Rows3,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem,
|
||||
ContextMenuSeparator, ContextMenuTrigger,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import type { QueryResult, ColumnInfo, DatabaseType } from "@/types/database";
|
||||
@@ -122,11 +146,45 @@ function shortTypeName(t: string): string {
|
||||
function typeColorClass(t: string): string {
|
||||
// Strip precision/scale suffix like (20,6)
|
||||
const base = t.replace(/\(.*\)$/, "").toLowerCase();
|
||||
if (["int", "int2", "int4", "int8", "smallint", "bigint", "integer", "serial", "bigserial", "tinyint", "mediumint"].includes(base)) return "text-blue-500";
|
||||
if (["float4", "float8", "double", "decimal", "numeric", "real", "float", "money"].includes(base)) return "text-cyan-500";
|
||||
if (["varchar", "text", "char", "character varying", "character", "string", "nvarchar", "nchar", "ntext", "longtext", "mediumtext", "tinytext", "clob"].includes(base)) return "text-green-500";
|
||||
if (
|
||||
[
|
||||
"int",
|
||||
"int2",
|
||||
"int4",
|
||||
"int8",
|
||||
"smallint",
|
||||
"bigint",
|
||||
"integer",
|
||||
"serial",
|
||||
"bigserial",
|
||||
"tinyint",
|
||||
"mediumint",
|
||||
].includes(base)
|
||||
)
|
||||
return "text-blue-500";
|
||||
if (["float4", "float8", "double", "decimal", "numeric", "real", "float", "money"].includes(base))
|
||||
return "text-cyan-500";
|
||||
if (
|
||||
[
|
||||
"varchar",
|
||||
"text",
|
||||
"char",
|
||||
"character varying",
|
||||
"character",
|
||||
"string",
|
||||
"nvarchar",
|
||||
"nchar",
|
||||
"ntext",
|
||||
"longtext",
|
||||
"mediumtext",
|
||||
"tinytext",
|
||||
"clob",
|
||||
].includes(base)
|
||||
)
|
||||
return "text-green-500";
|
||||
if (["bool", "boolean", "bit"].includes(base)) return "text-orange-500";
|
||||
if (["timestamp", "timestamptz", "datetime", "date", "time", "timetz", "datetime2", "smalldatetime"].includes(base)) return "text-purple-500";
|
||||
if (["timestamp", "timestamptz", "datetime", "date", "time", "timetz", "datetime2", "smalldatetime"].includes(base))
|
||||
return "text-purple-500";
|
||||
if (["json", "jsonb", "xml", "array"].includes(base)) return "text-pink-500";
|
||||
if (["uuid", "uniqueidentifier"].includes(base)) return "text-amber-500";
|
||||
if (["bytea", "blob", "binary", "varbinary", "image"].includes(base)) return "text-red-400";
|
||||
@@ -218,10 +276,7 @@ function dismissSuggestions() {
|
||||
|
||||
function navigateSuggestion(delta: number) {
|
||||
if (searchSuggestions.value.length === 0) return;
|
||||
suggestionIndex.value = Math.min(
|
||||
Math.max(suggestionIndex.value + delta, 0),
|
||||
searchSuggestions.value.length - 1,
|
||||
);
|
||||
suggestionIndex.value = Math.min(Math.max(suggestionIndex.value + delta, 0), searchSuggestions.value.length - 1);
|
||||
}
|
||||
|
||||
const PAIRS: Record<string, string> = { "'": "'", '"': '"', "(": ")" };
|
||||
@@ -325,7 +380,9 @@ function onResizeStart(colIdx: number, event: MouseEvent) {
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
requestAnimationFrame(() => { isResizing = false; });
|
||||
requestAnimationFrame(() => {
|
||||
isResizing = false;
|
||||
});
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
@@ -351,7 +408,7 @@ const columnVars = computed(() => {
|
||||
vars[`--col-w-${i}`] = `${w}px`;
|
||||
});
|
||||
vars["--row-num-w"] = `${ROW_NUM_WIDTH}px`;
|
||||
vars['--total-w'] = `${totalWidth.value}px`;
|
||||
vars["--total-w"] = `${totalWidth.value}px`;
|
||||
return vars;
|
||||
});
|
||||
|
||||
@@ -365,8 +422,8 @@ const isFullPage = computed(() => props.result.rows.length >= pageSize.value);
|
||||
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql);
|
||||
const isWhereSearch = computed(() => canUseWhereSearch.value && /^\s*where\b/i.test(searchText.value));
|
||||
const wherePredicate = computed(() => normalizeWhereInput(searchText.value));
|
||||
const activeWhereInput = computed(() => isWhereSearch.value && wherePredicate.value ? searchText.value : undefined);
|
||||
const clientSearchText = computed(() => isWhereSearch.value ? "" : searchText.value);
|
||||
const activeWhereInput = computed(() => (isWhereSearch.value && wherePredicate.value ? searchText.value : undefined));
|
||||
const clientSearchText = computed(() => (isWhereSearch.value ? "" : searchText.value));
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value <= 1) return;
|
||||
@@ -397,9 +454,7 @@ const dirtyRowCount = computed(() => dirtyRows.value.size);
|
||||
const newRowCount = computed(() => newRows.value.length);
|
||||
const deletedRowCount = computed(() => deletedRows.value.size);
|
||||
const pendingChangeCount = computed(() => dirtyRowCount.value + newRowCount.value + deletedRowCount.value);
|
||||
const hasPendingChanges = computed(() =>
|
||||
pendingChangeCount.value > 0
|
||||
);
|
||||
const hasPendingChanges = computed(() => pendingChangeCount.value > 0);
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
let rows = props.result.rows.map((row, sourceIndex) => ({ row, sourceIndex }));
|
||||
@@ -440,25 +495,37 @@ const displayItems = computed<RowItem[]>(() => {
|
||||
return { id: sourceIndex, sourceIndex, data, isNew: false, isDeleted, isDirtyCol, status };
|
||||
});
|
||||
newRows.value.forEach((row, i) => {
|
||||
items.push({ id: -(i + 1), newIndex: i, data: row, isNew: true, isDeleted: false, isDirtyCol: cols.map(() => false), status: "new" });
|
||||
items.push({
|
||||
id: -(i + 1),
|
||||
newIndex: i,
|
||||
data: row,
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
isDirtyCol: cols.map(() => false),
|
||||
status: "new",
|
||||
});
|
||||
});
|
||||
return items.filter((item) => matchesRowStatusFilter(item.status, rowStatusFilter.value));
|
||||
});
|
||||
const hasVisibleRows = computed(() => displayItems.value.length > 0);
|
||||
const hasActiveFilter = computed(() => !!clientSearchText.value || rowStatusFilter.value !== "all");
|
||||
const totalFilterableRowCount = computed(() => props.result.rows.length + newRows.value.length);
|
||||
const emptyTitle = computed(() => hasActiveFilter.value ? t('grid.noFilteredRows') : t('grid.noRows'));
|
||||
const emptyDescription = computed(() => hasActiveFilter.value ? t('grid.noFilteredRowsDescription') : t('grid.noRowsDescription'));
|
||||
const emptyTitle = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRows") : t("grid.noRows")));
|
||||
const emptyDescription = computed(() =>
|
||||
hasActiveFilter.value ? t("grid.noFilteredRowsDescription") : t("grid.noRowsDescription"),
|
||||
);
|
||||
const selectedRange = computed<CellSelectionRange | null>(() => {
|
||||
if (!selectionAnchor.value || !selectionFocus.value) return null;
|
||||
return normalizeSelectionRange(selectionAnchor.value, selectionFocus.value);
|
||||
});
|
||||
const visibleSelectionRows = computed(() => displayItems.value.map((item) => item.data));
|
||||
const selectedCells = computed(() => extractSelection(props.result.columns, visibleSelectionRows.value, selectedRange.value));
|
||||
const selectedCells = computed(() =>
|
||||
extractSelection(props.result.columns, visibleSelectionRows.value, selectedRange.value),
|
||||
);
|
||||
const selectedCellCount = computed(() => selectedCells.value.columns.length * selectedCells.value.rows.length);
|
||||
const hasCellSelection = computed(() => selectedCellCount.value > 0);
|
||||
const selectionSummary = computed(() => t("grid.selectedCells", { count: selectedCellCount.value }));
|
||||
const contextRowItem = computed(() => contextCell.value ? getRowItem(contextCell.value.rowId) : undefined);
|
||||
const contextRowItem = computed(() => (contextCell.value ? getRowItem(contextCell.value.rowId) : undefined));
|
||||
const activeCellDetail = computed(() => {
|
||||
const cell = detailCell.value;
|
||||
if (!cell) return null;
|
||||
@@ -494,8 +561,14 @@ const activeCellDetail = computed(() => {
|
||||
function toggleSort(colName: string) {
|
||||
if (isResizing) return;
|
||||
if (sortCol.value === colName) {
|
||||
if (sortDir.value === "asc") { sortDir.value = "desc"; emit("sort", colName, "desc", activeWhereInput.value); }
|
||||
else { sortCol.value = null; sortDir.value = "asc"; emit("sort", colName, null, activeWhereInput.value); }
|
||||
if (sortDir.value === "asc") {
|
||||
sortDir.value = "desc";
|
||||
emit("sort", colName, "desc", activeWhereInput.value);
|
||||
} else {
|
||||
sortCol.value = null;
|
||||
sortDir.value = "asc";
|
||||
emit("sort", colName, null, activeWhereInput.value);
|
||||
}
|
||||
} else {
|
||||
sortCol.value = colName;
|
||||
sortDir.value = "asc";
|
||||
@@ -546,7 +619,9 @@ function escapeVal(value: CellValue): string {
|
||||
return formatGridSqlLiteral(value);
|
||||
}
|
||||
|
||||
function isNull(value: unknown): boolean { return value === null; }
|
||||
function isNull(value: unknown): boolean {
|
||||
return value === null;
|
||||
}
|
||||
|
||||
function rowNumberStatusClass(item: RowItem): string {
|
||||
if (item.status === "new") {
|
||||
@@ -692,8 +767,14 @@ function cancelEdit() {
|
||||
}
|
||||
|
||||
function onEditKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") { e.preventDefault(); commitEdit(); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancelEdit(); }
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
@@ -724,7 +805,7 @@ const pendingDeleteRowId = ref<number | null>(null);
|
||||
const deleteRowDetails = computed(() =>
|
||||
props.tableMeta?.tableName
|
||||
? t("dangerDialog.deleteRowDetails", { table: props.tableMeta.tableName })
|
||||
: t("dangerDialog.deleteRowDetailsNoTable")
|
||||
: t("dangerDialog.deleteRowDetailsNoTable"),
|
||||
);
|
||||
|
||||
function requestDeleteRow(rowId: number) {
|
||||
@@ -842,7 +923,7 @@ function showCellDetails(rowIndex: number, colIndex: number) {
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast(t('grid.copied'));
|
||||
toast(t("grid.copied"));
|
||||
}
|
||||
|
||||
function copySelectionTsv() {
|
||||
@@ -879,9 +960,7 @@ function copyDetailSqlCondition() {
|
||||
const detail = activeCellDetail.value;
|
||||
if (!detail) return;
|
||||
const column = quoteIdent(detail.column);
|
||||
const condition = detail.value === null
|
||||
? `${column} IS NULL`
|
||||
: `${column} = ${escapeVal(detail.value)}`;
|
||||
const condition = detail.value === null ? `${column} IS NULL` : `${column} = ${escapeVal(detail.value)}`;
|
||||
copyText(condition);
|
||||
}
|
||||
|
||||
@@ -912,13 +991,16 @@ function transposeNav(delta: number) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.result, () => {
|
||||
clearCellSelection();
|
||||
showCellDetail.value = false;
|
||||
detailCell.value = null;
|
||||
showTranspose.value = false;
|
||||
transposeRowIndex.value = null;
|
||||
});
|
||||
watch(
|
||||
() => props.result,
|
||||
() => {
|
||||
clearCellSelection();
|
||||
showCellDetail.value = false;
|
||||
detailCell.value = null;
|
||||
showTranspose.value = false;
|
||||
transposeRowIndex.value = null;
|
||||
},
|
||||
);
|
||||
|
||||
// --- Copy/Export ---
|
||||
function onCellContext(rowId: number, rowIndex: number, colIdx: number) {
|
||||
@@ -940,15 +1022,15 @@ function copyRow() {
|
||||
const item = getRowItem(contextCell.value.rowId);
|
||||
if (!item) return;
|
||||
const obj: Record<string, unknown> = {};
|
||||
props.result.columns.forEach((col, i) => { obj[col] = item.data[i]; });
|
||||
props.result.columns.forEach((col, i) => {
|
||||
obj[col] = item.data[i];
|
||||
});
|
||||
copyText(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
const header = props.result.columns.join("\t");
|
||||
const body = displayItems.value
|
||||
.map((item) => item.data.map((c) => formatCell(c)).join("\t"))
|
||||
.join("\n");
|
||||
const body = displayItems.value.map((item) => item.data.map((c) => formatCell(c)).join("\t")).join("\n");
|
||||
copyText(`${header}\n${body}`);
|
||||
}
|
||||
|
||||
@@ -956,7 +1038,10 @@ async function saveFileContent(content: string, defaultFileName: string, filterN
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({ defaultPath: defaultFileName, filters: [{ name: filterName, extensions: [filterExt] }] });
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
if (path) await writeTextFile(path, content);
|
||||
} else {
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
@@ -972,16 +1057,16 @@ async function saveFileContent(content: string, defaultFileName: string, filterN
|
||||
async function exportCsv() {
|
||||
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const header = props.result.columns.map(escape).join(",");
|
||||
const body = displayItems.value
|
||||
.map((item) => item.data.map((c) => escape(formatCell(c))).join(","))
|
||||
.join("\n");
|
||||
const body = displayItems.value.map((item) => item.data.map((c) => escape(formatCell(c))).join(",")).join("\n");
|
||||
await saveFileContent(`${header}\n${body}`, "export.csv", "CSV", "csv");
|
||||
}
|
||||
|
||||
async function exportJson() {
|
||||
const data = displayItems.value.map((item) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
props.result.columns.forEach((col, i) => { obj[col] = item.data[i]; });
|
||||
props.result.columns.forEach((col, i) => {
|
||||
obj[col] = item.data[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
await saveFileContent(JSON.stringify(data, null, 2), "export.json", "JSON", "json");
|
||||
@@ -999,7 +1084,7 @@ const sqlOneLiner = computed(() => props.sql?.replace(/\s+/g, " ").trim() || "")
|
||||
function copySql() {
|
||||
if (!props.sql) return;
|
||||
navigator.clipboard.writeText(props.sql);
|
||||
toast(t('grid.copied'));
|
||||
toast(t("grid.copied"));
|
||||
}
|
||||
|
||||
const showDdl = globalDdlOpen;
|
||||
@@ -1047,7 +1132,7 @@ if (showDdl.value && props.tableMeta && props.connectionId) {
|
||||
|
||||
function copyDdl() {
|
||||
navigator.clipboard.writeText(ddlContent.value);
|
||||
toast(t('grid.copied'));
|
||||
toast(t("grid.copied"));
|
||||
}
|
||||
|
||||
function toggleDdlWrap() {
|
||||
@@ -1081,7 +1166,8 @@ onUnmounted(() => {
|
||||
finishCellSelection();
|
||||
});
|
||||
|
||||
const SQL_KEYWORDS = /\b(CREATE|TABLE|INDEX|UNIQUE|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|NOT|NULL|DEFAULT|INT|INTEGER|BIGINT|SMALLINT|VARCHAR|CHARACTER|VARYING|TEXT|BOOLEAN|DOUBLE|PRECISION|REAL|FLOAT|NUMERIC|DECIMAL|TIMESTAMP|DATE|TIME|SERIAL|AUTOINCREMENT|AUTO_INCREMENT|IF|EXISTS|ON|SET|CASCADE|RESTRICT|CHECK|WITH|WITHOUT|ZONE)\b/gi;
|
||||
const SQL_KEYWORDS =
|
||||
/\b(CREATE|TABLE|INDEX|UNIQUE|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|NOT|NULL|DEFAULT|INT|INTEGER|BIGINT|SMALLINT|VARCHAR|CHARACTER|VARYING|TEXT|BOOLEAN|DOUBLE|PRECISION|REAL|FLOAT|NUMERIC|DECIMAL|TIMESTAMP|DATE|TIME|SERIAL|AUTOINCREMENT|AUTO_INCREMENT|IF|EXISTS|ON|SET|CASCADE|RESTRICT|CHECK|WITH|WITHOUT|ZONE)\b/gi;
|
||||
|
||||
function highlightSql(sql: string): string {
|
||||
const tokens: string[] = [];
|
||||
@@ -1103,7 +1189,9 @@ function highlightSql(sql: string): string {
|
||||
|
||||
function escapeAndHighlightKeywords(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(SQL_KEYWORDS, '<span class="ddl-kw">$1</span>');
|
||||
}
|
||||
</script>
|
||||
@@ -1127,7 +1215,11 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
@keydown="onSearchKeydown"
|
||||
@click="updateSuggestionPosition"
|
||||
/>
|
||||
<span ref="measureRef" class="invisible absolute left-0 top-0 text-xs whitespace-pre pointer-events-none" aria-hidden="true" />
|
||||
<span
|
||||
ref="measureRef"
|
||||
class="invisible absolute left-0 top-0 text-xs whitespace-pre pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Suggestion dropdown -->
|
||||
<div
|
||||
v-if="searchSuggestions.length > 0"
|
||||
@@ -1139,7 +1231,10 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
:key="sug"
|
||||
class="flex items-center px-3 py-1.5 text-xs cursor-pointer"
|
||||
:class="idx === suggestionIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'"
|
||||
@mousedown.prevent="suggestionIndex = idx; acceptSuggestion()"
|
||||
@mousedown.prevent="
|
||||
suggestionIndex = idx;
|
||||
acceptSuggestion();
|
||||
"
|
||||
@mouseenter="suggestionIndex = idx"
|
||||
>
|
||||
<Search class="w-3 h-3 mr-2 text-muted-foreground shrink-0" />
|
||||
@@ -1155,11 +1250,11 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
<SelectValue :placeholder="t('grid.filterRows')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{{ t('grid.filterAllRows') }}</SelectItem>
|
||||
<SelectItem value="changed">{{ t('grid.filterChangedRows') }}</SelectItem>
|
||||
<SelectItem value="edited">{{ t('grid.statusEdited') }}</SelectItem>
|
||||
<SelectItem value="new">{{ t('grid.statusNew') }}</SelectItem>
|
||||
<SelectItem value="deleted">{{ t('grid.statusDeleted') }}</SelectItem>
|
||||
<SelectItem value="all">{{ t("grid.filterAllRows") }}</SelectItem>
|
||||
<SelectItem value="changed">{{ t("grid.filterChangedRows") }}</SelectItem>
|
||||
<SelectItem value="edited">{{ t("grid.statusEdited") }}</SelectItem>
|
||||
<SelectItem value="new">{{ t("grid.statusNew") }}</SelectItem>
|
||||
<SelectItem value="deleted">{{ t("grid.statusDeleted") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span v-if="hasActiveFilter" class="text-xs text-muted-foreground">
|
||||
@@ -1175,12 +1270,25 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
>
|
||||
<Loader2 v-if="isApplyingWhere" class="w-3 h-3 mr-1 animate-spin" />
|
||||
<Search v-else class="w-3 h-3 mr-1" />
|
||||
{{ t('grid.applyWhere') }}
|
||||
{{ t("grid.applyWhere") }}
|
||||
</Button>
|
||||
<Button v-if="editable && tableMeta" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t('grid.addRow') }}
|
||||
<Button
|
||||
v-if="editable && tableMeta"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 text-xs px-1.5 shrink-0"
|
||||
@click="addRow"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t("grid.addRow") }}
|
||||
</Button>
|
||||
<Button v-if="tableMeta && connectionId" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent': showDdl }" @click="toggleDdl">
|
||||
<Button
|
||||
v-if="tableMeta && connectionId"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 text-xs px-1.5 shrink-0"
|
||||
:class="{ 'bg-accent': showDdl }"
|
||||
@click="toggleDdl"
|
||||
>
|
||||
<Code2 class="w-3 h-3 mr-1" /> DDL
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1188,124 +1296,129 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
<div class="flex-1 flex min-h-0 overflow-hidden">
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<!-- Sticky header -->
|
||||
<div ref="headerRef" class="shrink-0 bg-muted z-10 border-b border-border overflow-hidden">
|
||||
<div class="flex text-xs font-medium" :style="{ width: 'var(--total-w)' }">
|
||||
<div class="shrink-0 px-2 py-1.5 border-r border-border text-center text-muted-foreground select-none" :style="{ width: 'var(--row-num-w)' }">#</div>
|
||||
<div
|
||||
v-for="(col, colIdx) in result.columns"
|
||||
:key="col"
|
||||
class="shrink-0 px-3 py-1.5 border-r border-border whitespace-nowrap cursor-pointer hover:bg-accent/50 select-none relative overflow-hidden"
|
||||
:style="{ width: `var(--col-w-${colIdx})` }"
|
||||
:title="columnCommentMap.get(col)"
|
||||
@click="toggleSort(col)"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<span class="min-w-0 truncate">{{ col }}</span>
|
||||
<ArrowUp v-if="sortCol === col && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
|
||||
<ArrowDown v-else-if="sortCol === col && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
|
||||
<span
|
||||
v-if="columnTypeMap.get(col)"
|
||||
class="shrink overflow-hidden truncate text-[10px] font-normal"
|
||||
:class="typeColorClass(columnTypeMap.get(col)!)"
|
||||
<div ref="headerRef" class="shrink-0 bg-muted z-10 border-b border-border overflow-hidden">
|
||||
<div class="flex text-xs font-medium" :style="{ width: 'var(--total-w)' }">
|
||||
<div
|
||||
class="shrink-0 px-2 py-1.5 border-r border-border text-center text-muted-foreground select-none"
|
||||
:style="{ width: 'var(--row-num-w)' }"
|
||||
>
|
||||
#{{ columnTypeMap.get(col) }}
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
|
||||
@mousedown.stop="onResizeStart(colIdx, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hasVisibleRows"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground"
|
||||
>
|
||||
<component
|
||||
:is="hasActiveFilter ? SearchX : Inbox"
|
||||
class="h-8 w-8 text-muted-foreground/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-foreground">{{ emptyTitle }}</div>
|
||||
<div class="text-xs">{{ emptyDescription }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual scrolled rows -->
|
||||
<RecycleScroller
|
||||
v-else
|
||||
ref="scrollerRef"
|
||||
class="data-grid-scroller flex-1 overflow-x-auto"
|
||||
:items="displayItems"
|
||||
:item-size="26"
|
||||
key-field="id"
|
||||
@scroll="syncHeaderScroll"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div
|
||||
class="flex text-xs border-b border-border hover:bg-accent/50"
|
||||
:class="{
|
||||
'bg-destructive/5 opacity-70': item.isDeleted,
|
||||
'bg-primary/5': item.isNew,
|
||||
'bg-muted/30': !item.isNew && !item.isDeleted && index % 2 === 1,
|
||||
}"
|
||||
:style="{ height: '26px', width: 'var(--total-w)' }"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 px-2 py-1 border-r border-border text-center select-none"
|
||||
:class="rowNumberStatusClass(item)"
|
||||
:style="{ width: 'var(--row-num-w)' }"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(cell, colIdx) in item.data"
|
||||
:key="colIdx"
|
||||
class="group/cell shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative select-none"
|
||||
:style="{ width: `var(--col-w-${colIdx})` }"
|
||||
:class="{
|
||||
'text-muted-foreground italic': isNull(cell),
|
||||
'bg-yellow-500/10': item.isDirtyCol[colIdx],
|
||||
'cell-selected': cellIsSelected(index, colIdx),
|
||||
'tabular-nums': typeof cell === 'number',
|
||||
'cursor-text hover:bg-accent/50': editable && !item.isDeleted,
|
||||
'line-through': item.isDeleted,
|
||||
}"
|
||||
@mousedown="beginCellSelection(index, colIdx, $event)"
|
||||
@mouseenter="extendCellSelection(index, colIdx)"
|
||||
@dblclick="editable && !item.isDeleted && startEdit(item.id, colIdx)"
|
||||
@contextmenu="onCellContext(item.id, index, colIdx)"
|
||||
>
|
||||
<template v-if="editingCell?.rowId === item.id && editingCell?.col === colIdx">
|
||||
<input
|
||||
v-model="editValue"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2 py-0.5 text-xs outline-none z-10"
|
||||
@blur="commitEdit"
|
||||
@click.stop
|
||||
@keydown.stop="onEditKeydown"
|
||||
#
|
||||
</div>
|
||||
<div
|
||||
v-for="(col, colIdx) in result.columns"
|
||||
:key="col"
|
||||
class="shrink-0 px-3 py-1.5 border-r border-border whitespace-nowrap cursor-pointer hover:bg-accent/50 select-none relative overflow-hidden"
|
||||
:style="{ width: `var(--col-w-${colIdx})` }"
|
||||
:title="columnCommentMap.get(col)"
|
||||
@click="toggleSort(col)"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<span class="min-w-0 truncate">{{ col }}</span>
|
||||
<ArrowUp v-if="sortCol === col && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
|
||||
<ArrowDown v-else-if="sortCol === col && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
|
||||
<span
|
||||
v-if="columnTypeMap.get(col)"
|
||||
class="shrink overflow-hidden truncate text-[10px] font-normal"
|
||||
:class="typeColorClass(columnTypeMap.get(col)!)"
|
||||
>
|
||||
#{{ columnTypeMap.get(col) }}
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
|
||||
@mousedown.stop="onResizeStart(colIdx, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatCell(cell) }}
|
||||
<button
|
||||
class="absolute right-0.5 top-0.5 hidden h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground group-hover/cell:flex"
|
||||
:title="t('grid.cellDetails')"
|
||||
@mousedown.stop
|
||||
@click.stop="showCellDetails(index, colIdx)"
|
||||
>
|
||||
<Info class="h-3 w-3" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
|
||||
<div
|
||||
v-if="!hasVisibleRows"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground"
|
||||
>
|
||||
<component
|
||||
:is="hasActiveFilter ? SearchX : Inbox"
|
||||
class="h-8 w-8 text-muted-foreground/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-foreground">{{ emptyTitle }}</div>
|
||||
<div class="text-xs">{{ emptyDescription }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual scrolled rows -->
|
||||
<RecycleScroller
|
||||
v-else
|
||||
ref="scrollerRef"
|
||||
class="data-grid-scroller flex-1 overflow-x-auto"
|
||||
:items="displayItems"
|
||||
:item-size="26"
|
||||
key-field="id"
|
||||
@scroll="syncHeaderScroll"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div
|
||||
class="flex text-xs border-b border-border hover:bg-accent/50"
|
||||
:class="{
|
||||
'bg-destructive/5 opacity-70': item.isDeleted,
|
||||
'bg-primary/5': item.isNew,
|
||||
'bg-muted/30': !item.isNew && !item.isDeleted && index % 2 === 1,
|
||||
}"
|
||||
:style="{ height: '26px', width: 'var(--total-w)' }"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 px-2 py-1 border-r border-border text-center select-none"
|
||||
:class="rowNumberStatusClass(item)"
|
||||
:style="{ width: 'var(--row-num-w)' }"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(cell, colIdx) in item.data"
|
||||
:key="colIdx"
|
||||
class="group/cell shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative select-none"
|
||||
:style="{ width: `var(--col-w-${colIdx})` }"
|
||||
:class="{
|
||||
'text-muted-foreground italic': isNull(cell),
|
||||
'bg-yellow-500/10': item.isDirtyCol[colIdx],
|
||||
'cell-selected': cellIsSelected(index, colIdx),
|
||||
'tabular-nums': typeof cell === 'number',
|
||||
'cursor-text hover:bg-accent/50': editable && !item.isDeleted,
|
||||
'line-through': item.isDeleted,
|
||||
}"
|
||||
@mousedown="beginCellSelection(index, colIdx, $event)"
|
||||
@mouseenter="extendCellSelection(index, colIdx)"
|
||||
@dblclick="editable && !item.isDeleted && startEdit(item.id, colIdx)"
|
||||
@contextmenu="onCellContext(item.id, index, colIdx)"
|
||||
>
|
||||
<template v-if="editingCell?.rowId === item.id && editingCell?.col === colIdx">
|
||||
<input
|
||||
v-model="editValue"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2 py-0.5 text-xs outline-none z-10"
|
||||
@blur="commitEdit"
|
||||
@click.stop
|
||||
@keydown.stop="onEditKeydown"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatCell(cell) }}
|
||||
<button
|
||||
class="absolute right-0.5 top-0.5 hidden h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground group-hover/cell:flex"
|
||||
:title="t('grid.cellDetails')"
|
||||
@mousedown.stop
|
||||
@click.stop="showCellDetails(index, colIdx)"
|
||||
>
|
||||
<Info class="h-3 w-3" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
<!-- DDL Drawer -->
|
||||
<div
|
||||
@@ -1324,7 +1437,13 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="copyDdl">
|
||||
<Copy class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :class="{ 'bg-accent': ddlWrap }" @click="toggleDdlWrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:class="{ 'bg-accent': ddlWrap }"
|
||||
@click="toggleDdlWrap"
|
||||
>
|
||||
<WrapText class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="showDdl = false">
|
||||
@@ -1348,7 +1467,7 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
>
|
||||
<div class="h-9 flex items-center gap-2 px-3 border-b shrink-0 bg-muted/20">
|
||||
<Info class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ t('grid.cellDetails') }}</span>
|
||||
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ t("grid.cellDetails") }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="showCellDetail = false">
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -1356,52 +1475,62 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto p-3 text-xs space-y-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t('grid.columnName') }}</div>
|
||||
<div class="text-muted-foreground">{{ t("grid.columnName") }}</div>
|
||||
<div class="font-medium break-all">{{ activeCellDetail.column }}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t('grid.rowNumber') }}</div>
|
||||
<div class="text-muted-foreground">{{ t("grid.rowNumber") }}</div>
|
||||
<div>{{ activeCellDetail.rowNumber }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t('grid.columnType') }}</div>
|
||||
<div :class="activeCellDetail.type ? typeColorClass(activeCellDetail.type) : 'text-muted-foreground'">
|
||||
{{ activeCellDetail.type || '-' }}
|
||||
<div class="text-muted-foreground">{{ t("grid.columnType") }}</div>
|
||||
<div
|
||||
:class="activeCellDetail.type ? typeColorClass(activeCellDetail.type) : 'text-muted-foreground'"
|
||||
>
|
||||
{{ activeCellDetail.type || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t('grid.nullValue') }}</div>
|
||||
<div>{{ activeCellDetail.value === null ? 'true' : 'false' }}</div>
|
||||
<div class="text-muted-foreground">{{ t("grid.nullValue") }}</div>
|
||||
<div>{{ activeCellDetail.value === null ? "true" : "false" }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t('grid.valueLength') }}</div>
|
||||
<div class="text-muted-foreground">{{ t("grid.valueLength") }}</div>
|
||||
<div>{{ activeCellDetail.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t('grid.columnComment') }}</div>
|
||||
<div class="whitespace-pre-wrap break-words">{{ activeCellDetail.comment || t('grid.noComment') }}</div>
|
||||
<div class="text-muted-foreground">{{ t("grid.columnComment") }}</div>
|
||||
<div class="whitespace-pre-wrap break-words">
|
||||
{{ activeCellDetail.comment || t("grid.noComment") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t('grid.cellValue') }}</div>
|
||||
<pre class="max-h-56 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words">{{ activeCellDetail.rawValue }}</pre>
|
||||
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
|
||||
<pre
|
||||
class="max-h-56 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
>{{ activeCellDetail.rawValue }}</pre
|
||||
>
|
||||
</div>
|
||||
<div v-if="activeCellDetail.formattedJson" class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t('grid.formattedJson') }}</div>
|
||||
<pre class="max-h-72 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words">{{ activeCellDetail.formattedJson }}</pre>
|
||||
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
|
||||
<pre
|
||||
class="max-h-72 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
>{{ activeCellDetail.formattedJson }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t p-2 grid grid-cols-1 gap-1">
|
||||
<Button variant="ghost" size="sm" class="h-7 justify-start text-xs" @click="copyDetailValue">
|
||||
<Copy class="w-3 h-3 mr-2" /> {{ t('grid.copyValue') }}
|
||||
<Copy class="w-3 h-3 mr-2" /> {{ t("grid.copyValue") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 justify-start text-xs" @click="copyDetailColumnName">
|
||||
<Copy class="w-3 h-3 mr-2" /> {{ t('grid.copyColumnName') }}
|
||||
<Copy class="w-3 h-3 mr-2" /> {{ t("grid.copyColumnName") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 justify-start text-xs" @click="copyDetailSqlCondition">
|
||||
<Code2 class="w-3 h-3 mr-2" /> {{ t('grid.copySqlCondition') }}
|
||||
<Code2 class="w-3 h-3 mr-2" /> {{ t("grid.copySqlCondition") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1412,13 +1541,27 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
>
|
||||
<div class="h-9 flex items-center gap-2 px-3 border-b shrink-0 bg-muted/20">
|
||||
<Rows3 class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span class="text-xs font-medium">{{ t('grid.transpose') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t('grid.rowNumber') }} {{ (transposeRowIndex ?? 0) + 1 }}</span>
|
||||
<span class="text-xs font-medium">{{ t("grid.transpose") }}</span>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>{{ t("grid.rowNumber") }} {{ (transposeRowIndex ?? 0) + 1 }}</span
|
||||
>
|
||||
<span class="flex-1" />
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="transposeRowIndex === 0" @click="transposeNav(-1)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:disabled="transposeRowIndex === 0"
|
||||
@click="transposeNav(-1)"
|
||||
>
|
||||
<ChevronLeft class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="transposeRowIndex === displayItems.length - 1" @click="transposeNav(1)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:disabled="transposeRowIndex === displayItems.length - 1"
|
||||
@click="transposeNav(1)"
|
||||
>
|
||||
<ChevronRight class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="showTranspose = false">
|
||||
@@ -1435,7 +1578,9 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
>
|
||||
<td class="px-3 py-1.5 font-medium text-muted-foreground whitespace-nowrap w-1/3 align-top">
|
||||
<div>{{ field.column }}</div>
|
||||
<div v-if="field.type" :class="typeColorClass(field.type)" class="text-[10px]">{{ field.type }}</div>
|
||||
<div v-if="field.type" :class="typeColorClass(field.type)" class="text-[10px]">
|
||||
{{ field.type }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 break-all" :class="{ 'text-muted-foreground italic': field.isNull }">
|
||||
{{ field.display }}
|
||||
@@ -1450,63 +1595,66 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent class="w-60">
|
||||
<ContextMenuItem @click="copyCell">{{ t('grid.copyCell') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyRow">{{ t('grid.copyRow') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyAll">{{ t('grid.copyAll') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyCell">{{ t("grid.copyCell") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyRow">{{ t("grid.copyRow") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyAll">{{ t("grid.copyAll") }}</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem v-if="contextCell" @click="openTranspose(contextCell.rowIndex)">
|
||||
<Rows3 class="w-3.5 h-3.5 mr-2" /> {{ t('grid.transpose') }}
|
||||
<Rows3 class="w-3.5 h-3.5 mr-2" /> {{ t("grid.transpose") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<template v-if="hasCellSelection">
|
||||
<ContextMenuItem @click="copySelectionTsv">{{ t('grid.copySelectionTsv') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySelectionCsv">{{ t('grid.copySelectionCsv') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySelectionJson">{{ t('grid.copySelectionJson') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySelectionSqlInList">{{ t('grid.copySelectionSql') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="clearCellSelection">{{ t('grid.clearSelection') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySelectionTsv">{{ t("grid.copySelectionTsv") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySelectionCsv">{{ t("grid.copySelectionCsv") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySelectionJson">{{ t("grid.copySelectionJson") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySelectionSqlInList">{{ t("grid.copySelectionSql") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="clearCellSelection">{{ t("grid.clearSelection") }}</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</template>
|
||||
<template v-if="editable && contextRowItem">
|
||||
<ContextMenuItem v-if="contextRowItem.isDeleted" @click="restoreRow(contextRowItem.id)">
|
||||
<Undo2 class="w-3.5 h-3.5 mr-2" /> {{ t('grid.restoreRow') }}
|
||||
<Undo2 class="w-3.5 h-3.5 mr-2" /> {{ t("grid.restoreRow") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-else class="text-destructive" @click="deleteSelectedRow">
|
||||
<Trash2 class="w-3.5 h-3.5 mr-2" /> {{ t('grid.deleteRow') }}
|
||||
<Trash2 class="w-3.5 h-3.5 mr-2" /> {{ t("grid.deleteRow") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</template>
|
||||
<ContextMenuItem @click="exportCsv">{{ t('grid.exportCsv') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportJson">{{ t('grid.exportJson') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportMarkdown">{{ t('grid.exportMarkdown') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportCsv">{{ t("grid.exportCsv") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportJson">{{ t("grid.exportJson") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportMarkdown">{{ t("grid.exportMarkdown") }}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<div v-if="!hasData" class="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t('grid.querySuccess') }}
|
||||
{{ t("grid.querySuccess") }}
|
||||
</div>
|
||||
|
||||
<!-- Error bar -->
|
||||
<div v-if="saveError" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0 flex items-center gap-2">
|
||||
<div
|
||||
v-if="saveError"
|
||||
class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0 flex items-center gap-2"
|
||||
>
|
||||
<span class="flex-1">{{ saveError }}</span>
|
||||
<button class="hover:underline" @click="saveError = ''">{{ t('grid.dismiss') }}</button>
|
||||
<button class="hover:underline" @click="saveError = ''">{{ t("grid.dismiss") }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom status bar -->
|
||||
<div class="flex items-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground bg-muted/30 shrink-0">
|
||||
<span v-if="hasData">{{ t('grid.totalRows', { count: result.rows.length }) }}</span>
|
||||
<span v-else>{{ t('grid.rowsAffected', { count: result.affected_rows }) }}</span>
|
||||
<span v-if="hasData">{{ t("grid.totalRows", { count: result.rows.length }) }}</span>
|
||||
<span v-else>{{ t("grid.rowsAffected", { count: result.affected_rows }) }}</span>
|
||||
<span>{{ result.execution_time_ms }}ms</span>
|
||||
<span v-if="hasCellSelection" class="text-foreground">{{ selectionSummary }}</span>
|
||||
|
||||
<template v-if="editable && tableMeta">
|
||||
<span v-if="hasPendingChanges" class="ml-2 text-foreground">
|
||||
{{ t('grid.pendingChanges', { count: pendingChangeCount }) }}
|
||||
{{ t("grid.pendingChanges", { count: pendingChangeCount }) }}
|
||||
</span>
|
||||
<Button v-if="hasPendingChanges" variant="default" size="sm" class="h-5 text-xs ml-2" @click="saveChanges">
|
||||
<Save class="w-3 h-3 mr-1" /> {{ t('grid.save') }}
|
||||
<Save class="w-3 h-3 mr-1" /> {{ t("grid.save") }}
|
||||
</Button>
|
||||
<Button v-if="hasPendingChanges" variant="ghost" size="sm" class="h-5 text-xs" @click="discardChanges">
|
||||
{{ t('grid.discard') }}
|
||||
{{ t("grid.discard") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs" @click="toggleDdl">
|
||||
<Code2 class="w-3 h-3 mr-1" /> DDL
|
||||
@@ -1518,12 +1666,12 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5">
|
||||
{{ pageSize }}{{ t('grid.rowsPerPageShort') }}
|
||||
{{ pageSize }}{{ t("grid.rowsPerPageShort") }}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem v-for="s in [50, 100, 500, 1000]" :key="s" @click="changePageSize(s)">
|
||||
{{ s }} {{ t('grid.rowsPerPageShort') }}
|
||||
{{ s }} {{ t("grid.rowsPerPageShort") }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -1543,9 +1691,9 @@ function escapeAndHighlightKeywords(s: string): string {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="exportCsv">{{ t('grid.exportCsv') }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportJson">{{ t('grid.exportJson') }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportMarkdown">{{ t('grid.exportMarkdown') }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportCsv">{{ t("grid.exportCsv") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportJson">{{ t("grid.exportJson") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportMarkdown">{{ t("grid.exportMarkdown") }}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -61,10 +61,20 @@ const letter = computed(() => letterIcons[normalizedType.value]);
|
||||
alt=""
|
||||
class="database-logo object-contain"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/>
|
||||
<svg v-else-if="letter" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="12" :fill="letter.color" />
|
||||
<text x="12" y="16.5" text-anchor="middle" fill="white" font-size="14" font-weight="bold" font-family="system-ui, sans-serif">{{ letter.letter }}</text>
|
||||
<text
|
||||
x="12"
|
||||
y="16.5"
|
||||
text-anchor="middle"
|
||||
fill="white"
|
||||
font-size="14"
|
||||
font-weight="bold"
|
||||
font-family="system-ui, sans-serif"
|
||||
>
|
||||
{{ letter.letter }}
|
||||
</text>
|
||||
</svg>
|
||||
<Database v-else class="text-blue-400" />
|
||||
</template>
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import {
|
||||
Dialog, DialogHeader, DialogTitle, DialogFooter, DialogScrollContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogScrollContent } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Check, FileUp, Loader2, Square, Upload, X,
|
||||
} from "lucide-vue-next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Check, FileUp, Loader2, Square, Upload, X } from "lucide-vue-next";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { autoMapImportColumns } from "@/lib/tableImport";
|
||||
@@ -47,7 +41,7 @@ const progress = ref<api.TableImportProgress | null>(null);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined
|
||||
props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined,
|
||||
);
|
||||
const targetColumnNames = computed(() => targetColumns.value.map((column) => column.name));
|
||||
const mappedColumns = computed<api.TableImportColumnMapping[]>(() => {
|
||||
@@ -61,12 +55,13 @@ const mappedColumns = computed<api.TableImportColumnMapping[]>(() => {
|
||||
.filter((mapping) => mapping.targetColumn);
|
||||
});
|
||||
const mappedCount = computed(() => mappedColumns.value.length);
|
||||
const canImport = computed(() =>
|
||||
!!preview.value &&
|
||||
!!props.prefillConnectionId &&
|
||||
!!props.prefillTable &&
|
||||
mappedColumns.value.length > 0 &&
|
||||
!running.value
|
||||
const canImport = computed(
|
||||
() =>
|
||||
!!preview.value &&
|
||||
!!props.prefillConnectionId &&
|
||||
!!props.prefillTable &&
|
||||
mappedColumns.value.length > 0 &&
|
||||
!running.value,
|
||||
);
|
||||
const progressPercent = computed(() => {
|
||||
const p = progress.value;
|
||||
@@ -74,8 +69,12 @@ const progressPercent = computed(() => {
|
||||
return Math.min(100, Math.round((p.rowsImported / p.totalRows) * 100));
|
||||
});
|
||||
const targetLabel = computed(() => {
|
||||
const pieces = [selectedConnection.value?.name, props.prefillDatabase, props.prefillSchema, props.prefillTable]
|
||||
.filter(Boolean);
|
||||
const pieces = [
|
||||
selectedConnection.value?.name,
|
||||
props.prefillDatabase,
|
||||
props.prefillSchema,
|
||||
props.prefillTable,
|
||||
].filter(Boolean);
|
||||
return pieces.join(" / ");
|
||||
});
|
||||
|
||||
@@ -175,19 +174,22 @@ async function startImport() {
|
||||
};
|
||||
|
||||
try {
|
||||
const summary = await api.importTableFile({
|
||||
importId: importId.value,
|
||||
connectionId: props.prefillConnectionId,
|
||||
database: props.prefillDatabase || "",
|
||||
schema: props.prefillSchema || "",
|
||||
table: props.prefillTable,
|
||||
filePath: currentPreview.filePath,
|
||||
mappings: mappedColumns.value,
|
||||
mode: importMode.value,
|
||||
batchSize: Math.max(1, Number(batchSize.value) || 500),
|
||||
}, (nextProgress) => {
|
||||
progress.value = nextProgress;
|
||||
});
|
||||
const summary = await api.importTableFile(
|
||||
{
|
||||
importId: importId.value,
|
||||
connectionId: props.prefillConnectionId,
|
||||
database: props.prefillDatabase || "",
|
||||
schema: props.prefillSchema || "",
|
||||
table: props.prefillTable,
|
||||
filePath: currentPreview.filePath,
|
||||
mappings: mappedColumns.value,
|
||||
mode: importMode.value,
|
||||
batchSize: Math.max(1, Number(batchSize.value) || 500),
|
||||
},
|
||||
(nextProgress) => {
|
||||
progress.value = nextProgress;
|
||||
},
|
||||
);
|
||||
toast(t("tableImport.success", { count: summary.rowsImported }), 2500);
|
||||
open.value = false;
|
||||
} catch (e: any) {
|
||||
@@ -218,48 +220,52 @@ watch(open, (value) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<FileUp class="h-4 w-4" />
|
||||
{{ t('tableImport.title') }}
|
||||
{{ t("tableImport.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2">
|
||||
<div class="min-w-0 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="truncate text-xs text-muted-foreground">{{ t('tableImport.target') }}</div>
|
||||
<div class="truncate text-sm font-medium">{{ targetLabel || t('editor.noDatabase') }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ t("tableImport.target") }}</div>
|
||||
<div class="truncate text-sm font-medium">
|
||||
{{ targetLabel || t("editor.noDatabase") }}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" :disabled="running || loadingPreview" @click="selectFile">
|
||||
<Loader2 v-if="loadingPreview" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Upload v-else class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('tableImport.selectFile') }}
|
||||
{{ t("tableImport.selectFile") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="preview" class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="rounded-md border px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t('tableImport.file') }}</div>
|
||||
<div class="text-muted-foreground">{{ t("tableImport.file") }}</div>
|
||||
<div class="truncate font-medium">{{ preview.fileName }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t('tableImport.rows') }}</div>
|
||||
<div class="text-muted-foreground">{{ t("tableImport.rows") }}</div>
|
||||
<div class="font-medium">{{ preview.totalRows.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t('tableImport.mapped') }}</div>
|
||||
<div class="text-muted-foreground">{{ t("tableImport.mapped") }}</div>
|
||||
<div class="font-medium">{{ mappedCount }} / {{ preview.columns.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="preview" class="grid grid-cols-[minmax(220px,280px)_1fr] gap-3">
|
||||
<div class="rounded-md border">
|
||||
<div class="border-b px-3 py-2 text-xs font-medium">{{ t('tableImport.mapping') }}</div>
|
||||
<div class="border-b px-3 py-2 text-xs font-medium">{{ t("tableImport.mapping") }}</div>
|
||||
<div class="max-h-[280px] overflow-auto p-2">
|
||||
<div
|
||||
v-for="sourceColumn in preview.columns"
|
||||
:key="sourceColumn"
|
||||
class="grid grid-cols-[1fr_1fr] items-center gap-2 py-1"
|
||||
>
|
||||
<div class="truncate font-mono text-xs" :title="sourceColumn">{{ sourceColumn }}</div>
|
||||
<div class="truncate font-mono text-xs" :title="sourceColumn">
|
||||
{{ sourceColumn }}
|
||||
</div>
|
||||
<Select
|
||||
:model-value="columnMapping[sourceColumn] || SKIP_VALUE"
|
||||
@update:model-value="(value: any) => updateMapping(sourceColumn, value)"
|
||||
@@ -268,7 +274,7 @@ watch(open, (value) => {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="SKIP_VALUE">{{ t('tableImport.skipColumn') }}</SelectItem>
|
||||
<SelectItem :value="SKIP_VALUE">{{ t("tableImport.skipColumn") }}</SelectItem>
|
||||
<SelectItem v-for="column in targetColumns" :key="column.name" :value="column.name">
|
||||
{{ column.name }}
|
||||
</SelectItem>
|
||||
@@ -279,7 +285,7 @@ watch(open, (value) => {
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 rounded-md border">
|
||||
<div class="border-b px-3 py-2 text-xs font-medium">{{ t('tableImport.preview') }}</div>
|
||||
<div class="border-b px-3 py-2 text-xs font-medium">{{ t("tableImport.preview") }}</div>
|
||||
<div class="max-h-[280px] overflow-auto">
|
||||
<table class="min-w-full border-separate border-spacing-0 text-xs">
|
||||
<thead class="sticky top-0 bg-background">
|
||||
@@ -312,29 +318,30 @@ watch(open, (value) => {
|
||||
|
||||
<div v-if="preview" class="grid grid-cols-3 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('tableImport.mode') }}</Label>
|
||||
<Select :model-value="importMode" @update:model-value="(value: any) => importMode = value">
|
||||
<Label class="text-xs">{{ t("tableImport.mode") }}</Label>
|
||||
<Select :model-value="importMode" @update:model-value="(value: any) => (importMode = value)">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="append">{{ t('tableImport.append') }}</SelectItem>
|
||||
<SelectItem value="truncate">{{ t('tableImport.truncate') }}</SelectItem>
|
||||
<SelectItem value="append">{{ t("tableImport.append") }}</SelectItem>
|
||||
<SelectItem value="truncate">{{ t("tableImport.truncate") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('transfer.batchSize') }}</Label>
|
||||
<Label class="text-xs">{{ t("transfer.batchSize") }}</Label>
|
||||
<Input v-model.number="batchSize" type="number" min="1" class="h-8 text-xs" />
|
||||
</div>
|
||||
<div v-if="running || progress" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('tableImport.progress') }}</Label>
|
||||
<Label class="text-xs">{{ t("tableImport.progress") }}</Label>
|
||||
<div class="h-8 rounded-md border px-2 text-xs flex items-center gap-2">
|
||||
<Loader2 v-if="running && !cancelling" class="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
<Square v-else-if="cancelling" class="h-3.5 w-3.5 fill-current text-destructive" />
|
||||
<Check v-else class="h-3.5 w-3.5 text-emerald-600" />
|
||||
<span class="truncate">
|
||||
{{ progress?.rowsImported ?? 0 }} / {{ progress?.totalRows ?? preview.totalRows }} · {{ progressPercent }}%
|
||||
{{ progress?.rowsImported ?? 0 }} / {{ progress?.totalRows ?? preview.totalRows }} ·
|
||||
{{ progressPercent }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,9 +349,12 @@ watch(open, (value) => {
|
||||
|
||||
<div v-if="loadingTarget" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t('common.loading') }}
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-if="errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,16 +362,16 @@ watch(open, (value) => {
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="running" @click="open = false">
|
||||
<X class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('dangerDialog.cancel') }}
|
||||
{{ t("dangerDialog.cancel") }}
|
||||
</Button>
|
||||
<Button v-if="running" variant="destructive" :disabled="cancelling" @click="cancelImport">
|
||||
<Loader2 v-if="cancelling" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Square v-else class="mr-1.5 h-3.5 w-3.5 fill-current" />
|
||||
{{ t('sqlFile.cancel') }}
|
||||
{{ t("sqlFile.cancel") }}
|
||||
</Button>
|
||||
<Button v-else :disabled="!canImport" @click="startImport">
|
||||
<Upload class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('tableImport.start') }}
|
||||
{{ t("tableImport.start") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import { computed, watch, defineAsyncComponent } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
|
||||
import EditorSettingsDialog from "@/components/editor/EditorSettingsDialog.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
@@ -13,7 +11,9 @@ const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/Sc
|
||||
const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/sql-file/SqlFileExecutionDialog.vue"));
|
||||
const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue"));
|
||||
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
|
||||
const TableStructureEditorDialog = defineAsyncComponent(() => import("@/components/structure/TableStructureEditorDialog.vue"));
|
||||
const TableStructureEditorDialog = defineAsyncComponent(
|
||||
() => import("@/components/structure/TableStructureEditorDialog.vue"),
|
||||
);
|
||||
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
|
||||
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
|
||||
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
|
||||
@@ -36,8 +36,24 @@ const emit = defineEmits<{
|
||||
connectSucceeded: [name: string];
|
||||
connectFailed: [message: string];
|
||||
structureEditorSaved: [];
|
||||
openLineageTarget: [target: { connectionId: string; database: string; schema?: string; tableName: string; columnName?: string }];
|
||||
openDatabaseSearchTarget: [target: { connectionId: string; database: string; schema?: string; tableName: string; whereInput?: string }];
|
||||
openLineageTarget: [
|
||||
target: {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columnName?: string;
|
||||
},
|
||||
];
|
||||
openDatabaseSearchTarget: [
|
||||
target: {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
whereInput?: string;
|
||||
},
|
||||
];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -54,9 +70,12 @@ watch(editConfig, (v) => {
|
||||
if (v) emit("update:showConnectionDialog", true);
|
||||
});
|
||||
|
||||
watch(() => props.showConnectionDialog, (v) => {
|
||||
if (!v) connectionStore.stopEditing();
|
||||
});
|
||||
watch(
|
||||
() => props.showConnectionDialog,
|
||||
(v) => {
|
||||
if (!v) connectionStore.stopEditing();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,10 +87,7 @@ watch(() => props.showConnectionDialog, (v) => {
|
||||
@connect-succeeded="emit('connectSucceeded', $event)"
|
||||
@connect-failed="emit('connectFailed', $event)"
|
||||
/>
|
||||
<EditorSettingsDialog
|
||||
:open="showSettingsDialog"
|
||||
@update:open="emit('update:showSettingsDialog', $event)"
|
||||
/>
|
||||
<EditorSettingsDialog :open="showSettingsDialog" @update:open="emit('update:showSettingsDialog', $event)" />
|
||||
<DangerConfirmDialog
|
||||
:open="showDangerDialog"
|
||||
:sql="dangerSql"
|
||||
@@ -135,17 +151,29 @@ watch(() => props.showConnectionDialog, (v) => {
|
||||
v-model:open="dialogs.showConfigPassphraseDialog.value"
|
||||
:mode="dialogs.configPassphraseMode.value"
|
||||
:external-error="dialogs.configPassphraseError.value"
|
||||
@confirm="dialogs.configPassphraseMode.value === 'export' ? dialogs.onExportConfirm($event) : dialogs.onImportConfirm($event)"
|
||||
@confirm="
|
||||
dialogs.configPassphraseMode.value === 'export'
|
||||
? dialogs.onExportConfirm($event)
|
||||
: dialogs.onImportConfirm($event)
|
||||
"
|
||||
/>
|
||||
<Dialog v-model:open="dialogs.showImportLayoutConfirm.value">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('configExport.importLayoutTitle') }}</DialogTitle>
|
||||
<DialogTitle>{{ t("configExport.importLayoutTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t('configExport.importLayoutConfirm') }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ t("configExport.importLayoutConfirm") }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="dialogs.showImportLayoutConfirm.value = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button @click="dialogs.showImportLayoutConfirm.value = false; dialogs.pendingImportLayout.value && connectionStore.applySidebarLayout(dialogs.pendingImportLayout.value)">{{ t('configExport.importLayoutApply') }}</Button>
|
||||
<Button variant="outline" @click="dialogs.showImportLayoutConfirm.value = false">{{
|
||||
t("dangerDialog.cancel")
|
||||
}}</Button>
|
||||
<Button
|
||||
@click="
|
||||
dialogs.showImportLayoutConfirm.value = false;
|
||||
dialogs.pendingImportLayout.value && connectionStore.applySidebarLayout(dialogs.pendingImportLayout.value);
|
||||
"
|
||||
>{{ t("configExport.importLayoutApply") }}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -22,7 +22,7 @@ const { t } = useI18n();
|
||||
<div class="h-full shrink-0 relative select-none" :style="{ width: sidebarWidth + 'px' }">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="h-9 flex items-center px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20">
|
||||
{{ t('sidebar.connections') }}
|
||||
{{ t("sidebar.connections") }}
|
||||
<span class="flex-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -30,7 +30,7 @@ const { t } = useI18n();
|
||||
<Upload class="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('sidebar.import') }}</TooltipContent>
|
||||
<TooltipContent>{{ t("sidebar.import") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -38,7 +38,7 @@ const { t } = useI18n();
|
||||
<Download class="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('sidebar.export') }}</TooltipContent>
|
||||
<TooltipContent>{{ t("sidebar.export") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -12,12 +12,7 @@ import {
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import {
|
||||
connectionColor,
|
||||
tabDisplayTitle,
|
||||
tabTooltipLines,
|
||||
tabModeLabel,
|
||||
} from "@/lib/tabPresentation";
|
||||
import { connectionColor, tabDisplayTitle, tabTooltipLines, tabModeLabel } from "@/lib/tabPresentation";
|
||||
|
||||
const { t } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
@@ -25,21 +20,27 @@ const queryStore = useQueryStore();
|
||||
const tabsContainerRef = ref<HTMLElement | null>(null);
|
||||
const { canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs } = useTabScroll(tabsContainerRef);
|
||||
|
||||
watch(() => queryStore.tabs.length, () => {
|
||||
nextTick(updateScrollButtons);
|
||||
});
|
||||
watch(
|
||||
() => queryStore.tabs.length,
|
||||
() => {
|
||||
nextTick(updateScrollButtons);
|
||||
},
|
||||
);
|
||||
|
||||
watch(() => queryStore.activeTabId, () => {
|
||||
nextTick(() => {
|
||||
const container = tabsContainerRef.value;
|
||||
if (!container) return;
|
||||
const activeEl = container.querySelector('[data-active-tab="true"]');
|
||||
if (activeEl) {
|
||||
activeEl.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
||||
}
|
||||
updateScrollButtons();
|
||||
});
|
||||
});
|
||||
watch(
|
||||
() => queryStore.activeTabId,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
const container = tabsContainerRef.value;
|
||||
if (!container) return;
|
||||
const activeEl = container.querySelector('[data-active-tab="true"]');
|
||||
if (activeEl) {
|
||||
activeEl.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
||||
}
|
||||
updateScrollButtons();
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -55,23 +56,25 @@ watch(() => queryStore.activeTabId, () => {
|
||||
<div
|
||||
ref="tabsContainerRef"
|
||||
class="flex-1 flex items-center overflow-x-auto min-w-0"
|
||||
style="-ms-overflow-style:none;scrollbar-width:none;-webkit-overflow-scrolling:touch"
|
||||
style="-ms-overflow-style: none; scrollbar-width: none; -webkit-overflow-scrolling: touch"
|
||||
@scroll="updateScrollButtons"
|
||||
>
|
||||
<ContextMenu
|
||||
v-for="tab in queryStore.tabs"
|
||||
:key="tab.id"
|
||||
>
|
||||
<ContextMenu v-for="tab in queryStore.tabs" :key="tab.id">
|
||||
<ContextMenuTrigger as-child>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="group flex min-w-38 items-center gap-1 px-1 h-full text-xs cursor-pointer border-r transition-colors whitespace-nowrap"
|
||||
:class="tab.id === queryStore.activeTabId ? 'bg-background font-medium' : 'font-normal text-muted-foreground'"
|
||||
:class="
|
||||
tab.id === queryStore.activeTabId ? 'bg-background font-medium' : 'font-normal text-muted-foreground'
|
||||
"
|
||||
:data-active-tab="tab.id === queryStore.activeTabId"
|
||||
@click="queryStore.activeTabId = tab.id"
|
||||
>
|
||||
<span class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: connectionColor(tab.connectionId) || '#9ca3af' }" />
|
||||
<span
|
||||
class="h-4 w-1 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: connectionColor(tab.connectionId) || '#9ca3af' }"
|
||||
/>
|
||||
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -83,11 +86,15 @@ watch(() => queryStore.activeTabId, () => {
|
||||
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }}</TooltipContent>
|
||||
<TooltipContent>{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span
|
||||
class="shrink-0 rounded border px-1 text-[10px] leading-4"
|
||||
:class="tab.mode === 'data' ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300' : 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-300'"
|
||||
:class="
|
||||
tab.mode === 'data'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-300'
|
||||
"
|
||||
>
|
||||
{{ tabModeLabel(tab) }}
|
||||
</span>
|
||||
@@ -111,23 +118,20 @@ watch(() => queryStore.activeTabId, () => {
|
||||
<ContextMenuContent class="w-44">
|
||||
<ContextMenuItem @click="queryStore.togglePinnedTab(tab.id)">
|
||||
<Pin class="w-3.5 h-3.5 mr-2" :class="{ 'fill-current': tab.pinned }" />
|
||||
{{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }}
|
||||
{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="queryStore.closeTab(tab.id)">
|
||||
<X class="w-3.5 h-3.5 mr-2" />
|
||||
{{ t('contextMenu.closeTab') }}
|
||||
{{ t("contextMenu.closeTab") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
:disabled="queryStore.tabs.length <= 1"
|
||||
@click="queryStore.closeOtherTabs(tab.id)"
|
||||
>
|
||||
<ContextMenuItem :disabled="queryStore.tabs.length <= 1" @click="queryStore.closeOtherTabs(tab.id)">
|
||||
<X class="w-3.5 h-3.5 mr-2" />
|
||||
{{ t('contextMenu.closeOtherTabs') }}
|
||||
{{ t("contextMenu.closeOtherTabs") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" @click="queryStore.closeAllTabs">
|
||||
<X class="w-3.5 h-3.5 mr-2" />
|
||||
{{ t('contextMenu.closeAllTabs') }}
|
||||
{{ t("contextMenu.closeAllTabs") }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DatabaseZap, FilePlus2, Loader2, Globe, Moon, Sun, History, Bot, ArrowLeftRight, FileCode, Settings, CloudDownload } from "lucide-vue-next";
|
||||
import {
|
||||
DatabaseZap,
|
||||
FilePlus2,
|
||||
Loader2,
|
||||
Globe,
|
||||
Moon,
|
||||
Sun,
|
||||
History,
|
||||
Bot,
|
||||
ArrowLeftRight,
|
||||
FileCode,
|
||||
Settings,
|
||||
CloudDownload,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
defineProps<{
|
||||
isDark: boolean
|
||||
showAiPanel: boolean
|
||||
showHistory: boolean
|
||||
checkingUpdates: boolean
|
||||
hasConnections: boolean
|
||||
hasSqlFileConnections: boolean
|
||||
isDark: boolean;
|
||||
showAiPanel: boolean;
|
||||
showHistory: boolean;
|
||||
checkingUpdates: boolean;
|
||||
hasConnections: boolean;
|
||||
hasSqlFileConnections: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'new-connection': []
|
||||
'new-query': []
|
||||
'toggle-theme': []
|
||||
'toggle-locale': []
|
||||
'toggle-ai': []
|
||||
'toggle-history': []
|
||||
'open-github': []
|
||||
'open-settings': []
|
||||
'check-updates': []
|
||||
'open-transfer': []
|
||||
'open-sql-file': []
|
||||
"new-connection": [];
|
||||
"new-query": [];
|
||||
"toggle-theme": [];
|
||||
"toggle-locale": [];
|
||||
"toggle-ai": [];
|
||||
"toggle-history": [];
|
||||
"open-github": [];
|
||||
"open-settings": [];
|
||||
"check-updates": [];
|
||||
"open-transfer": [];
|
||||
"open-sql-file": [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -34,22 +47,40 @@ const { t } = useI18n();
|
||||
<div class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0">
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('new-connection')">
|
||||
<DatabaseZap class="h-3.5 w-3.5" />
|
||||
{{ t('toolbar.newConnection') }}
|
||||
{{ t("toolbar.newConnection") }}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('new-query')" :disabled="!hasConnections">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs gap-1"
|
||||
@click="emit('new-query')"
|
||||
:disabled="!hasConnections"
|
||||
>
|
||||
<FilePlus2 class="h-3.5 w-3.5" />
|
||||
{{ t('toolbar.newQuery') }}
|
||||
{{ t("toolbar.newQuery") }}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('open-transfer')" :disabled="!hasConnections">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs gap-1"
|
||||
@click="emit('open-transfer')"
|
||||
:disabled="!hasConnections"
|
||||
>
|
||||
<ArrowLeftRight class="h-3.5 w-3.5" />
|
||||
{{ t('transfer.dataTransfer') }}
|
||||
{{ t("transfer.dataTransfer") }}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('open-sql-file')" :disabled="!hasSqlFileConnections">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs gap-1"
|
||||
@click="emit('open-sql-file')"
|
||||
:disabled="!hasSqlFileConnections"
|
||||
>
|
||||
<FileCode class="h-3.5 w-3.5" />
|
||||
{{ t('sqlFile.title') }}
|
||||
{{ t("sqlFile.title") }}
|
||||
</Button>
|
||||
|
||||
<div class="flex-1" />
|
||||
@@ -61,21 +92,33 @@ const { t } = useI18n();
|
||||
<CloudDownload v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('updates.check') }}</TooltipContent>
|
||||
<TooltipContent>{{ t("updates.check") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showHistory }" @click="emit('toggle-history')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:class="{ 'bg-accent': showHistory }"
|
||||
@click="emit('toggle-history')"
|
||||
>
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('history.title') }}</TooltipContent>
|
||||
<TooltipContent>{{ t("history.title") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showAiPanel }" @click="emit('toggle-ai')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:class="{ 'bg-accent': showAiPanel }"
|
||||
@click="emit('toggle-ai')"
|
||||
>
|
||||
<Bot class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -89,7 +132,7 @@ const { t } = useI18n();
|
||||
<Sun v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ isDark ? 'Light' : 'Dark' }}</TooltipContent>
|
||||
<TooltipContent>{{ isDark ? "Light" : "Dark" }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
@@ -98,13 +141,17 @@ const { t } = useI18n();
|
||||
<Globe class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('common.language') }}</TooltipContent>
|
||||
<TooltipContent>{{ t("common.language") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('open-github')">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.729.083-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12 24 5.37 18.627 0 12 0z"/></svg>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.729.083-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12 24 5.37 18.627 0 12 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>GitHub</TooltipContent>
|
||||
@@ -116,7 +163,7 @@ const { t } = useI18n();
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('settings.title') }}</TooltipContent>
|
||||
<TooltipContent>{{ t("settings.title") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,7 +61,7 @@ const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
|
||||
});
|
||||
|
||||
const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
props.activeConnection?.db_type === "postgres" ? "postgres" : "mysql"
|
||||
props.activeConnection?.db_type === "postgres" ? "postgres" : "mysql",
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -91,7 +91,13 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
<Pane :size="60" :min-size="20">
|
||||
<div class="h-full flex flex-col">
|
||||
<div
|
||||
v-if="activeTab.result || activeTab.explainPlan || activeTab.explainError || activeTab.isExecuting || activeTab.isExplaining"
|
||||
v-if="
|
||||
activeTab.result ||
|
||||
activeTab.explainPlan ||
|
||||
activeTab.explainError ||
|
||||
activeTab.isExecuting ||
|
||||
activeTab.isExplaining
|
||||
"
|
||||
class="h-8 shrink-0 border-b bg-muted/20 px-2 flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
@@ -101,7 +107,7 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
:disabled="!activeTab.result && !activeTab.isExecuting"
|
||||
@click="emit('update:activeOutputView', 'result')"
|
||||
>
|
||||
{{ t('tabs.tableData') }}
|
||||
{{ t("tabs.tableData") }}
|
||||
</Button>
|
||||
<template v-if="activeOutputView === 'result' && activeTab.results && activeTab.results.length > 1">
|
||||
<span class="mx-1 h-4 w-px bg-border" />
|
||||
@@ -113,7 +119,7 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="queryStore.setActiveResultIndex(activeTab.id, rIdx)"
|
||||
>
|
||||
{{ t('tabs.resultN', { n: rIdx + 1 }) }}
|
||||
{{ t("tabs.resultN", { n: rIdx + 1 }) }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
@@ -124,7 +130,7 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
@click="emit('update:activeOutputView', 'explain')"
|
||||
>
|
||||
<GitBranch class="h-3.5 w-3.5" />
|
||||
{{ t('explain.title') }}
|
||||
{{ t("explain.title") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -139,21 +145,40 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<DataGrid v-if="activeTab.result" :key="`${activeTab.id}-${activeTab.activeResultIndex ?? 0}`" class="flex-1 min-h-0" :result="activeTab.result" :sql="activeTab.lastExecutedSql || activeTab.sql" :loading="activeTab.isExecuting" />
|
||||
<div v-if="activeTab.result?.columns.includes('Error')" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/5">
|
||||
<DataGrid
|
||||
v-if="activeTab.result"
|
||||
:key="`${activeTab.id}-${activeTab.activeResultIndex ?? 0}`"
|
||||
class="flex-1 min-h-0"
|
||||
:result="activeTab.result"
|
||||
:sql="activeTab.lastExecutedSql || activeTab.sql"
|
||||
:loading="activeTab.isExecuting"
|
||||
/>
|
||||
<div
|
||||
v-if="activeTab.result?.columns.includes('Error')"
|
||||
class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/5"
|
||||
>
|
||||
<Bot class="h-3.5 w-3.5 text-destructive" />
|
||||
<button class="text-xs text-destructive hover:underline" @click="emit('fixWithAi', String(activeTab.result?.rows?.[0]?.[0] ?? ''))">
|
||||
{{ t('ai.fixWithAi') }}
|
||||
<button
|
||||
class="text-xs text-destructive hover:underline"
|
||||
@click="emit('fixWithAi', String(activeTab.result?.rows?.[0]?.[0] ?? ''))"
|
||||
>
|
||||
{{ t("ai.fixWithAi") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!activeTab.result && activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<div
|
||||
v-else-if="!activeTab.result && activeTab.isExecuting"
|
||||
class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
{{ t(queryExecutionLabelKey(activeTab)) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!activeTab.result" class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t('editor.pressToExecute') }}
|
||||
<div
|
||||
v-else-if="!activeTab.result"
|
||||
class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t("editor.pressToExecute") }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -165,9 +190,11 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
<template v-else-if="activeTab.mode === 'data'">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
>
|
||||
<Table2 class="h-3.5 w-3.5" />
|
||||
{{ t('tabs.tableData') }}
|
||||
{{ t("tabs.tableData") }}
|
||||
</span>
|
||||
<span class="font-medium truncate">{{ activeTab.tableMeta?.tableName || activeTab.title }}</span>
|
||||
<span class="text-muted-foreground truncate">
|
||||
@@ -175,7 +202,7 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
<template v-if="activeTab.tableMeta?.schema"> · {{ activeTab.tableMeta.schema }}</template>
|
||||
</span>
|
||||
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
|
||||
{{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }}
|
||||
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}
|
||||
</span>
|
||||
</div>
|
||||
<DataGrid
|
||||
@@ -192,10 +219,18 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
:table-meta="activeTab.tableMeta"
|
||||
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
|
||||
@reload="emit('reload')"
|
||||
@paginate="(offset: number, limit: number, whereInput?: string) => emit('paginate', offset, limit, whereInput)"
|
||||
@sort="(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, direction, whereInput)"
|
||||
@paginate="
|
||||
(offset: number, limit: number, whereInput?: string) => emit('paginate', offset, limit, whereInput)
|
||||
"
|
||||
@sort="
|
||||
(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) =>
|
||||
emit('sort', column, direction, whereInput)
|
||||
"
|
||||
/>
|
||||
<div v-else-if="activeTab.isExecuting" class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<div
|
||||
v-else-if="activeTab.isExecuting"
|
||||
class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
{{ t(queryExecutionLabelKey(activeTab)) }}
|
||||
@@ -209,7 +244,7 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
>
|
||||
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Square v-else class="h-3.5 w-3.5 fill-current" />
|
||||
{{ t('toolbar.stopQuery') }}
|
||||
{{ t("toolbar.stopQuery") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,11 +253,7 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
<!-- Redis mode: key browser -->
|
||||
<template v-else-if="activeTab.mode === 'redis'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<RedisKeyBrowser
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:db="Number(activeTab.database)"
|
||||
/>
|
||||
<RedisKeyBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :db="Number(activeTab.database)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,13 +3,7 @@ import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Play, Loader2, Square, Database, Table2, AlignLeft, GitBranch, Save, FolderOpen } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -41,7 +35,7 @@ const { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions } = useData
|
||||
|
||||
const activeDatabaseOptions = computed(() => {
|
||||
const connection = props.activeConnection;
|
||||
return connection ? databaseOptions.value[connection.id] ?? [] : [];
|
||||
return connection ? (databaseOptions.value[connection.id] ?? []) : [];
|
||||
});
|
||||
|
||||
const activeDatabaseValue = computed(() => props.activeTab.database || "");
|
||||
@@ -55,7 +49,9 @@ function databaseDisplayName(database: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10">
|
||||
<div
|
||||
class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10"
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -63,7 +59,9 @@ function databaseDisplayName(database: string): string {
|
||||
:variant="activeTab.isExecuting ? 'destructive' : 'ghost'"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())"
|
||||
:disabled="
|
||||
activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())
|
||||
"
|
||||
@click="activeTab.isExecuting ? emit('cancel') : emit('execute')"
|
||||
>
|
||||
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -71,7 +69,9 @@ function databaseDisplayName(database: string): string {
|
||||
<Play v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ activeTab.isExecuting ? t('toolbar.stopQuery') : t('toolbar.executeShortcut') }}</TooltipContent>
|
||||
<TooltipContent>{{
|
||||
activeTab.isExecuting ? t("toolbar.stopQuery") : t("toolbar.executeShortcut")
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -86,23 +86,37 @@ function databaseDisplayName(database: string): string {
|
||||
<GitBranch v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ activeTab.isExplaining ? t('toolbar.stopExplain') : t('toolbar.explainPlan') }}</TooltipContent>
|
||||
<TooltipContent>{{
|
||||
activeTab.isExplaining ? t("toolbar.stopExplain") : t("toolbar.explainPlan")
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()" @click="emit('formatSql')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()"
|
||||
@click="emit('formatSql')"
|
||||
>
|
||||
<AlignLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('toolbar.formatSql') }}</TooltipContent>
|
||||
<TooltipContent>{{ t("toolbar.formatSql") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="!activeTab.sql.trim()" @click="emit('saveSql')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:disabled="!activeTab.sql.trim()"
|
||||
@click="emit('saveSql')"
|
||||
>
|
||||
<Save class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('toolbar.saveSql') }}</TooltipContent>
|
||||
<TooltipContent>{{ t("toolbar.saveSql") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -110,18 +124,21 @@ function databaseDisplayName(database: string): string {
|
||||
<FolderOpen class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('toolbar.openSql') }}</TooltipContent>
|
||||
<TooltipContent>{{ t("toolbar.openSql") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span class="flex-1 min-w-0" />
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="activeConnection?.color" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: activeConnection.color }" />
|
||||
<Select
|
||||
:model-value="activeConnectionValue"
|
||||
@update:model-value="(v: any) => emit('changeConnection', v)"
|
||||
>
|
||||
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs font-medium text-foreground shadow-none focus:ring-0">
|
||||
<span
|
||||
v-if="activeConnection?.color"
|
||||
class="h-4 w-1 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: activeConnection.color }"
|
||||
/>
|
||||
<Select :model-value="activeConnectionValue" @update:model-value="(v: any) => emit('changeConnection', v)">
|
||||
<SelectTrigger
|
||||
class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs font-medium text-foreground shadow-none focus:ring-0"
|
||||
>
|
||||
<div v-if="activeConnection" class="flex min-w-0 items-center gap-1.5">
|
||||
<DatabaseIcon :db-type="connectionIconType(activeConnection)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ connectionDisplayName(activeConnectionValue) }}</span>
|
||||
@@ -129,11 +146,7 @@ function databaseDisplayName(database: string): string {
|
||||
<SelectValue v-else :placeholder="t('editor.selectConnection')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem
|
||||
v-for="connection in connectionStore.connections"
|
||||
:key="connection.id"
|
||||
:value="connection.id"
|
||||
>
|
||||
<SelectItem v-for="connection in connectionStore.connections" :key="connection.id" :value="connection.id">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<DatabaseIcon :db-type="connectionIconType(connection)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ connection.name }}</span>
|
||||
@@ -147,19 +160,23 @@ function databaseDisplayName(database: string): string {
|
||||
<Select
|
||||
:model-value="activeDatabaseValue"
|
||||
@update:model-value="(v: any) => emit('changeDatabase', v)"
|
||||
@update:open="(open: boolean) => { if (open && activeConnection) loadDatabaseOptions(activeConnection.id).catch(() => {}) }"
|
||||
@update:open="
|
||||
(open: boolean) => {
|
||||
if (open && activeConnection) loadDatabaseOptions(activeConnection.id).catch(() => {});
|
||||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
|
||||
<SelectValue :placeholder="loadingDatabaseOptions[activeConnection?.id || ''] ? t('common.loading') : t('editor.selectDatabase')">
|
||||
<SelectValue
|
||||
:placeholder="
|
||||
loadingDatabaseOptions[activeConnection?.id || ''] ? t('common.loading') : t('editor.selectDatabase')
|
||||
"
|
||||
>
|
||||
{{ databaseDisplayName(activeDatabaseValue) }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem
|
||||
v-for="database in activeDatabaseOptions"
|
||||
:key="database"
|
||||
:value="database"
|
||||
>
|
||||
<SelectItem v-for="database in activeDatabaseOptions" :key="database" :value="database">
|
||||
{{ databaseDisplayName(database) }}
|
||||
</SelectItem>
|
||||
<SelectItem v-if="!activeDatabaseOptions.length && activeDatabaseValue" :value="activeDatabaseValue">
|
||||
@@ -171,7 +188,7 @@ function databaseDisplayName(database: string): string {
|
||||
</div>
|
||||
<div v-if="activeTab.tableMeta" class="flex min-w-0 items-center gap-1 ml-2">
|
||||
<Table2 class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }}</span>
|
||||
<span class="truncate">{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,26 +2,24 @@
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import type { UpdateInfo } from "@/lib/api";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
||||
const open = defineModel<boolean>("open", { required: true });
|
||||
|
||||
defineProps<{
|
||||
updateInfo: UpdateInfo | null
|
||||
updateCheckMessage: string
|
||||
isDownloadingUpdate: boolean
|
||||
downloadProgress: number
|
||||
updateReady: boolean
|
||||
updateInfo: UpdateInfo | null;
|
||||
updateCheckMessage: string;
|
||||
isDownloadingUpdate: boolean;
|
||||
downloadProgress: number;
|
||||
updateReady: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-latest-release': []
|
||||
'download-and-install': []
|
||||
'restart': []
|
||||
"open-latest-release": [];
|
||||
"download-and-install": [];
|
||||
restart: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -32,36 +30,48 @@ const isDesktop = isTauriRuntime();
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ updateInfo?.update_available ? t('updates.availableTitle') : t('updates.title') }}</DialogTitle>
|
||||
<DialogTitle>{{ updateInfo?.update_available ? t("updates.availableTitle") : t("updates.title") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-3 text-sm">
|
||||
<p v-if="updateInfo?.update_available">
|
||||
{{ t('updates.availableMessage', { current: updateInfo.current_version, latest: updateInfo.latest_version }) }}
|
||||
{{
|
||||
t("updates.availableMessage", {
|
||||
current: updateInfo.current_version,
|
||||
latest: updateInfo.latest_version,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-else class="text-muted-foreground">
|
||||
{{ updateCheckMessage || t('updates.upToDate', { version: updateInfo?.current_version || '' }) }}
|
||||
{{ updateCheckMessage || t("updates.upToDate", { version: updateInfo?.current_version || "" }) }}
|
||||
</p>
|
||||
<div v-if="updateInfo?.update_available && updateInfo.release_notes" class="max-h-48 overflow-auto rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
|
||||
<div
|
||||
v-if="updateInfo?.update_available && updateInfo.release_notes"
|
||||
class="max-h-48 overflow-auto rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap"
|
||||
>
|
||||
{{ updateInfo.release_notes }}
|
||||
</div>
|
||||
<p v-if="!isDesktop && updateInfo?.update_available" class="text-xs text-muted-foreground">
|
||||
Docker 用户请运行 <code class="bg-muted px-1 py-0.5 rounded text-[11px]">docker compose pull && docker compose up -d</code> 更新
|
||||
Docker 用户请运行
|
||||
<code class="bg-muted px-1 py-0.5 rounded text-[11px]">docker compose pull && docker compose up -d</code>
|
||||
更新
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<template v-if="updateInfo?.update_available">
|
||||
<Button variant="outline" @click="emit('open-latest-release')">{{ t('updates.openRelease') }}</Button>
|
||||
<Button variant="outline" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
|
||||
<template v-if="isDesktop">
|
||||
<Button v-if="updateReady" @click="emit('restart')">{{ t('updates.restart') }}</Button>
|
||||
<Button v-if="updateReady" @click="emit('restart')">{{ t("updates.restart") }}</Button>
|
||||
<Button v-else-if="isDownloadingUpdate" disabled>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t('updates.downloading', { progress: downloadProgress }) }}
|
||||
{{ t("updates.downloading", { progress: downloadProgress }) }}
|
||||
</Button>
|
||||
<Button v-else @click="emit('download-and-install')">{{ t('updates.downloadAndInstall') }}</Button>
|
||||
<Button v-else @click="emit('download-and-install')">{{ t("updates.downloadAndInstall") }}</Button>
|
||||
</template>
|
||||
</template>
|
||||
<Button v-else-if="updateCheckMessage" @click="emit('open-latest-release')">{{ t('updates.openRelease') }}</Button>
|
||||
<Button v-else-if="updateCheckMessage" @click="emit('open-latest-release')">{{
|
||||
t("updates.openRelease")
|
||||
}}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -6,20 +6,20 @@ import { connectionDriverLabel, connectionIconType, connectionOptionSubtitle } f
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
defineProps<{
|
||||
connectionStats: { total: number; connected: number; types: number }
|
||||
recentConnections: ConnectionConfig[]
|
||||
appVersion: string
|
||||
hasConnections: boolean
|
||||
connectionStats: { total: number; connected: number; types: number };
|
||||
recentConnections: ConnectionConfig[];
|
||||
appVersion: string;
|
||||
hasConnections: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-connection-query': [connectionId: string]
|
||||
'new-connection': []
|
||||
'new-query': []
|
||||
'show-history': []
|
||||
'import-config': []
|
||||
'open-github': []
|
||||
'open-mcp-guide': []
|
||||
"open-connection-query": [connectionId: string];
|
||||
"new-connection": [];
|
||||
"new-query": [];
|
||||
"show-history": [];
|
||||
"import-config": [];
|
||||
"open-github": [];
|
||||
"open-mcp-guide": [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -31,19 +31,19 @@ const { t } = useI18n();
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database class="h-3.5 w-3.5" /> {{ t('welcome.connections') }}
|
||||
<Database class="h-3.5 w-3.5" /> {{ t("welcome.connections") }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.total }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck class="h-3.5 w-3.5" /> {{ t('welcome.connected') }}
|
||||
<ShieldCheck class="h-3.5 w-3.5" /> {{ t("welcome.connected") }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.connected }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles class="h-3.5 w-3.5" /> {{ t('welcome.databaseTypes') }}
|
||||
<Sparkles class="h-3.5 w-3.5" /> {{ t("welcome.databaseTypes") }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.types }}</div>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@ const { t } = useI18n();
|
||||
<div class="grid grid-cols-[1.2fr_0.8fr] gap-4">
|
||||
<div class="rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<div class="text-sm font-medium">{{ t('welcome.quickConnections') }}</div>
|
||||
<div class="text-sm font-medium">{{ t("welcome.quickConnections") }}</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<button
|
||||
@@ -72,31 +72,44 @@ const { t } = useI18n();
|
||||
<FilePlus2 class="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<div v-if="recentConnections.length === 0" class="px-4 py-8 text-sm text-muted-foreground">
|
||||
{{ t('sidebar.noConnections') }}
|
||||
{{ t("sidebar.noConnections") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border">
|
||||
<div class="border-b px-4 py-3">
|
||||
<div class="text-sm font-medium">{{ t('welcome.shortcuts') }}</div>
|
||||
<div class="text-sm font-medium">{{ t("welcome.shortcuts") }}</div>
|
||||
</div>
|
||||
<div class="grid gap-1 p-2">
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('new-connection')">
|
||||
<Plus class="h-4 w-4" /> {{ t('toolbar.newConnection') }}
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
@click="emit('new-connection')"
|
||||
>
|
||||
<Plus class="h-4 w-4" /> {{ t("toolbar.newConnection") }}
|
||||
</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" :disabled="!hasConnections" @click="emit('new-query')">
|
||||
<FilePlus2 class="h-4 w-4" /> {{ t('toolbar.newQuery') }}
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
:disabled="!hasConnections"
|
||||
@click="emit('new-query')"
|
||||
>
|
||||
<FilePlus2 class="h-4 w-4" /> {{ t("toolbar.newQuery") }}
|
||||
</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('show-history')">
|
||||
<History class="h-4 w-4" /> {{ t('history.title') }}
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
@click="emit('show-history')"
|
||||
>
|
||||
<History class="h-4 w-4" /> {{ t("history.title") }}
|
||||
</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('import-config')">
|
||||
<Upload class="h-4 w-4" /> {{ t('sidebar.import') }}
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
@click="emit('import-config')"
|
||||
>
|
||||
<Upload class="h-4 w-4" /> {{ t("sidebar.import") }}
|
||||
</button>
|
||||
<div class="mt-2 rounded-md bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
<Search class="mr-1 inline h-3.5 w-3.5" />
|
||||
{{ t('welcome.tip') }}
|
||||
{{ t("welcome.tip") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,11 +120,15 @@ const { t } = useI18n();
|
||||
<div class="flex items-start gap-3">
|
||||
<Sparkles class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ t('welcome.mcpTitle') }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t('welcome.mcpDescription') }}</p>
|
||||
<div class="text-sm font-medium">{{ t("welcome.mcpTitle") }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{{ t("welcome.mcpDescription") }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<code class="rounded bg-muted px-2 py-0.5 text-[11px] select-all">npx @dbx-app/mcp-server</code>
|
||||
<a href="#" class="text-xs text-primary hover:underline" @click.prevent="emit('open-mcp-guide')">{{ t('welcome.mcpLearnMore') }}</a>
|
||||
<a href="#" class="text-xs text-primary hover:underline" @click.prevent="emit('open-mcp-guide')">{{
|
||||
t("welcome.mcpLearnMore")
|
||||
}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,7 +136,7 @@ const { t } = useI18n();
|
||||
|
||||
<!-- Project Info -->
|
||||
<div class="mt-2 flex items-center justify-center gap-3 text-[11px] text-muted-foreground/60">
|
||||
<span>DBX {{ appVersion ? 'v' + appVersion : '' }}</span>
|
||||
<span>DBX {{ appVersion ? "v" + appVersion : "" }}</span>
|
||||
<span>·</span>
|
||||
<a href="#" class="hover:text-foreground transition-colors" @click.prevent="emit('open-github')">GitHub</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowUpRight, Check, Columns3, Copy, Eye, Filter, History, Link, Loader2, RefreshCw, Search, SearchX, X } from "lucide-vue-next";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
Columns3,
|
||||
Copy,
|
||||
Eye,
|
||||
Filter,
|
||||
History,
|
||||
Link,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SearchX,
|
||||
X,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -30,13 +44,15 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
"open-target": [value: {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columnName?: string;
|
||||
}];
|
||||
"open-target": [
|
||||
value: {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columnName?: string;
|
||||
},
|
||||
];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -69,12 +85,14 @@ const targetLabel = computed(() => {
|
||||
|
||||
const counts = computed(() => summarizeLineageCounts(result.value?.items ?? []));
|
||||
|
||||
const confidenceOptions = computed<Array<{ value: "all" | FieldLineageConfidence; label: string; count: number }>>(() => [
|
||||
{ value: "all", label: t("lineage.all"), count: result.value?.items.length ?? 0 },
|
||||
{ value: "certain", label: t("lineage.certain"), count: counts.value.certain },
|
||||
{ value: "likely", label: t("lineage.likely"), count: counts.value.likely },
|
||||
{ value: "possible", label: t("lineage.possible"), count: counts.value.possible },
|
||||
]);
|
||||
const confidenceOptions = computed<Array<{ value: "all" | FieldLineageConfidence; label: string; count: number }>>(
|
||||
() => [
|
||||
{ value: "all", label: t("lineage.all"), count: result.value?.items.length ?? 0 },
|
||||
{ value: "certain", label: t("lineage.certain"), count: counts.value.certain },
|
||||
{ value: "likely", label: t("lineage.likely"), count: counts.value.likely },
|
||||
{ value: "possible", label: t("lineage.possible"), count: counts.value.possible },
|
||||
],
|
||||
);
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const query = searchText.value.trim().toLowerCase();
|
||||
@@ -90,7 +108,11 @@ const filteredItems = computed(() => {
|
||||
item.sqlSnippet,
|
||||
itemKindLabel(item),
|
||||
itemDescription(item),
|
||||
].some((value) => String(value ?? "").toLowerCase().includes(query));
|
||||
].some((value) =>
|
||||
String(value ?? "")
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
})
|
||||
.sort((a, b) => itemRank(a) - itemRank(b));
|
||||
});
|
||||
@@ -132,27 +154,36 @@ async function loadLineage() {
|
||||
await api.listTables(props.prefillConnectionId, props.prefillDatabase, schema),
|
||||
props.prefillTable,
|
||||
).slice(0, MAX_TABLES);
|
||||
const viewInfos = tableInfos.filter((table) => table.table_type.toUpperCase().includes("VIEW")).slice(0, MAX_VIEW_DDLS);
|
||||
const viewInfos = tableInfos
|
||||
.filter((table) => table.table_type.toUpperCase().includes("VIEW"))
|
||||
.slice(0, MAX_VIEW_DDLS);
|
||||
progressTotal.value = tableInfos.length + viewInfos.length + 1;
|
||||
|
||||
const tables: FieldLineageTable[] = [];
|
||||
for (let i = 0; i < tableInfos.length; i += BATCH_SIZE) {
|
||||
if (isStale(currentRun)) return;
|
||||
const batch = tableInfos.slice(i, i + BATCH_SIZE);
|
||||
const loaded = await Promise.all(batch.map(async (table) => {
|
||||
try {
|
||||
const columns = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, schema, table.name);
|
||||
const foreignKeys = await api.listForeignKeys(props.prefillConnectionId, props.prefillDatabase, schema, table.name);
|
||||
return {
|
||||
schema,
|
||||
name: table.name,
|
||||
columns: columns.map((column) => column.name),
|
||||
foreignKeys,
|
||||
};
|
||||
} catch {
|
||||
return { schema, name: table.name, columns: [], foreignKeys: [] };
|
||||
}
|
||||
}));
|
||||
const loaded = await Promise.all(
|
||||
batch.map(async (table) => {
|
||||
try {
|
||||
const columns = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, schema, table.name);
|
||||
const foreignKeys = await api.listForeignKeys(
|
||||
props.prefillConnectionId,
|
||||
props.prefillDatabase,
|
||||
schema,
|
||||
table.name,
|
||||
);
|
||||
return {
|
||||
schema,
|
||||
name: table.name,
|
||||
columns: columns.map((column) => column.name),
|
||||
foreignKeys,
|
||||
};
|
||||
} catch {
|
||||
return { schema, name: table.name, columns: [], foreignKeys: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
tables.push(...loaded);
|
||||
progressDone.value += batch.length;
|
||||
}
|
||||
@@ -220,7 +251,8 @@ function confidenceTone(confidence: FieldLineageItem["confidence"]) {
|
||||
|
||||
function itemRank(item: FieldLineageItem) {
|
||||
const confidenceRank = item.confidence === "certain" ? 0 : item.confidence === "likely" ? 10 : 20;
|
||||
const kindRank = item.kind === "foreignKey" ? 0 : item.kind === "viewReference" ? 1 : item.kind === "historyReference" ? 2 : 3;
|
||||
const kindRank =
|
||||
item.kind === "foreignKey" ? 0 : item.kind === "viewReference" ? 1 : item.kind === "historyReference" ? 2 : 3;
|
||||
return confidenceRank + kindRank;
|
||||
}
|
||||
|
||||
@@ -238,14 +270,14 @@ function itemPrimaryLabel(item: FieldLineageItem) {
|
||||
function itemDescription(item: FieldLineageItem) {
|
||||
if (item.kind === "foreignKey") {
|
||||
if (item.direction === "incoming") {
|
||||
return t("lineage.description.foreignKeyIncoming", { target: `${item.table}.${item.column}` });
|
||||
return t("lineage.description.foreignKeyIncoming", {
|
||||
target: `${item.table}.${item.column}`,
|
||||
});
|
||||
}
|
||||
return t("lineage.description.foreignKeyOutgoing", { target: `${item.table}.${item.column}` });
|
||||
}
|
||||
if (item.kind === "viewReference") {
|
||||
return item.confidence === "likely"
|
||||
? t("lineage.description.viewLikely")
|
||||
: t("lineage.description.viewPossible");
|
||||
return item.confidence === "likely" ? t("lineage.description.viewLikely") : t("lineage.description.viewPossible");
|
||||
}
|
||||
if (item.kind === "historyReference") {
|
||||
return item.confidence === "likely"
|
||||
@@ -279,11 +311,13 @@ function openItemTarget(item: FieldLineageItem) {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogScrollContent class="h-[78vh] min-h-[560px] max-h-[780px] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden gap-0 p-0 sm:max-w-[980px]">
|
||||
<DialogScrollContent
|
||||
class="h-[78vh] min-h-[560px] max-h-[780px] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden gap-0 p-0 sm:max-w-[980px]"
|
||||
>
|
||||
<DialogHeader class="border-b px-6 py-4 pr-12">
|
||||
<DialogTitle class="flex items-center gap-2 text-lg">
|
||||
<Link class="h-5 w-5" />
|
||||
{{ t('lineage.title') }}
|
||||
{{ t("lineage.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -291,7 +325,7 @@ function openItemTarget(item: FieldLineageItem) {
|
||||
<div class="border-b bg-muted/10 px-6 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">{{ t('lineage.targetField') }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("lineage.targetField") }}</div>
|
||||
<div class="mt-1 flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
||||
<span class="max-w-[520px] truncate font-semibold">{{ targetLabel }}</span>
|
||||
<Badge variant="outline">{{ props.prefillDatabase }}</Badge>
|
||||
@@ -299,9 +333,9 @@ function openItemTarget(item: FieldLineageItem) {
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="result" class="flex flex-wrap gap-2 text-xs">
|
||||
<Badge>{{ t('lineage.certain') }} {{ counts.certain }}</Badge>
|
||||
<Badge variant="secondary">{{ t('lineage.likely') }} {{ counts.likely }}</Badge>
|
||||
<Badge variant="outline">{{ t('lineage.possible') }} {{ counts.possible }}</Badge>
|
||||
<Badge>{{ t("lineage.certain") }} {{ counts.certain }}</Badge>
|
||||
<Badge variant="secondary">{{ t("lineage.likely") }} {{ counts.likely }}</Badge>
|
||||
<Badge variant="outline">{{ t("lineage.possible") }} {{ counts.possible }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -327,84 +361,118 @@ function openItemTarget(item: FieldLineageItem) {
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div v-if="loading" class="rounded-md border bg-muted/20 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t('lineage.loading', { done: progressDone, total: progressTotal || '-' }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="result && result.items.length === 0" class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground">
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t('lineage.empty') }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="result">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t('lineage.showing', { shown: filteredItems.length, total: result.items.length }) }}</span>
|
||||
<span v-if="filteredItems.length">
|
||||
{{ t('lineage.certain') }} {{ filteredCounts.certain }} ·
|
||||
{{ t('lineage.likely') }} {{ filteredCounts.likely }} ·
|
||||
{{ t('lineage.possible') }} {{ filteredCounts.possible }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredItems.length === 0" class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground">
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t('lineage.noFiltered') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="item in filteredItems" :key="item.id" class="rounded-md border bg-background transition-colors hover:bg-muted/25">
|
||||
<div class="flex items-start gap-3 p-3">
|
||||
<div :class="['mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border', confidenceTone(item.confidence)]">
|
||||
<component :is="itemIcon(item)" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="item.table"
|
||||
type="button"
|
||||
class="group inline-flex min-w-0 max-w-[560px] items-center gap-1 truncate rounded-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="t('lineage.openTable')"
|
||||
@click="openItemTarget(item)"
|
||||
>
|
||||
<span class="truncate">{{ itemPrimaryLabel(item) }}</span>
|
||||
<ArrowUpRight class="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<span v-else class="max-w-[560px] truncate font-medium">{{ itemPrimaryLabel(item) }}</span>
|
||||
<Badge v-if="item.schema" variant="outline" class="text-[10px]">{{ item.schema }}</Badge>
|
||||
<Badge variant="outline" class="text-[10px]">{{ itemKindLabel(item) }}</Badge>
|
||||
<Badge :variant="confidenceVariant(item.confidence)" class="text-[10px]">{{ t(`lineage.${item.confidence}`) }}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ itemDescription(item) }}</p>
|
||||
<pre v-if="item.sqlSnippet" class="mt-2 max-h-20 overflow-auto rounded-md bg-muted/40 p-2 text-xs whitespace-pre-wrap">{{ item.sqlSnippet }}</pre>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 shrink-0 p-0" :title="t('lineage.copy')" @click="copyItem(item)">
|
||||
<Check v-if="copiedId === item.id" class="h-4 w-4 text-emerald-600" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="loading" class="rounded-md border bg-muted/20 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("lineage.loading", { done: progressDone, total: progressTotal || "-" }) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="result && result.items.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground"
|
||||
>
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t("lineage.empty") }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="result">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t("lineage.showing", { shown: filteredItems.length, total: result.items.length }) }}</span>
|
||||
<span v-if="filteredItems.length">
|
||||
{{ t("lineage.certain") }} {{ filteredCounts.certain }} · {{ t("lineage.likely") }}
|
||||
{{ filteredCounts.likely }} · {{ t("lineage.possible") }}
|
||||
{{ filteredCounts.possible }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredItems.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground"
|
||||
>
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t("lineage.noFiltered") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="rounded-md border bg-background transition-colors hover:bg-muted/25"
|
||||
>
|
||||
<div class="flex items-start gap-3 p-3">
|
||||
<div
|
||||
:class="[
|
||||
'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border',
|
||||
confidenceTone(item.confidence),
|
||||
]"
|
||||
>
|
||||
<component :is="itemIcon(item)" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="item.table"
|
||||
type="button"
|
||||
class="group inline-flex min-w-0 max-w-[560px] items-center gap-1 truncate rounded-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="t('lineage.openTable')"
|
||||
@click="openItemTarget(item)"
|
||||
>
|
||||
<span class="truncate">{{ itemPrimaryLabel(item) }}</span>
|
||||
<ArrowUpRight
|
||||
class="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="max-w-[560px] truncate font-medium">{{ itemPrimaryLabel(item) }}</span>
|
||||
<Badge v-if="item.schema" variant="outline" class="text-[10px]">{{ item.schema }}</Badge>
|
||||
<Badge variant="outline" class="text-[10px]">{{ itemKindLabel(item) }}</Badge>
|
||||
<Badge :variant="confidenceVariant(item.confidence)" class="text-[10px]">{{
|
||||
t(`lineage.${item.confidence}`)
|
||||
}}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{{ itemDescription(item) }}
|
||||
</p>
|
||||
<pre
|
||||
v-if="item.sqlSnippet"
|
||||
class="mt-2 max-h-20 overflow-auto rounded-md bg-muted/40 p-2 text-xs whitespace-pre-wrap"
|
||||
>{{ item.sqlSnippet }}</pre
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 shrink-0 p-0"
|
||||
:title="t('lineage.copy')"
|
||||
@click="copyItem(item)"
|
||||
>
|
||||
<Check v-if="copiedId === item.id" class="h-4 w-4 text-emerald-600" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="border-t px-6 py-4">
|
||||
<Button v-if="loading" variant="outline" @click="cancelLoad">
|
||||
<X class="h-4 w-4" />
|
||||
{{ t('lineage.cancel') }}
|
||||
{{ t("lineage.cancel") }}
|
||||
</Button>
|
||||
<Button v-else variant="outline" @click="loadLineage">
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
{{ t('lineage.refresh') }}
|
||||
{{ t("lineage.refresh") }}
|
||||
</Button>
|
||||
<Button @click="dialogOpen = false">{{ t('common.close') }}</Button>
|
||||
<Button @click="dialogOpen = false">{{ t("common.close") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -8,14 +8,17 @@ import type { EditNode, EditNodeKind } from "@/types/editor";
|
||||
|
||||
defineOptions({ name: "JsonEditNode" });
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
node: EditNode;
|
||||
removable?: boolean;
|
||||
parentKind?: EditNodeKind | "root";
|
||||
}>(), {
|
||||
removable: true,
|
||||
parentKind: "root",
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
node: EditNode;
|
||||
removable?: boolean;
|
||||
parentKind?: EditNodeKind | "root";
|
||||
}>(),
|
||||
{
|
||||
removable: true,
|
||||
parentKind: "root",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "remove"): void;
|
||||
@@ -54,7 +57,7 @@ function fieldRows(value: string): number {
|
||||
|
||||
function fieldValueTone(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith("\"")) return "is-string";
|
||||
if (trimmed.startsWith('"')) return "is-string";
|
||||
if (/^(true|false)$/i.test(trimmed)) return "is-boolean";
|
||||
if (/^(null|NULL)$/i.test(trimmed)) return "is-null";
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return "is-number";
|
||||
@@ -136,11 +139,11 @@ function confirmRemoveChild() {
|
||||
wrap="soft"
|
||||
/>
|
||||
<div v-else class="json-edit-container-open">
|
||||
<span>{{ node.kind === 'array' ? '[' : '{' }}</span>
|
||||
<span>{{ node.kind === "array" ? "[" : "{" }}</span>
|
||||
<span class="json-edit-count">{{ node.children.length }}</span>
|
||||
</div>
|
||||
|
||||
<span class="json-edit-comma">{{ node.kind === 'value' ? ',' : '' }}</span>
|
||||
<span class="json-edit-comma">{{ node.kind === "value" ? "," : "" }}</span>
|
||||
|
||||
<Button
|
||||
v-if="removable"
|
||||
@@ -152,14 +155,10 @@ function confirmRemoveChild() {
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Button>
|
||||
<span v-else-if="node.readonlyValue" class="json-edit-lock">{{ t('mongo.readonlyId') }}</span>
|
||||
<span v-else-if="node.readonlyValue" class="json-edit-lock">{{ t("mongo.readonlyId") }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isContainer"
|
||||
class="json-edit-children"
|
||||
:style="{ '--mongo-key-width': childKeyWidth }"
|
||||
>
|
||||
<div v-if="isContainer" class="json-edit-children" :style="{ '--mongo-key-width': childKeyWidth }">
|
||||
<JsonEditNode
|
||||
v-for="(child, idx) in node.children"
|
||||
:key="child.key"
|
||||
@@ -170,12 +169,10 @@ function confirmRemoveChild() {
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addChild">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t('mongo.addField') }}
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }}
|
||||
</Button>
|
||||
|
||||
<div class="json-edit-close">
|
||||
{{ node.kind === 'array' ? ']' : '}' }}<span class="json-edit-comma">,</span>
|
||||
</div>
|
||||
<div class="json-edit-close">{{ node.kind === "array" ? "]" : "}" }}<span class="json-edit-comma">,</span></div>
|
||||
</div>
|
||||
|
||||
<DangerConfirmDialog
|
||||
|
||||
@@ -34,9 +34,7 @@ const error = ref("");
|
||||
const editFields = ref<EditNode[]>([]);
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
||||
type PendingDelete =
|
||||
| { kind: "document"; index: number }
|
||||
| { kind: "field"; index: number; name: string };
|
||||
type PendingDelete = { kind: "document"; index: number } | { kind: "field"; index: number; name: string };
|
||||
|
||||
const pendingDelete = ref<PendingDelete | null>(null);
|
||||
|
||||
@@ -67,8 +65,11 @@ async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await api.mongoFindDocuments(
|
||||
props.connectionId, props.database, props.collection,
|
||||
page.value * pageSize, pageSize
|
||||
props.connectionId,
|
||||
props.database,
|
||||
props.collection,
|
||||
page.value * pageSize,
|
||||
pageSize,
|
||||
);
|
||||
documents.value = result.documents.map(asRecord);
|
||||
total.value = result.total;
|
||||
@@ -106,7 +107,7 @@ function startEdit() {
|
||||
const doc = selectedDoc.value;
|
||||
if (!doc) return;
|
||||
editFields.value = Object.entries(doc).map(([name, value]) =>
|
||||
createEditNode(name, value, name === "_id", name === "_id")
|
||||
createEditNode(name, value, name === "_id", name === "_id"),
|
||||
);
|
||||
isEditing.value = true;
|
||||
isNew.value = false;
|
||||
@@ -148,7 +149,7 @@ function createEditNode(keyName: string, value: unknown, readonlyKey: boolean, r
|
||||
readonlyKey,
|
||||
readonlyValue,
|
||||
children: Object.entries(value as JsonRecord).map(([childName, child]) =>
|
||||
createEditNode(childName, child, readonlyValue, readonlyValue)
|
||||
createEditNode(childName, child, readonlyValue, readonlyValue),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -193,7 +194,7 @@ function parseFieldValue(raw: string): unknown {
|
||||
if (trimmed === "NULL") return null;
|
||||
if (/^(true|false|null)$/i.test(trimmed)) return JSON.parse(trimmed.toLowerCase());
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith("\"")) {
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith('"')) {
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
return raw;
|
||||
@@ -235,8 +236,17 @@ async function saveDoc() {
|
||||
} else if (selectedIdx.value !== null) {
|
||||
const current = documents.value[selectedIdx.value];
|
||||
const id = current?._id;
|
||||
if (!id) { error.value = "No _id field"; return; }
|
||||
await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(doc));
|
||||
if (!id) {
|
||||
error.value = "No _id field";
|
||||
return;
|
||||
}
|
||||
await api.mongoUpdateDocument(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
props.collection,
|
||||
String(id),
|
||||
JSON.stringify(doc),
|
||||
);
|
||||
}
|
||||
isEditing.value = false;
|
||||
isNew.value = false;
|
||||
@@ -254,7 +264,10 @@ async function applyDeleteDoc(idx: number) {
|
||||
error.value = "";
|
||||
try {
|
||||
await api.mongoDeleteDocument(props.connectionId, props.database, props.collection, String(id));
|
||||
if (selectedIdx.value === idx) { selectedIdx.value = null; editJson.value = ""; }
|
||||
if (selectedIdx.value === idx) {
|
||||
selectedIdx.value = null;
|
||||
editJson.value = "";
|
||||
}
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
error.value = String(e);
|
||||
@@ -291,16 +304,15 @@ function nextPage() {
|
||||
|
||||
function docPreview(doc: JsonRecord): string {
|
||||
const id = doc._id || "";
|
||||
const keys = Object.keys(doc).filter(k => k !== "_id").slice(0, 3);
|
||||
const preview = keys.map(k => `${k}: ${JSON.stringify(doc[k]).substring(0, 30)}`).join(", ");
|
||||
const keys = Object.keys(doc)
|
||||
.filter((k) => k !== "_id")
|
||||
.slice(0, 3);
|
||||
const preview = keys.map((k) => `${k}: ${JSON.stringify(doc[k]).substring(0, 30)}`).join(", ");
|
||||
return `${id} - ${preview}`;
|
||||
}
|
||||
|
||||
function highlightedJson(json: string): string {
|
||||
const escaped = json
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
return escaped.replace(
|
||||
/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
@@ -322,104 +334,121 @@ onMounted(load);
|
||||
<!-- Document list (left) -->
|
||||
<Pane :size="30" :min-size="15" :max-size="50">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="h-9 flex items-center gap-1 px-3 border-b shrink-0 text-xs text-muted-foreground">
|
||||
<span>{{ t('mongo.documents', { count: total }) }}</span>
|
||||
<span class="flex-1" />
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="load"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
|
||||
</div>
|
||||
<div class="h-9 flex items-center gap-1 px-3 border-b shrink-0 text-xs text-muted-foreground">
|
||||
<span>{{ t("mongo.documents", { count: total }) }}</span>
|
||||
<span class="flex-1" />
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="load"
|
||||
><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }"
|
||||
/></Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-for="(doc, idx) in documents"
|
||||
:key="idx"
|
||||
class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group"
|
||||
:class="{ 'bg-accent': selectedIdx === idx }"
|
||||
@click="selectDoc(idx)"
|
||||
>
|
||||
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="requestDeleteDoc(idx)">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-for="(doc, idx) in documents"
|
||||
:key="idx"
|
||||
class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group"
|
||||
:class="{ 'bg-accent': selectedIdx === idx }"
|
||||
@click="selectDoc(idx)"
|
||||
>
|
||||
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0"
|
||||
@click.stop="requestDeleteDoc(idx)"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="documents.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t("mongo.emptyCollection") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground shrink-0">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage">
|
||||
<ChevronLeft class="h-3 w-3" />
|
||||
</Button>
|
||||
<span>{{ page + 1 }} / {{ Math.max(1, Math.ceil(total / pageSize)) }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:disabled="(page + 1) * pageSize >= total"
|
||||
@click="nextPage"
|
||||
>
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="documents.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t('mongo.emptyCollection') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground shrink-0">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage">
|
||||
<ChevronLeft class="h-3 w-3" />
|
||||
</Button>
|
||||
<span>{{ page + 1 }} / {{ Math.max(1, Math.ceil(total / pageSize)) }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="(page + 1) * pageSize >= total" @click="nextPage">
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<!-- Document viewer/editor (right) -->
|
||||
<Pane :size="70">
|
||||
<div class="h-full flex flex-col min-w-0 overflow-hidden">
|
||||
<template v-if="selectedIdx !== null || isNew">
|
||||
<div class="h-9 flex items-center gap-2 px-4 border-b bg-muted/30 shrink-0">
|
||||
<Badge variant="secondary" class="text-xs">{{ isNew ? 'New' : selectedDoc?._id }}</Badge>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t('mongo.edit') }}</Button>
|
||||
<template v-if="isEditing">
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="addField">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t('mongo.addField') }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="cancelEdit">{{ t('grid.discard') }}</Button>
|
||||
<Button size="sm" class="h-6 text-xs" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t('grid.save') }}</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="flex-1 overflow-auto bg-muted/10">
|
||||
<div class="json-edit min-w-fit p-5 font-mono text-[13px] leading-6" :style="{ '--mongo-key-width': editKeyWidth }">
|
||||
<div class="json-edit-brace">{</div>
|
||||
|
||||
<JsonEditNode
|
||||
v-for="(field, idx) in editFields"
|
||||
:key="field.key"
|
||||
:node="field"
|
||||
parent-kind="root"
|
||||
:removable="!field.readonlyValue"
|
||||
@remove="requestRemoveField(idx)"
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addField">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t('mongo.addField') }}
|
||||
</Button>
|
||||
|
||||
<div class="json-edit-brace">}</div>
|
||||
<div class="h-full flex flex-col min-w-0 overflow-hidden">
|
||||
<template v-if="selectedIdx !== null || isNew">
|
||||
<div class="h-9 flex items-center gap-2 px-4 border-b bg-muted/30 shrink-0">
|
||||
<Badge variant="secondary" class="text-xs">{{ isNew ? "New" : selectedDoc?._id }}</Badge>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{
|
||||
t("mongo.edit")
|
||||
}}</Button>
|
||||
<template v-if="isEditing">
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="addField">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="cancelEdit">{{ t("grid.discard") }}</Button>
|
||||
<Button size="sm" class="h-6 text-xs" @click="saveDoc"
|
||||
><Save class="w-3 h-3 mr-1" />{{ t("grid.save") }}</Button
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="flex-1 overflow-auto bg-muted/10">
|
||||
<div
|
||||
class="json-edit min-w-fit p-5 font-mono text-[13px] leading-6"
|
||||
:style="{ '--mongo-key-width': editKeyWidth }"
|
||||
>
|
||||
<div class="json-edit-brace">{</div>
|
||||
|
||||
<JsonEditNode
|
||||
v-for="(field, idx) in editFields"
|
||||
:key="field.key"
|
||||
:node="field"
|
||||
parent-kind="root"
|
||||
:removable="!field.readonlyValue"
|
||||
@remove="requestRemoveField(idx)"
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addField">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }}
|
||||
</Button>
|
||||
|
||||
<div class="json-edit-brace">}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-auto bg-muted/10">
|
||||
<pre class="json-viewer min-w-fit p-5 font-mono text-[13px] leading-6" v-html="highlightedJson(editJson)" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t("mongo.selectDocument") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-auto bg-muted/10">
|
||||
<pre
|
||||
class="json-viewer min-w-fit p-5 font-mono text-[13px] leading-6"
|
||||
v-html="highlightedJson(editJson)"
|
||||
/>
|
||||
<div v-if="error" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
|
||||
{{ error }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t('mongo.selectDocument') }}
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteConfirm"
|
||||
:message="t('dangerDialog.deleteMessage')"
|
||||
:details="deleteDetails"
|
||||
:confirm-label="t('dangerDialog.deleteConfirm')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
|
||||
{{ error }}
|
||||
</div>
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteConfirm"
|
||||
:message="t('dangerDialog.deleteMessage')"
|
||||
:details="deleteDetails"
|
||||
:confirm-label="t('dangerDialog.deleteConfirm')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</template>
|
||||
@@ -489,5 +518,4 @@ onMounted(load);
|
||||
:global(.dark) :deep(.json-null) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -68,13 +68,20 @@ function onKeyDeleted() {
|
||||
|
||||
function typeColor(type: string): string {
|
||||
switch (type) {
|
||||
case "string": return "text-green-500";
|
||||
case "list": return "text-blue-500";
|
||||
case "set": return "text-purple-500";
|
||||
case "zset": return "text-amber-500";
|
||||
case "hash": return "text-orange-500";
|
||||
case "stream": return "text-teal-500";
|
||||
default: return "text-muted-foreground";
|
||||
case "string":
|
||||
return "text-green-500";
|
||||
case "list":
|
||||
return "text-blue-500";
|
||||
case "set":
|
||||
return "text-purple-500";
|
||||
case "zset":
|
||||
return "text-amber-500";
|
||||
case "hash":
|
||||
return "text-orange-500";
|
||||
case "stream":
|
||||
return "text-teal-500";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,71 +92,74 @@ onMounted(loadKeys);
|
||||
<Splitpanes class="h-full">
|
||||
<!-- Key list (left) -->
|
||||
<Pane :size="30" :min-size="15" :max-size="50">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<!-- Search bar -->
|
||||
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
v-model="searchPattern"
|
||||
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0"
|
||||
:placeholder="t('redis.pattern')"
|
||||
@keydown.enter="loadKeys"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="loadKeys">
|
||||
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
|
||||
<RefreshCw v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Key count -->
|
||||
<div class="h-9 flex items-center px-3 text-xs text-muted-foreground border-b shrink-0">
|
||||
{{ loading && keys.length === 0 ? t('redis.loadingKeys') : t('redis.keys', { count: keys.length }) }}
|
||||
</div>
|
||||
|
||||
<!-- Key list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-for="k in keys"
|
||||
:key="k.key"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer hover:bg-accent/50 border-b border-border/50"
|
||||
:class="{ 'bg-accent': selectedKey === k.key }"
|
||||
@click="selectKey(k.key)"
|
||||
>
|
||||
<Key class="w-3 h-3 shrink-0" :class="typeColor(k.key_type)" />
|
||||
<span class="truncate flex-1 font-mono">{{ k.key }}</span>
|
||||
<Badge variant="outline" class="text-[10px] px-1 py-0 shrink-0">{{ k.key_type }}</Badge>
|
||||
</div>
|
||||
<div v-if="keys.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t('redis.noKeys') }}
|
||||
</div>
|
||||
<div v-if="loading && keys.length === 0" class="px-3 py-8 flex items-center justify-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>{{ t('redis.loadingKeys') }}</span>
|
||||
</div>
|
||||
<div v-if="hasMore || (loading && keys.length > 0)" class="p-2">
|
||||
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loading" @click="loadMoreKeys">
|
||||
<Loader2 v-if="loading" class="w-3 h-3 mr-1.5 animate-spin" />
|
||||
{{ t('redis.loadMoreKeys') }}
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<!-- Search bar -->
|
||||
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
v-model="searchPattern"
|
||||
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0"
|
||||
:placeholder="t('redis.pattern')"
|
||||
@keydown.enter="loadKeys"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="loadKeys">
|
||||
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
|
||||
<RefreshCw v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Key count -->
|
||||
<div class="h-9 flex items-center px-3 text-xs text-muted-foreground border-b shrink-0">
|
||||
{{ loading && keys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: keys.length }) }}
|
||||
</div>
|
||||
|
||||
<!-- Key list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-for="k in keys"
|
||||
:key="k.key"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer hover:bg-accent/50 border-b border-border/50"
|
||||
:class="{ 'bg-accent': selectedKey === k.key }"
|
||||
@click="selectKey(k.key)"
|
||||
>
|
||||
<Key class="w-3 h-3 shrink-0" :class="typeColor(k.key_type)" />
|
||||
<span class="truncate flex-1 font-mono">{{ k.key }}</span>
|
||||
<Badge variant="outline" class="text-[10px] px-1 py-0 shrink-0">{{ k.key_type }}</Badge>
|
||||
</div>
|
||||
<div v-if="keys.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t("redis.noKeys") }}
|
||||
</div>
|
||||
<div
|
||||
v-if="loading && keys.length === 0"
|
||||
class="px-3 py-8 flex items-center justify-center gap-2 text-muted-foreground text-xs"
|
||||
>
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>{{ t("redis.loadingKeys") }}</span>
|
||||
</div>
|
||||
<div v-if="hasMore || (loading && keys.length > 0)" class="p-2">
|
||||
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loading" @click="loadMoreKeys">
|
||||
<Loader2 v-if="loading" class="w-3 h-3 mr-1.5 animate-spin" />
|
||||
{{ t("redis.loadMoreKeys") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<!-- Value viewer (right) -->
|
||||
<Pane :size="70">
|
||||
<div class="h-full min-w-0">
|
||||
<RedisValueViewer
|
||||
v-if="selectedKey"
|
||||
:key="selectedKey"
|
||||
:connection-id="connectionId"
|
||||
:key-name="selectedKey"
|
||||
@deleted="onKeyDeleted"
|
||||
/>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t('redis.selectKey') }}
|
||||
<div class="h-full min-w-0">
|
||||
<RedisValueViewer
|
||||
v-if="selectedKey"
|
||||
:key="selectedKey"
|
||||
:connection-id="connectionId"
|
||||
:key-name="selectedKey"
|
||||
@deleted="onKeyDeleted"
|
||||
/>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t("redis.selectKey") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</template>
|
||||
|
||||
@@ -38,8 +38,10 @@ const deleteDetails = computed(() => {
|
||||
const pending = pendingDelete.value;
|
||||
if (!pending) return "";
|
||||
if (pending.kind === "key") return t("dangerDialog.redisKeyDetails", { key: props.keyName });
|
||||
if (pending.kind === "hash") return t("dangerDialog.redisHashFieldDetails", { key: props.keyName, field: pending.field });
|
||||
if (pending.kind === "list") return t("dangerDialog.redisListItemDetails", { key: props.keyName, index: pending.index });
|
||||
if (pending.kind === "hash")
|
||||
return t("dangerDialog.redisHashFieldDetails", { key: props.keyName, field: pending.field });
|
||||
if (pending.kind === "list")
|
||||
return t("dangerDialog.redisListItemDetails", { key: props.keyName, index: pending.index });
|
||||
return t("dangerDialog.redisSetMemberDetails", { key: props.keyName, member: pending.member });
|
||||
});
|
||||
|
||||
@@ -147,7 +149,7 @@ onMounted(load);
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
{{ t('common.loading') }}
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
@@ -156,35 +158,65 @@ onMounted(load);
|
||||
<span class="font-mono text-sm font-medium truncate">{{ data.key }}</span>
|
||||
<Badge variant="secondary" class="text-xs">{{ data.key_type }}</Badge>
|
||||
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs">TTL: {{ data.ttl }}s</Badge>
|
||||
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs opacity-50">{{ t('redis.noExpiry') }}</Badge>
|
||||
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs opacity-50">{{
|
||||
t("redis.noExpiry")
|
||||
}}</Badge>
|
||||
<span class="flex-1" />
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="load"><RefreshCw class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="requestDeleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="requestDeleteKey"
|
||||
><Trash2 class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
</div>
|
||||
|
||||
<!-- String -->
|
||||
<div v-if="data.key_type === 'string'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<textarea v-model="editValue" class="flex-1 p-4 font-mono text-sm bg-background resize-none outline-none" @input="isEditing = true" />
|
||||
<textarea
|
||||
v-model="editValue"
|
||||
class="flex-1 p-4 font-mono text-sm bg-background resize-none outline-none"
|
||||
@input="isEditing = true"
|
||||
/>
|
||||
<div v-if="isEditing" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
|
||||
<Button variant="ghost" size="sm" @click="isEditing = false; editValue = String(data.value)">{{ t('grid.discard') }}</Button>
|
||||
<Button size="sm" @click="saveString"><Save class="w-3 h-3 mr-1" /> {{ t('grid.save') }}</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="
|
||||
isEditing = false;
|
||||
editValue = String(data.value);
|
||||
"
|
||||
>{{ t("grid.discard") }}</Button
|
||||
>
|
||||
<Button size="sm" @click="saveString"><Save class="w-3 h-3 mr-1" /> {{ t("grid.save") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div v-else-if="data.key_type === 'list'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{ t('redis.items', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t("redis.items", { count: Array.isArray(data.value) ? data.value.length : 0 })
|
||||
}}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="value" @keydown.enter="listPush" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="listPush"><Plus class="w-3 h-3 mr-1" />Push</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="listPush"
|
||||
><Plus class="w-3 h-3 mr-1" />Push</Button
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group">
|
||||
<div
|
||||
v-for="(item, idx) in data.value"
|
||||
:key="idx"
|
||||
class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group"
|
||||
>
|
||||
<span class="text-muted-foreground text-xs w-8 shrink-0">{{ idx }}</span>
|
||||
<span class="truncate flex-1">{{ item }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="requestListRemove(Number(idx))"><Trash2 class="w-3 h-3" /></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click="requestListRemove(Number(idx))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,15 +224,29 @@ onMounted(load);
|
||||
<!-- Set -->
|
||||
<div v-else-if="data.key_type === 'set'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{ t('redis.items', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t("redis.items", { count: Array.isArray(data.value) ? data.value.length : 0 })
|
||||
}}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="member" @keydown.enter="setAdd" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="setAdd"><Plus class="w-3 h-3 mr-1" />Add</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="setAdd"
|
||||
><Plus class="w-3 h-3 mr-1" />Add</Button
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group">
|
||||
<div
|
||||
v-for="(item, idx) in data.value"
|
||||
:key="idx"
|
||||
class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group"
|
||||
>
|
||||
<span class="truncate flex-1">{{ item }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="requestSetRemove(String(item))"><Trash2 class="w-3 h-3" /></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click="requestSetRemove(String(item))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,17 +254,31 @@ onMounted(load);
|
||||
<!-- Hash -->
|
||||
<div v-else-if="data.key_type === 'hash'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{ t('redis.fields', { count: Object.keys(data.value || {}).length }) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t("redis.fields", { count: Object.keys(data.value || {}).length })
|
||||
}}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newField" class="h-6 w-24 text-xs" placeholder="field" />
|
||||
<Input v-model="newValue" class="h-6 w-32 text-xs" placeholder="value" @keydown.enter="hashSet" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="hashSet"><Plus class="w-3 h-3 mr-1" />Set</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="hashSet"
|
||||
><Plus class="w-3 h-3 mr-1" />Set</Button
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-for="(val, field) in data.value" :key="String(field)" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3 group">
|
||||
<div
|
||||
v-for="(val, field) in data.value"
|
||||
:key="String(field)"
|
||||
class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3 group"
|
||||
>
|
||||
<span class="text-blue-500 shrink-0 min-w-24">{{ field }}</span>
|
||||
<span class="truncate text-muted-foreground flex-1">{{ val }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="requestHashDel(String(field))"><Trash2 class="w-3 h-3" /></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click="requestHashDel(String(field))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,9 +286,13 @@ onMounted(load);
|
||||
<!-- Sorted Set (readonly) -->
|
||||
<div v-else-if="data.key_type === 'zset'" class="flex-1 overflow-auto">
|
||||
<div class="px-4 py-1 text-xs text-muted-foreground border-b">
|
||||
{{ t('redis.members', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}
|
||||
{{ t("redis.members", { count: Array.isArray(data.value) ? data.value.length : 0 }) }}
|
||||
</div>
|
||||
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3">
|
||||
<div
|
||||
v-for="(item, idx) in data.value"
|
||||
:key="idx"
|
||||
class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3"
|
||||
>
|
||||
<span class="text-muted-foreground text-xs w-16 shrink-0">{{ item.score }}</span>
|
||||
<span class="truncate">{{ item.member }}</span>
|
||||
</div>
|
||||
@@ -237,11 +301,19 @@ onMounted(load);
|
||||
<!-- Stream (readonly) -->
|
||||
<div v-else-if="data.key_type === 'stream'" class="flex-1 overflow-auto">
|
||||
<div class="px-4 py-1 text-xs text-muted-foreground border-b">
|
||||
{{ t('redis.entries', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}
|
||||
{{ t("redis.entries", { count: Array.isArray(data.value) ? data.value.length : 0 }) }}
|
||||
</div>
|
||||
<div v-for="entry in data.value" :key="entry.id" class="px-4 py-2 border-b text-sm font-mono hover:bg-accent/50">
|
||||
<div
|
||||
v-for="entry in data.value"
|
||||
:key="entry.id"
|
||||
class="px-4 py-2 border-b text-sm font-mono hover:bg-accent/50"
|
||||
>
|
||||
<div class="mb-1 text-xs text-muted-foreground">{{ entry.id }}</div>
|
||||
<div v-for="(val, field) in entry.fields" :key="String(field)" class="grid grid-cols-[minmax(6rem,0.35fr)_1fr] gap-3 py-0.5">
|
||||
<div
|
||||
v-for="(val, field) in entry.fields"
|
||||
:key="String(field)"
|
||||
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr] gap-3 py-0.5"
|
||||
>
|
||||
<span class="truncate text-blue-500">{{ field }}</span>
|
||||
<span class="truncate text-muted-foreground">{{ val }}</span>
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
buildDatabaseSearchSql,
|
||||
buildSearchResultWhere,
|
||||
findMatchedSearchColumns,
|
||||
} from "@/lib/databaseSearch";
|
||||
import { buildDatabaseSearchSql, buildSearchResultWhere, findMatchedSearchColumns } from "@/lib/databaseSearch";
|
||||
import type { DatabaseType, TableInfo } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -25,13 +21,15 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
"open-target": [value: {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
whereInput?: string;
|
||||
}];
|
||||
"open-target": [
|
||||
value: {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
whereInput?: string;
|
||||
},
|
||||
];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -56,7 +54,17 @@ type SearchTableTask = {
|
||||
};
|
||||
|
||||
const SCHEMA_AWARE_TYPES = new Set<DatabaseType>(["postgres", "sqlserver", "oracle", "redshift"]);
|
||||
const SYSTEM_SCHEMAS = new Set(["information_schema", "pg_catalog", "sys", "system", "mysql", "performance_schema", "xdb", "outln", "dbsnmp"]);
|
||||
const SYSTEM_SCHEMAS = new Set([
|
||||
"information_schema",
|
||||
"pg_catalog",
|
||||
"sys",
|
||||
"system",
|
||||
"mysql",
|
||||
"performance_schema",
|
||||
"xdb",
|
||||
"outln",
|
||||
"dbsnmp",
|
||||
]);
|
||||
const MAX_TABLES = 200;
|
||||
|
||||
const keyword = ref("");
|
||||
@@ -73,7 +81,9 @@ const limitedTables = ref(false);
|
||||
const currentExecutionId = ref("");
|
||||
let runId = 0;
|
||||
|
||||
const connection = computed(() => props.prefillConnectionId ? connectionStore.getConfig(props.prefillConnectionId) : undefined);
|
||||
const connection = computed(() =>
|
||||
props.prefillConnectionId ? connectionStore.getConfig(props.prefillConnectionId) : undefined,
|
||||
);
|
||||
const scopeLabel = computed(() =>
|
||||
[connection.value?.name, props.prefillDatabase, props.prefillSchema].filter(Boolean).join(" / "),
|
||||
);
|
||||
@@ -279,7 +289,7 @@ function openResult(item: SearchResultItem) {
|
||||
<DialogHeader class="border-b px-5 py-4">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Search class="h-5 w-5" />
|
||||
{{ t('databaseSearch.title') }}
|
||||
{{ t("databaseSearch.title") }}
|
||||
</DialogTitle>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ scopeLabel }}</span>
|
||||
@@ -290,7 +300,7 @@ function openResult(item: SearchResultItem) {
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_9rem_auto]">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('databaseSearch.keyword') }}</Label>
|
||||
<Label class="text-xs">{{ t("databaseSearch.keyword") }}</Label>
|
||||
<Input
|
||||
v-model="keyword"
|
||||
class="h-9"
|
||||
@@ -300,40 +310,45 @@ function openResult(item: SearchResultItem) {
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('databaseSearch.limitPerTable') }}</Label>
|
||||
<Label class="text-xs">{{ t("databaseSearch.limitPerTable") }}</Label>
|
||||
<Input v-model.number="perTableLimit" class="h-9" type="number" min="1" max="100" :disabled="running" />
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<Button class="h-9" :disabled="!canSearch" @click="startSearch">
|
||||
<Loader2 v-if="running" class="h-4 w-4 animate-spin" />
|
||||
<Search v-else class="h-4 w-4" />
|
||||
{{ running ? t('databaseSearch.searching') : t('databaseSearch.search') }}
|
||||
{{ running ? t("databaseSearch.searching") : t("databaseSearch.search") }}
|
||||
</Button>
|
||||
<Button v-if="running" variant="destructive" class="h-9" @click="stopSearch">
|
||||
<Square class="h-4 w-4 fill-current" />
|
||||
{{ t('databaseSearch.stop') }}
|
||||
{{ t("databaseSearch.stop") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{{ t('databaseSearch.hint') }}
|
||||
{{ t("databaseSearch.hint") }}
|
||||
</div>
|
||||
|
||||
<div v-if="running || progressTotal" class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{{ loadingTables ? t('databaseSearch.loadingTables') : progressLabel }}</span>
|
||||
<span>{{ t('databaseSearch.resultCount', { count: results.length }) }}</span>
|
||||
<span v-if="limitedTables" class="text-amber-600">{{ t('databaseSearch.limitedTables', { count: 200 }) }}</span>
|
||||
<span>{{ loadingTables ? t("databaseSearch.loadingTables") : progressLabel }}</span>
|
||||
<span>{{ t("databaseSearch.resultCount", { count: results.length }) }}</span>
|
||||
<span v-if="limitedTables" class="text-amber-600">{{
|
||||
t("databaseSearch.limitedTables", { count: 200 })
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="generalError" class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<div
|
||||
v-if="generalError"
|
||||
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle class="mt-0.5 h-4 w-4" />
|
||||
<span>{{ generalError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium">{{ t('databaseSearch.results') }}</div>
|
||||
<div class="text-sm font-medium">{{ t("databaseSearch.results") }}</div>
|
||||
<Badge variant="outline">{{ results.length }}</Badge>
|
||||
</div>
|
||||
<div v-if="results.length" class="max-h-[360px] space-y-2 overflow-auto pr-1">
|
||||
@@ -346,31 +361,36 @@ function openResult(item: SearchResultItem) {
|
||||
<Table2 class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="truncate font-medium">{{ item.schema ? `${item.schema}.${item.tableName}` : item.tableName }}</span>
|
||||
<span class="truncate font-medium">{{
|
||||
item.schema ? `${item.schema}.${item.tableName}` : item.tableName
|
||||
}}</span>
|
||||
<Badge v-for="column in item.matchedColumns" :key="column" variant="secondary">{{ column }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 truncate text-xs text-muted-foreground">{{ item.preview }}</div>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-primary">{{ t('databaseSearch.openResult') }}</span>
|
||||
<span class="shrink-0 text-xs text-primary">{{ t("databaseSearch.openResult") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="rounded-md border border-dashed py-10 text-center text-sm text-muted-foreground">
|
||||
{{ running ? t('databaseSearch.waiting') : t('databaseSearch.noResults') }}
|
||||
{{ running ? t("databaseSearch.waiting") : t("databaseSearch.noResults") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tableErrors.length" class="space-y-2">
|
||||
<div class="text-sm font-medium text-muted-foreground">{{ t('databaseSearch.errors') }}</div>
|
||||
<div class="text-sm font-medium text-muted-foreground">
|
||||
{{ t("databaseSearch.errors") }}
|
||||
</div>
|
||||
<div class="max-h-24 space-y-1 overflow-auto rounded-md border bg-muted/20 p-2 text-xs text-muted-foreground">
|
||||
<div v-for="error in tableErrors" :key="`${error.tableName}:${error.message}`">
|
||||
<span class="font-medium text-foreground">{{ error.tableName }}</span>: {{ error.message }}
|
||||
<span class="font-medium text-foreground">{{ error.tableName }}</span
|
||||
>: {{ error.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="border-t px-5 py-3">
|
||||
<Button variant="outline" @click="dialogOpen = false">{{ t('common.close') }}</Button>
|
||||
<Button variant="outline" @click="dialogOpen = false">{{ t("common.close") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -149,7 +149,7 @@ function createNewGroup() {
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<DropdownMenuLabel class="text-xs">{{ t('sidebar.filterByType') }}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel class="text-xs">{{ t("sidebar.filterByType") }}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
v-for="item in typeStats"
|
||||
@@ -167,7 +167,7 @@ function createNewGroup() {
|
||||
<template v-if="hasTypeFilter">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @select.prevent="clearTypeFilter">
|
||||
<span class="text-xs text-muted-foreground">{{ t('sidebar.clearFilter') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t("sidebar.clearFilter") }}</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
@@ -184,7 +184,7 @@ function createNewGroup() {
|
||||
@rename-started="pendingRenameGroupId = null"
|
||||
/>
|
||||
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t('sidebar.noConnections') }}
|
||||
{{ t("sidebar.noConnections") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+291
-156
@@ -2,16 +2,47 @@
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
|
||||
Loader2, FolderOpen, FolderClosed, Trash2, TerminalSquare, RefreshCw,
|
||||
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug,
|
||||
Pin, ArrowRightLeft, Download, FileCode, Network, FileUp, PencilRuler, Search,
|
||||
FolderInput, FolderPlus,
|
||||
Database,
|
||||
Table,
|
||||
Columns3,
|
||||
Eye,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
FolderClosed,
|
||||
Trash2,
|
||||
TerminalSquare,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
TableProperties,
|
||||
Key,
|
||||
Link,
|
||||
Zap,
|
||||
ListTree,
|
||||
Pencil,
|
||||
Plug,
|
||||
Unplug,
|
||||
Pin,
|
||||
ArrowRightLeft,
|
||||
Download,
|
||||
FileCode,
|
||||
Network,
|
||||
FileUp,
|
||||
PencilRuler,
|
||||
Search,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem,
|
||||
ContextMenuSeparator, ContextMenuTrigger,
|
||||
ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuSubContent,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -29,9 +60,7 @@ import { qualifiedTableName as buildQualifiedTableName, quoteTableIdentifier } f
|
||||
import { treeNodeRowAction } from "@/lib/treeNodeClick";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
@@ -53,8 +82,28 @@ const emit = defineEmits<{
|
||||
|
||||
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
|
||||
const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
|
||||
const databaseSearchSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift", "duckdb", "clickhouse"]);
|
||||
const tableImportSupportedTypes = new Set(["mysql", "postgres", "sqlite", "duckdb", "clickhouse", "sqlserver", "oracle", "doris", "starrocks", "redshift"]);
|
||||
const databaseSearchSupportedTypes = new Set([
|
||||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
"redshift",
|
||||
"duckdb",
|
||||
"clickhouse",
|
||||
]);
|
||||
const tableImportSupportedTypes = new Set([
|
||||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"duckdb",
|
||||
"clickhouse",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
"doris",
|
||||
"starrocks",
|
||||
"redshift",
|
||||
]);
|
||||
const tableStructureSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver"]);
|
||||
const fieldLineageSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
|
||||
const isExportingDatabase = ref(false);
|
||||
@@ -151,47 +200,50 @@ async function toggle() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.isExpanded) { node.isExpanded = false; return; }
|
||||
if (node.isExpanded) {
|
||||
node.isExpanded = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (node.type === "connection" && node.connectionId) {
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "redis") {
|
||||
await connectionStore.loadRedisDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb" || config?.db_type === "elasticsearch") {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else {
|
||||
await connectionStore.loadDatabases(node.connectionId);
|
||||
if (node.type === "connection" && node.connectionId) {
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "redis") {
|
||||
await connectionStore.loadRedisDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb" || config?.db_type === "elasticsearch") {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else {
|
||||
await connectionStore.loadDatabases(node.connectionId);
|
||||
}
|
||||
} else if (node.type === "redis-db" && node.connectionId && node.database) {
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "Redis"}:db${node.database}`;
|
||||
queryStore.createTab(node.connectionId, node.database, tabTitle, "redis");
|
||||
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
|
||||
await connectionStore.loadMongoCollections(node.connectionId, node.database);
|
||||
} else if (node.type === "mongo-collection" && node.connectionId && node.database) {
|
||||
const tabTitle = `${node.database}.${node.label}`;
|
||||
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
} else if (node.type === "database" && node.connectionId && node.database) {
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "postgres" || config?.db_type === "sqlserver") {
|
||||
await connectionStore.loadSchemas(node.connectionId, node.database);
|
||||
} else {
|
||||
await connectionStore.loadTables(node.connectionId, node.database);
|
||||
}
|
||||
} else if (node.type === "schema" && node.connectionId && node.database && node.schema) {
|
||||
await connectionStore.loadTables(node.connectionId, node.database, node.schema);
|
||||
} else if ((node.type === "table" || node.type === "view") && node.connectionId && node.database) {
|
||||
await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema);
|
||||
} else if (node.type === "group-columns" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadColumns(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-indexes" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-fkeys" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-triggers" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema);
|
||||
}
|
||||
} else if (node.type === "redis-db" && node.connectionId && node.database) {
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "Redis"}:db${node.database}`;
|
||||
queryStore.createTab(node.connectionId, node.database, tabTitle, "redis");
|
||||
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
|
||||
await connectionStore.loadMongoCollections(node.connectionId, node.database);
|
||||
} else if (node.type === "mongo-collection" && node.connectionId && node.database) {
|
||||
const tabTitle = `${node.database}.${node.label}`;
|
||||
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
} else if (node.type === "database" && node.connectionId && node.database) {
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "postgres" || config?.db_type === "sqlserver") {
|
||||
await connectionStore.loadSchemas(node.connectionId, node.database);
|
||||
} else {
|
||||
await connectionStore.loadTables(node.connectionId, node.database);
|
||||
}
|
||||
} else if (node.type === "schema" && node.connectionId && node.database && node.schema) {
|
||||
await connectionStore.loadTables(node.connectionId, node.database, node.schema);
|
||||
} else if ((node.type === "table" || node.type === "view") && node.connectionId && node.database) {
|
||||
await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema);
|
||||
} else if (node.type === "group-columns" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadColumns(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-indexes" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-fkeys" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-triggers" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
@@ -218,9 +270,10 @@ async function openData() {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
if (!config) throw new Error("Connection config not found");
|
||||
|
||||
const qualifiedName = (config.db_type === "postgres" || config.db_type === "oracle" || config.db_type === "sqlserver") && node.schema
|
||||
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
|
||||
: quoteIdent(node.label);
|
||||
const qualifiedName =
|
||||
(config.db_type === "postgres" || config.db_type === "oracle" || config.db_type === "sqlserver") && node.schema
|
||||
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
|
||||
: quoteIdent(node.label);
|
||||
|
||||
const querySchema = node.schema || node.database;
|
||||
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
@@ -333,9 +386,8 @@ async function fetchExportTableRows(
|
||||
|
||||
while (rows.length < DATABASE_EXPORT_ROW_LIMIT) {
|
||||
const remaining = DATABASE_EXPORT_ROW_LIMIT - rows.length;
|
||||
const limit = databaseType === "sqlserver"
|
||||
? DATABASE_EXPORT_ROW_LIMIT
|
||||
: Math.min(DATABASE_EXPORT_PAGE_SIZE, remaining);
|
||||
const limit =
|
||||
databaseType === "sqlserver" ? DATABASE_EXPORT_ROW_LIMIT : Math.min(DATABASE_EXPORT_PAGE_SIZE, remaining);
|
||||
const sql = buildExportPageSql({
|
||||
databaseType,
|
||||
schema: table.schema,
|
||||
@@ -362,7 +414,10 @@ async function saveFileContent(content: string, defaultFileName: string, filterN
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({ defaultPath: defaultFileName, filters: [{ name: filterName, extensions: [filterExt] }] });
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
if (path) await writeTextFile(path, content);
|
||||
} else {
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
@@ -401,9 +456,7 @@ async function exportDatabase() {
|
||||
});
|
||||
}
|
||||
|
||||
const scopeName = node.type === "schema" && node.schema
|
||||
? `${node.database}.${node.schema}`
|
||||
: node.database;
|
||||
const scopeName = node.type === "schema" && node.schema ? `${node.database}.${node.schema}` : node.database;
|
||||
const content = buildDatabaseSqlExport({
|
||||
databaseName: scopeName,
|
||||
tables: exportedTables,
|
||||
@@ -412,7 +465,13 @@ async function exportDatabase() {
|
||||
});
|
||||
|
||||
await saveFileContent(content, `${safeFileName(scopeName)}.sql`, "SQL", "sql");
|
||||
toast(t("contextMenu.exportDatabaseSuccess", { count: exportedTables.length, limit: DATABASE_EXPORT_ROW_LIMIT }), 3000);
|
||||
toast(
|
||||
t("contextMenu.exportDatabaseSuccess", {
|
||||
count: exportedTables.length,
|
||||
limit: DATABASE_EXPORT_ROW_LIMIT,
|
||||
}),
|
||||
3000,
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error("Export database failed:", e);
|
||||
toast(t("contextMenu.exportDatabaseFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -441,9 +500,10 @@ async function exportData(format: "csv" | "json" | "sql") {
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const qualifiedName = (config.db_type === "postgres" || config.db_type === "oracle" || config.db_type === "sqlserver") && node.schema
|
||||
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
|
||||
: quoteIdent(node.label);
|
||||
const qualifiedName =
|
||||
(config.db_type === "postgres" || config.db_type === "oracle" || config.db_type === "sqlserver") && node.schema
|
||||
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
|
||||
: quoteIdent(node.label);
|
||||
const result = await api.executeQuery(node.connectionId, node.database, `SELECT * FROM ${qualifiedName}`);
|
||||
|
||||
let content: string;
|
||||
@@ -459,7 +519,9 @@ async function exportData(format: "csv" | "json" | "sql") {
|
||||
ext = "json";
|
||||
const data = result.rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
||||
result.columns.forEach((col, i) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
content = JSON.stringify(data, null, 2);
|
||||
@@ -467,11 +529,13 @@ async function exportData(format: "csv" | "json" | "sql") {
|
||||
ext = "sql";
|
||||
const cols = result.columns.map((c) => quoteIdent(c)).join(", ");
|
||||
const lines = result.rows.map((row) => {
|
||||
const vals = row.map((v) => {
|
||||
if (v === null) return "NULL";
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
}).join(", ");
|
||||
const vals = row
|
||||
.map((v) => {
|
||||
if (v === null) return "NULL";
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
})
|
||||
.join(", ");
|
||||
return `INSERT INTO ${qualifiedName} (${cols}) VALUES (${vals});`;
|
||||
});
|
||||
content = lines.join("\n");
|
||||
@@ -596,25 +660,50 @@ const canOpenDatabaseSearch = computed(() => {
|
||||
});
|
||||
const canOpenTableImport = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "table" && !!props.node.database && !!config && tableImportSupportedTypes.has(config.db_type);
|
||||
return (
|
||||
props.node.type === "table" && !!props.node.database && !!config && tableImportSupportedTypes.has(config.db_type)
|
||||
);
|
||||
});
|
||||
const canOpenStructureEditor = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "table" && !!props.node.database && !!config && tableStructureSupportedTypes.has(config.db_type);
|
||||
return (
|
||||
props.node.type === "table" && !!props.node.database && !!config && tableStructureSupportedTypes.has(config.db_type)
|
||||
);
|
||||
});
|
||||
const canOpenFieldLineage = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "column" && !!props.node.database && !!props.node.tableName && !!config && fieldLineageSupportedTypes.has(config.db_type);
|
||||
return (
|
||||
props.node.type === "column" &&
|
||||
!!props.node.database &&
|
||||
!!props.node.tableName &&
|
||||
!!config &&
|
||||
fieldLineageSupportedTypes.has(config.db_type)
|
||||
);
|
||||
});
|
||||
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));
|
||||
const hasTypeMenu = computed(() => {
|
||||
const t = props.node.type;
|
||||
return t === "connection" || t === "database" || t === "schema" || t === "table" || t === "view" || t === "column" || isGroupLabel(props.node);
|
||||
return (
|
||||
t === "connection" ||
|
||||
t === "database" ||
|
||||
t === "schema" ||
|
||||
t === "table" ||
|
||||
t === "view" ||
|
||||
t === "column" ||
|
||||
isGroupLabel(props.node)
|
||||
);
|
||||
});
|
||||
const columnComment = computed(() => props.node.type === "column" && props.node.meta && "comment" in props.node.meta ? (props.node.meta as any).comment : null);
|
||||
const columnComment = computed(() =>
|
||||
props.node.type === "column" && props.node.meta && "comment" in props.node.meta
|
||||
? (props.node.meta as any).comment
|
||||
: null,
|
||||
);
|
||||
const paddingLeft = `${props.depth * 16 + 8}px`;
|
||||
const isConnected = computed(() =>
|
||||
props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId)
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
props.node.type === "connection" &&
|
||||
!!props.node.connectionId &&
|
||||
connectionStore.connectedIds.has(props.node.connectionId),
|
||||
);
|
||||
|
||||
function connectionIconType(connectionId?: string) {
|
||||
@@ -637,9 +726,7 @@ const visibleChildren = computed(() => {
|
||||
|
||||
const hasMoreChildren = computed(() => (props.node.children?.length ?? 0) > displayLimit.value);
|
||||
|
||||
const remainingCount = computed(() =>
|
||||
(props.node.children?.length ?? 0) - displayLimit.value
|
||||
);
|
||||
const remainingCount = computed(() => (props.node.children?.length ?? 0) - displayLimit.value);
|
||||
|
||||
function togglePin() {
|
||||
connectionStore.toggleTreeNodePin(props.node.id);
|
||||
@@ -661,11 +748,15 @@ function startRenameGroup() {
|
||||
emit("rename-started");
|
||||
}
|
||||
|
||||
watch(() => props.pendingRename, (val) => {
|
||||
if (val && props.node.type === "connection-group") {
|
||||
startRenameGroup();
|
||||
}
|
||||
}, { immediate: true });
|
||||
watch(
|
||||
() => props.pendingRename,
|
||||
(val) => {
|
||||
if (val && props.node.type === "connection-group") {
|
||||
startRenameGroup();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function finishRenameGroup() {
|
||||
isRenamingGroup.value = false;
|
||||
@@ -728,31 +819,30 @@ const currentGroupId = computed(() => {
|
||||
// --- Drag and Drop ---
|
||||
import { useDragSort } from "@/composables/useDragSort";
|
||||
|
||||
const { state: dragState, startDrag, updateTarget, clearTarget } = useDragSort(
|
||||
(draggedId, targetId, position) => connectionStore.reorderSidebarEntry(draggedId, targetId, position),
|
||||
);
|
||||
const {
|
||||
state: dragState,
|
||||
startDrag,
|
||||
updateTarget,
|
||||
clearTarget,
|
||||
} = useDragSort((draggedId, targetId, position) => connectionStore.reorderSidebarEntry(draggedId, targetId, position));
|
||||
|
||||
const isDraggable = computed(() => {
|
||||
if (props.dragDisabled) return false;
|
||||
return props.node.type === "connection" || props.node.type === "connection-group";
|
||||
});
|
||||
|
||||
const isDropTarget = computed(() =>
|
||||
props.node.type === "connection" || props.node.type === "connection-group",
|
||||
);
|
||||
const isDropTarget = computed(() => props.node.type === "connection" || props.node.type === "connection-group");
|
||||
|
||||
const showDropBefore = computed(() =>
|
||||
dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "before",
|
||||
const showDropBefore = computed(
|
||||
() => dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "before",
|
||||
);
|
||||
const showDropAfter = computed(() =>
|
||||
dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "after",
|
||||
const showDropAfter = computed(
|
||||
() => dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "after",
|
||||
);
|
||||
const showDropInside = computed(() =>
|
||||
dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "inside",
|
||||
);
|
||||
const isDragging = computed(() =>
|
||||
dragState.active && dragState.draggedId === props.node.id,
|
||||
const showDropInside = computed(
|
||||
() => dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "inside",
|
||||
);
|
||||
const isDragging = computed(() => dragState.active && dragState.draggedId === props.node.id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -771,8 +861,16 @@ const isDragging = computed(() =>
|
||||
@mousemove="isDropTarget ? updateTarget($event, node.id, node.type) : undefined"
|
||||
@mouseleave="clearTarget(node.id)"
|
||||
>
|
||||
<div v-if="showDropBefore" class="absolute right-2 top-0 h-0.5 bg-primary rounded-full pointer-events-none" :style="{ left: paddingLeft }" />
|
||||
<div v-if="showDropAfter" class="absolute right-2 bottom-0 h-0.5 bg-primary rounded-full pointer-events-none" :style="{ left: paddingLeft }" />
|
||||
<div
|
||||
v-if="showDropBefore"
|
||||
class="absolute right-2 top-0 h-0.5 bg-primary rounded-full pointer-events-none"
|
||||
:style="{ left: paddingLeft }"
|
||||
/>
|
||||
<div
|
||||
v-if="showDropAfter"
|
||||
class="absolute right-2 bottom-0 h-0.5 bg-primary rounded-full pointer-events-none"
|
||||
:style="{ left: paddingLeft }"
|
||||
/>
|
||||
<template v-if="canExpand">
|
||||
<button
|
||||
type="button"
|
||||
@@ -785,9 +883,22 @@ const isDragging = computed(() =>
|
||||
</button>
|
||||
</template>
|
||||
<span v-else class="w-3.5 h-3.5 shrink-0" />
|
||||
<DatabaseIcon v-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="w-3.5 h-3.5 shrink-0" />
|
||||
<component v-else :is="getIconInfo(node)?.icon || Database" class="w-3.5 h-3.5 shrink-0" :class="getIconInfo(node)?.colorClass" />
|
||||
<span v-if="node.type === 'connection'" class="h-3 w-1.5 rounded-full shrink-0" :style="{ backgroundColor: connectionColor || '#9ca3af' }" />
|
||||
<DatabaseIcon
|
||||
v-if="node.type === 'connection'"
|
||||
:db-type="connectionIconType(node.connectionId)"
|
||||
class="w-3.5 h-3.5 shrink-0"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="getIconInfo(node)?.icon || Database"
|
||||
class="w-3.5 h-3.5 shrink-0"
|
||||
:class="getIconInfo(node)?.colorClass"
|
||||
/>
|
||||
<span
|
||||
v-if="node.type === 'connection'"
|
||||
class="h-3 w-1.5 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: connectionColor || '#9ca3af' }"
|
||||
/>
|
||||
<input
|
||||
v-if="isRenamingGroup"
|
||||
v-model="renameInput"
|
||||
@@ -799,8 +910,15 @@ const isDragging = computed(() =>
|
||||
@vue:mounted="($event: any) => $event.el.focus()"
|
||||
/>
|
||||
<span v-else class="min-w-0 flex-1 truncate">{{ isGroupLabel(node) ? t(node.label) : node.label }}</span>
|
||||
<span v-if="columnComment" class="truncate text-muted-foreground/60 text-[10px] max-w-[40%]">{{ columnComment }}</span>
|
||||
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
<span v-if="columnComment" class="truncate text-muted-foreground/60 text-[10px] max-w-[40%]">{{
|
||||
columnComment
|
||||
}}</span>
|
||||
<span
|
||||
v-if="
|
||||
node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)
|
||||
"
|
||||
class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0"
|
||||
/>
|
||||
<button
|
||||
v-if="canPin"
|
||||
class="rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/15 hover:text-foreground focus:opacity-100"
|
||||
@@ -812,7 +930,13 @@ const isDragging = computed(() =>
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="node.isExpanded && node.children">
|
||||
<TreeItem v-for="child in visibleChildren" :key="child.id" :node="child" :depth="depth + 1" :drag-disabled="dragDisabled" />
|
||||
<TreeItem
|
||||
v-for="child in visibleChildren"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:drag-disabled="dragDisabled"
|
||||
/>
|
||||
<div
|
||||
v-if="hasMoreChildren"
|
||||
class="flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent text-xs text-muted-foreground"
|
||||
@@ -820,7 +944,7 @@ const isDragging = computed(() =>
|
||||
@click="showMore"
|
||||
>
|
||||
<Loader2 v-if="node.isLoading" class="w-3 h-3 shrink-0 animate-spin" />
|
||||
<span>{{ t('sidebar.showMore', { count: Math.min(CHILDREN_PAGE_SIZE, remainingCount) }) }}</span>
|
||||
<span>{{ t("sidebar.showMore", { count: Math.min(CHILDREN_PAGE_SIZE, remainingCount) }) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -829,27 +953,27 @@ const isDragging = computed(() =>
|
||||
<ContextMenuContent class="w-auto min-w-36">
|
||||
<ContextMenuItem v-if="canPin" @click="togglePin">
|
||||
<Pin class="w-4 h-4" :class="{ 'fill-current': isPinned }" />
|
||||
{{ isPinned ? t('contextMenu.unpin') : t('contextMenu.pin') }}
|
||||
{{ isPinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator v-if="canPin && hasTypeMenu" />
|
||||
|
||||
<template v-if="node.type === 'connection'">
|
||||
<ContextMenuItem v-if="!isConnected" @click="toggle">
|
||||
<Plug class="w-4 h-4" /> {{ t('contextMenu.openConnection') }}
|
||||
<Plug class="w-4 h-4" /> {{ t("contextMenu.openConnection") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-else @click="disconnectConnection">
|
||||
<Unplug class="w-4 h-4" /> {{ t('contextMenu.closeConnection') }}
|
||||
<Unplug class="w-4 h-4" /> {{ t("contextMenu.closeConnection") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="newQuery">
|
||||
<TerminalSquare class="w-4 h-4" /> {{ t('contextMenu.newQuery') }}
|
||||
<TerminalSquare class="w-4 h-4" /> {{ t("contextMenu.newQuery") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenSqlFileExecution" @click="openSqlFileExecution">
|
||||
<FileCode class="w-4 h-4" /> {{ t('sqlFile.title') }}
|
||||
<FileCode class="w-4 h-4" /> {{ t("sqlFile.title") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub v-if="availableGroups.length > 0 || currentGroupId">
|
||||
<ContextMenuSubTrigger>
|
||||
<FolderInput class="w-4 h-4" /> {{ t('connectionGroup.moveToGroup') }}
|
||||
<FolderInput class="w-4 h-4" /> {{ t("connectionGroup.moveToGroup") }}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
@@ -862,89 +986,89 @@ const isDragging = computed(() =>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator v-if="currentGroupId" />
|
||||
<ContextMenuItem v-if="currentGroupId" @click="moveToGroup(null)">
|
||||
{{ t('connectionGroup.ungrouped') }}
|
||||
{{ t("connectionGroup.ungrouped") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="moveToNewGroup">
|
||||
<FolderPlus class="w-4 h-4" /> {{ t('connectionGroup.newGroup') }}
|
||||
<FolderPlus class="w-4 h-4" /> {{ t("connectionGroup.newGroup") }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem v-else @click="moveToNewGroup">
|
||||
<FolderPlus class="w-4 h-4" /> {{ t('connectionGroup.moveToNewGroup') }}
|
||||
<FolderPlus class="w-4 h-4" /> {{ t("connectionGroup.moveToNewGroup") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
|
||||
<RefreshCw class="w-4 h-4" /> {{ t("contextMenu.refreshChildren") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="editConnection">
|
||||
<Pencil class="w-4 h-4" /> {{ t('contextMenu.editConnection') }}
|
||||
<Pencil class="w-4 h-4" /> {{ t("contextMenu.editConnection") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem class="text-destructive" @click="deleteConnection">
|
||||
<Trash2 class="w-4 h-4" /> {{ t('contextMenu.deleteConnection') }}
|
||||
<Trash2 class="w-4 h-4" /> {{ t("contextMenu.deleteConnection") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'connection-group'">
|
||||
<ContextMenuItem @click="startRenameGroup">
|
||||
<Pencil class="w-4 h-4" /> {{ t('connectionGroup.renameGroup') }}
|
||||
<Pencil class="w-4 h-4" /> {{ t("connectionGroup.renameGroup") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem class="text-destructive" @click="deleteConnectionGroup">
|
||||
<Trash2 class="w-4 h-4" /> {{ t('connectionGroup.deleteGroup') }}
|
||||
<Trash2 class="w-4 h-4" /> {{ t("connectionGroup.deleteGroup") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'database' || node.type === 'schema'">
|
||||
<ContextMenuItem @click="newQuery">
|
||||
<TerminalSquare class="w-4 h-4" /> {{ t('contextMenu.newQuery') }}
|
||||
<TerminalSquare class="w-4 h-4" /> {{ t("contextMenu.newQuery") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenSqlFileExecution" @click="openSqlFileExecution">
|
||||
<FileCode class="w-4 h-4" /> {{ t('sqlFile.title') }}
|
||||
<FileCode class="w-4 h-4" /> {{ t("sqlFile.title") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram">
|
||||
<Network class="w-4 h-4" /> {{ t('diagram.open') }}
|
||||
<Network class="w-4 h-4" /> {{ t("diagram.open") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenDatabaseSearch" @click="openDatabaseSearch">
|
||||
<Search class="w-4 h-4" /> {{ t('databaseSearch.open') }}
|
||||
<Search class="w-4 h-4" /> {{ t("databaseSearch.open") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
|
||||
<RefreshCw class="w-4 h-4" /> {{ t("contextMenu.refreshChildren") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="openTransfer">
|
||||
<ArrowRightLeft class="w-4 h-4" /> {{ t('transfer.dataTransfer') }}
|
||||
<ArrowRightLeft class="w-4 h-4" /> {{ t("transfer.dataTransfer") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="openSchemaDiff">
|
||||
<ArrowRightLeft class="w-4 h-4" /> {{ t('diff.title') }}
|
||||
<ArrowRightLeft class="w-4 h-4" /> {{ t("diff.title") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem :disabled="isExportingDatabase" @click="exportDatabase">
|
||||
<Loader2 v-if="isExportingDatabase" class="w-4 h-4 animate-spin" />
|
||||
<Download v-else class="w-4 h-4" />
|
||||
{{ t('contextMenu.exportDatabase') }}
|
||||
{{ t("contextMenu.exportDatabase") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'table' || node.type === 'view'">
|
||||
<ContextMenuItem @click="openData">
|
||||
<TableProperties class="w-4 h-4" /> {{ t('contextMenu.viewData') }}
|
||||
<TableProperties class="w-4 h-4" /> {{ t("contextMenu.viewData") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenStructureEditor" @click="openStructureEditor">
|
||||
<PencilRuler class="w-4 h-4" /> {{ t('contextMenu.editStructure') }}
|
||||
<PencilRuler class="w-4 h-4" /> {{ t("contextMenu.editStructure") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="newQuery">
|
||||
<TerminalSquare class="w-4 h-4" /> {{ t('contextMenu.newQuery') }}
|
||||
<TerminalSquare class="w-4 h-4" /> {{ t("contextMenu.newQuery") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram">
|
||||
<Network class="w-4 h-4" /> {{ t('diagram.open') }}
|
||||
<Network class="w-4 h-4" /> {{ t("diagram.open") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenTableImport" @click="openTableImport">
|
||||
<FileUp class="w-4 h-4" /> {{ t('contextMenu.importData') }}
|
||||
<FileUp class="w-4 h-4" /> {{ t("contextMenu.importData") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<Download class="w-4 h-4" /> {{ t('contextMenu.exportData') }}
|
||||
<Download class="w-4 h-4" /> {{ t("contextMenu.exportData") }}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem @click="exportData('csv')">CSV</ContextMenuItem>
|
||||
@@ -953,42 +1077,49 @@ const isDragging = computed(() =>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem @click="exportStructure">
|
||||
<FileCode class="w-4 h-4" /> {{ t('contextMenu.exportStructure') }}
|
||||
<FileCode class="w-4 h-4" /> {{ t("contextMenu.exportStructure") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
|
||||
<RefreshCw class="w-4 h-4" /> {{ t("contextMenu.refreshChildren") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'column'">
|
||||
<ContextMenuItem v-if="canOpenFieldLineage" @click="openFieldLineage">
|
||||
<Network class="w-4 h-4" /> {{ t('lineage.open') }}
|
||||
<Network class="w-4 h-4" /> {{ t("lineage.open") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="isGroupLabel(node)">
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
|
||||
<RefreshCw class="w-4 h-4" /> {{ t("contextMenu.refreshChildren") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<ContextMenuSeparator v-if="hasTypeMenu" />
|
||||
<ContextMenuItem @click="copyName">
|
||||
<Copy class="w-4 h-4" /> {{ t('contextMenu.copyName') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyName"> <Copy class="w-4 h-4" /> {{ t("contextMenu.copyName") }} </ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<Dialog v-model:open="showDeleteConfirm">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('contextMenu.confirmDeleteTitle') }}</DialogTitle>
|
||||
<DialogTitle>{{ t("contextMenu.confirmDeleteTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t('contextMenu.confirmDeleteMessage', { name: node.label }) }}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t("contextMenu.confirmDeleteMessage", { name: node.label }) }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDeleteConfirm = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button variant="destructive" @click="showDeleteConfirm = false; confirmDelete()">{{ t('contextMenu.deleteConnection') }}</Button>
|
||||
<Button variant="outline" @click="showDeleteConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="
|
||||
showDeleteConfirm = false;
|
||||
confirmDelete();
|
||||
"
|
||||
>{{ t("contextMenu.deleteConnection") }}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -996,7 +1127,7 @@ const isDragging = computed(() =>
|
||||
<Dialog v-model:open="showMoveToNewGroupDialog">
|
||||
<DialogContent class="sm:max-w-[360px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('connectionGroup.createGroup') }}</DialogTitle>
|
||||
<DialogTitle>{{ t("connectionGroup.createGroup") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
v-model="moveToNewGroupName"
|
||||
@@ -1004,8 +1135,10 @@ const isDragging = computed(() =>
|
||||
@keydown.enter.prevent="confirmMoveToNewGroup"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showMoveToNewGroupDialog = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button :disabled="!moveToNewGroupName.trim()" @click="confirmMoveToNewGroup">{{ t('connectionGroup.createGroup') }}</Button>
|
||||
<Button variant="outline" @click="showMoveToNewGroupDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="!moveToNewGroupName.trim()" @click="confirmMoveToNewGroup">{{
|
||||
t("connectionGroup.createGroup")
|
||||
}}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1013,12 +1146,14 @@ const isDragging = computed(() =>
|
||||
<Dialog v-model:open="showDeleteGroupConfirm">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('connectionGroup.deleteGroupConfirmTitle') }}</DialogTitle>
|
||||
<DialogTitle>{{ t("connectionGroup.deleteGroupConfirmTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t('connectionGroup.deleteGroupConfirmMessage', { name: node.label }) }}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t("connectionGroup.deleteGroupConfirmMessage", { name: node.label }) }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDeleteGroupConfirm = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button variant="destructive" @click="confirmDeleteGroup">{{ t('connectionGroup.deleteGroup') }}</Button>
|
||||
<Button variant="outline" @click="showDeleteGroupConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" @click="confirmDeleteGroup">{{ t("connectionGroup.deleteGroup") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import {
|
||||
Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -57,17 +53,20 @@ const terminalStatus = ref<SqlFileStatus | "idle">("idle");
|
||||
const terminalError = ref("");
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((c) =>
|
||||
!["redis", "mongodb", "elasticsearch"].includes(c.db_type),
|
||||
),
|
||||
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
|
||||
);
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
sqlConnections.value.find((c) => c.id === connectionId.value),
|
||||
);
|
||||
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
|
||||
|
||||
const canStart = computed(() =>
|
||||
Boolean(preview.value && selectedConnection.value && database.value.trim() && !running.value && !loadingPreview.value && !loadingDatabases.value),
|
||||
Boolean(
|
||||
preview.value &&
|
||||
selectedConnection.value &&
|
||||
database.value.trim() &&
|
||||
!running.value &&
|
||||
!loadingPreview.value &&
|
||||
!loadingDatabases.value,
|
||||
),
|
||||
);
|
||||
|
||||
const statusTone = computed(() => {
|
||||
@@ -340,14 +339,14 @@ watch(open, (value) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<FileCode class="w-4 h-4" />
|
||||
{{ t('sqlFile.title') }}
|
||||
{{ t("sqlFile.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-4 py-3">
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{{ t('sqlFile.file') }}
|
||||
{{ t("sqlFile.file") }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -357,10 +356,16 @@ watch(open, (value) => {
|
||||
class="h-8 text-xs font-mono"
|
||||
:placeholder="t('sqlFile.selectSqlFile')"
|
||||
/>
|
||||
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="running || selectingFile" @click="selectFile">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 shrink-0"
|
||||
:disabled="running || selectingFile"
|
||||
@click="selectFile"
|
||||
>
|
||||
<Loader2 v-if="selectingFile || loadingPreview" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
<FolderOpen v-else class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ t('sqlFile.browse') }}
|
||||
{{ t("sqlFile.browse") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -372,18 +377,20 @@ watch(open, (value) => {
|
||||
</div>
|
||||
<span class="text-muted-foreground shrink-0">{{ formatBytes(preview.sizeBytes) }}</span>
|
||||
</div>
|
||||
<pre class="max-h-40 overflow-auto p-3 text-xs font-mono whitespace-pre-wrap bg-muted/15">{{ preview.preview }}</pre>
|
||||
<pre class="max-h-40 overflow-auto p-3 text-xs font-mono whitespace-pre-wrap bg-muted/15">{{
|
||||
preview.preview
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{{ t('sqlFile.target') }}
|
||||
{{ t("sqlFile.target") }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('sqlFile.connection') }}</Label>
|
||||
<Label class="text-xs">{{ t("sqlFile.connection") }}</Label>
|
||||
<Select v-model="connectionId" :disabled="running">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div v-if="connectionId" class="flex items-center gap-1.5 min-w-0">
|
||||
@@ -404,7 +411,7 @@ watch(open, (value) => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('sqlFile.database') }}</Label>
|
||||
<Label class="text-xs">{{ t("sqlFile.database") }}</Label>
|
||||
<Select v-if="databaseOptions.length" v-model="database" :disabled="running || loadingDatabases">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('sqlFile.selectDatabase')" />
|
||||
@@ -431,7 +438,7 @@ watch(open, (value) => {
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{{ t('sqlFile.options') }}
|
||||
{{ t("sqlFile.options") }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -442,7 +449,7 @@ watch(open, (value) => {
|
||||
>
|
||||
<CheckSquare v-if="continueOnError" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t('sqlFile.continueOnError') }}
|
||||
{{ t("sqlFile.continueOnError") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -451,7 +458,7 @@ watch(open, (value) => {
|
||||
<div class="flex items-center gap-1.5 min-w-0" :class="statusTone">
|
||||
<component :is="statusIcon" class="w-3.5 h-3.5 shrink-0" :class="{ 'animate-spin': running }" />
|
||||
<span class="font-medium truncate">
|
||||
{{ cancelling ? t('sqlFile.cancelling') : statusLabel(terminalStatus) }}
|
||||
{{ cancelling ? t("sqlFile.cancelling") : statusLabel(terminalStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="progress" class="text-muted-foreground shrink-0">
|
||||
@@ -462,38 +469,53 @@ watch(open, (value) => {
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="terminalStatus === 'error' ? 'bg-destructive' : terminalStatus === 'cancelled' ? 'bg-yellow-500' : 'bg-primary'"
|
||||
:class="
|
||||
terminalStatus === 'error'
|
||||
? 'bg-destructive'
|
||||
: terminalStatus === 'cancelled'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-primary'
|
||||
"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<div class="border rounded-md px-2 py-1.5 min-w-0">
|
||||
<div class="text-muted-foreground truncate">{{ t('sqlFile.statement') }}</div>
|
||||
<div class="text-muted-foreground truncate">{{ t("sqlFile.statement") }}</div>
|
||||
<div class="font-medium truncate">{{ progress?.statementIndex ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="border rounded-md px-2 py-1.5 min-w-0">
|
||||
<div class="text-muted-foreground truncate">{{ t('sqlFile.succeeded') }}</div>
|
||||
<div class="font-medium text-green-600 truncate">{{ progress?.successCount ?? 0 }}</div>
|
||||
<div class="text-muted-foreground truncate">{{ t("sqlFile.succeeded") }}</div>
|
||||
<div class="font-medium text-green-600 truncate">
|
||||
{{ progress?.successCount ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border rounded-md px-2 py-1.5 min-w-0">
|
||||
<div class="text-muted-foreground truncate">{{ t('sqlFile.failed') }}</div>
|
||||
<div class="font-medium text-destructive truncate">{{ progress?.failureCount ?? 0 }}</div>
|
||||
<div class="text-muted-foreground truncate">{{ t("sqlFile.failed") }}</div>
|
||||
<div class="font-medium text-destructive truncate">
|
||||
{{ progress?.failureCount ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border rounded-md px-2 py-1.5 min-w-0">
|
||||
<div class="text-muted-foreground truncate">{{ t('sqlFile.affectedRows') }}</div>
|
||||
<div class="font-medium truncate">{{ (progress?.affectedRows ?? 0).toLocaleString() }}</div>
|
||||
<div class="text-muted-foreground truncate">{{ t("sqlFile.affectedRows") }}</div>
|
||||
<div class="font-medium truncate">
|
||||
{{ (progress?.affectedRows ?? 0).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="progress?.statementSummary" class="space-y-1">
|
||||
<Label class="text-xs">{{ t('sqlFile.currentStatement') }}</Label>
|
||||
<Label class="text-xs">{{ t("sqlFile.currentStatement") }}</Label>
|
||||
<div class="border rounded-md p-2 text-xs font-mono bg-muted/15 max-h-20 overflow-auto whitespace-pre-wrap">
|
||||
{{ progress.statementSummary }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="progress?.error || terminalError" class="border rounded-md p-2 text-xs text-destructive bg-destructive/5">
|
||||
<div
|
||||
v-if="progress?.error || terminalError"
|
||||
class="border rounded-md p-2 text-xs text-destructive bg-destructive/5"
|
||||
>
|
||||
{{ progress?.error || terminalError }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -504,16 +526,16 @@ watch(open, (value) => {
|
||||
<Button variant="destructive" size="sm" :disabled="cancelling" @click="cancelExecution">
|
||||
<Loader2 v-if="cancelling" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
<X v-else class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ cancelling ? t('sqlFile.cancelling') : t('sqlFile.cancel') }}
|
||||
{{ cancelling ? t("sqlFile.cancelling") : t("sqlFile.cancel") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="outline" size="sm" @click="open = false">
|
||||
{{ t('common.close') }}
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!canStart" @click="startExecution">
|
||||
<Play class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ t('sqlFile.execute') }}
|
||||
{{ t("sqlFile.execute") }}
|
||||
</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tabs, TabsContent, TabsList, TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertTriangle, Check, ChevronDown, Database, KeyRound, Loader2, Plus, RefreshCw, Save, TableProperties, Trash2, X,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Database,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
TableProperties,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { buildTableStructureChangeSql, type EditableStructureColumn, type EditableStructureIndex } from "@/lib/tableStructureEditorSql";
|
||||
import {
|
||||
buildTableStructureChangeSql,
|
||||
type EditableStructureColumn,
|
||||
type EditableStructureIndex,
|
||||
} from "@/lib/tableStructureEditorSql";
|
||||
import { createColumnDrafts, createIndexDrafts, toColumnNames } from "@/lib/tableStructureEditorState";
|
||||
import type { ForeignKeyInfo, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
@@ -71,9 +83,7 @@ function onIndexColResize(e: MouseEvent, col: number) {
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
const connection = computed(() =>
|
||||
props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined
|
||||
);
|
||||
const connection = computed(() => (props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined));
|
||||
const databaseType = computed(() => connection.value?.db_type);
|
||||
|
||||
const indexTypesByDb: Record<string, string[]> = {
|
||||
@@ -86,39 +96,39 @@ const indexTypesByDb: Record<string, string[]> = {
|
||||
const indexTypeOptions = computed(() => indexTypesByDb[databaseType.value ?? ""] ?? []);
|
||||
|
||||
const indexColLabels = computed(() => [
|
||||
t('structureEditor.indexName'),
|
||||
t('structureEditor.indexColumns'),
|
||||
t('structureEditor.unique'),
|
||||
t('structureEditor.indexType'),
|
||||
t('structureEditor.includedColumns'),
|
||||
t('structureEditor.filter'),
|
||||
t('structureEditor.comment'),
|
||||
t('structureEditor.actions'),
|
||||
t("structureEditor.indexName"),
|
||||
t("structureEditor.indexColumns"),
|
||||
t("structureEditor.unique"),
|
||||
t("structureEditor.indexType"),
|
||||
t("structureEditor.includedColumns"),
|
||||
t("structureEditor.filter"),
|
||||
t("structureEditor.comment"),
|
||||
t("structureEditor.actions"),
|
||||
]);
|
||||
const targetSchema = computed(() => props.prefillSchema || props.prefillDatabase || "");
|
||||
const targetLabel = computed(() => [
|
||||
connection.value?.name,
|
||||
props.prefillDatabase,
|
||||
props.prefillSchema,
|
||||
props.prefillTable,
|
||||
].filter(Boolean).join(" / "));
|
||||
const targetLabel = computed(() =>
|
||||
[connection.value?.name, props.prefillDatabase, props.prefillSchema, props.prefillTable].filter(Boolean).join(" / "),
|
||||
);
|
||||
|
||||
const changeSql = computed(() => buildTableStructureChangeSql({
|
||||
databaseType: databaseType.value,
|
||||
schema: props.prefillSchema,
|
||||
tableName: props.prefillTable || "",
|
||||
columns: columns.value,
|
||||
indexes: indexes.value,
|
||||
}));
|
||||
const changeSql = computed(() =>
|
||||
buildTableStructureChangeSql({
|
||||
databaseType: databaseType.value,
|
||||
schema: props.prefillSchema,
|
||||
tableName: props.prefillTable || "",
|
||||
columns: columns.value,
|
||||
indexes: indexes.value,
|
||||
}),
|
||||
);
|
||||
const pendingStatements = computed(() => changeSql.value.statements);
|
||||
const warnings = computed(() => changeSql.value.warnings);
|
||||
const canApply = computed(() =>
|
||||
!loading.value &&
|
||||
!saving.value &&
|
||||
pendingStatements.value.length > 0 &&
|
||||
warnings.value.length === 0 &&
|
||||
!!props.prefillConnectionId &&
|
||||
!!props.prefillTable
|
||||
const canApply = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
!saving.value &&
|
||||
pendingStatements.value.length > 0 &&
|
||||
warnings.value.length === 0 &&
|
||||
!!props.prefillConnectionId &&
|
||||
!!props.prefillTable,
|
||||
);
|
||||
|
||||
function resetState() {
|
||||
@@ -193,7 +203,10 @@ function addIndex() {
|
||||
}
|
||||
|
||||
const availableColumnNames = computed(() =>
|
||||
columns.value.filter((c) => !c.markedForDrop).map((c) => c.name).filter(Boolean)
|
||||
columns.value
|
||||
.filter((c) => !c.markedForDrop)
|
||||
.map((c) => c.name)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const colSearch = ref("");
|
||||
@@ -254,24 +267,24 @@ watch(open, (value) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<TableProperties class="h-4 w-4" />
|
||||
{{ t('structureEditor.title') }}
|
||||
{{ t("structureEditor.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-3 py-2">
|
||||
<div class="flex items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs">
|
||||
<Database class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ targetLabel || t('editor.noDatabase') }}</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ targetLabel || t("editor.noDatabase") }}</span>
|
||||
<Badge variant="outline">{{ connection?.driver_label || databaseType }}</Badge>
|
||||
<Button variant="ghost" size="sm" class="h-7 gap-1" :disabled="loading || saving" @click="loadStructure">
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.refresh') }}
|
||||
{{ t("structureEditor.refresh") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex h-[420px] items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t('common.loading') }}
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid min-h-[520px] grid-cols-[minmax(0,1fr)_360px] gap-3">
|
||||
@@ -279,18 +292,18 @@ watch(open, (value) => {
|
||||
<Tabs v-model="activeTab" class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b px-3 py-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="columns">{{ t('structureEditor.columns') }}</TabsTrigger>
|
||||
<TabsTrigger value="indexes">{{ t('structureEditor.indexes') }}</TabsTrigger>
|
||||
<TabsTrigger value="foreignKeys">{{ t('structureEditor.foreignKeys') }}</TabsTrigger>
|
||||
<TabsTrigger value="triggers">{{ t('structureEditor.triggers') }}</TabsTrigger>
|
||||
<TabsTrigger value="columns">{{ t("structureEditor.columns") }}</TabsTrigger>
|
||||
<TabsTrigger value="indexes">{{ t("structureEditor.indexes") }}</TabsTrigger>
|
||||
<TabsTrigger value="foreignKeys">{{ t("structureEditor.foreignKeys") }}</TabsTrigger>
|
||||
<TabsTrigger value="triggers">{{ t("structureEditor.triggers") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button v-if="activeTab === 'columns'" size="sm" class="h-7 gap-1" @click="addColumn">
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.addColumn') }}
|
||||
{{ t("structureEditor.addColumn") }}
|
||||
</Button>
|
||||
<Button v-if="activeTab === 'indexes'" size="sm" class="h-7 gap-1" @click="addIndex">
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.addIndex') }}
|
||||
{{ t("structureEditor.addIndex") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -299,12 +312,24 @@ watch(open, (value) => {
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr>
|
||||
<th class="w-8 border-b border-r px-2 py-2 text-left">#</th>
|
||||
<th class="min-w-36 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.columnName') }}</th>
|
||||
<th class="min-w-40 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.dataType') }}</th>
|
||||
<th class="w-20 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.nullable') }}</th>
|
||||
<th class="min-w-32 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.defaultValue') }}</th>
|
||||
<th class="min-w-36 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.comment') }}</th>
|
||||
<th class="w-24 border-b px-2 py-2 text-left">{{ t('structureEditor.actions') }}</th>
|
||||
<th class="min-w-36 border-b border-r px-2 py-2 text-left">
|
||||
{{ t("structureEditor.columnName") }}
|
||||
</th>
|
||||
<th class="min-w-40 border-b border-r px-2 py-2 text-left">
|
||||
{{ t("structureEditor.dataType") }}
|
||||
</th>
|
||||
<th class="w-20 border-b border-r px-2 py-2 text-left">
|
||||
{{ t("structureEditor.nullable") }}
|
||||
</th>
|
||||
<th class="min-w-32 border-b border-r px-2 py-2 text-left">
|
||||
{{ t("structureEditor.defaultValue") }}
|
||||
</th>
|
||||
<th class="min-w-36 border-b border-r px-2 py-2 text-left">
|
||||
{{ t("structureEditor.comment") }}
|
||||
</th>
|
||||
<th class="w-24 border-b px-2 py-2 text-left">
|
||||
{{ t("structureEditor.actions") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -323,16 +348,29 @@ watch(open, (value) => {
|
||||
<Input v-model="column.name" class="h-7 min-w-32 text-xs" :disabled="column.markedForDrop" />
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input v-model="column.dataType" class="h-7 min-w-36 font-mono text-xs" :disabled="column.markedForDrop" />
|
||||
<Input
|
||||
v-model="column.dataType"
|
||||
class="h-7 min-w-36 font-mono text-xs"
|
||||
:disabled="column.markedForDrop"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input v-model="column.isNullable" type="checkbox" class="h-3.5 w-3.5" :disabled="column.markedForDrop || column.isPrimaryKey" />
|
||||
<span>{{ column.isNullable ? t('structureEditor.yes') : t('structureEditor.no') }}</span>
|
||||
<input
|
||||
v-model="column.isNullable"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5"
|
||||
:disabled="column.markedForDrop || column.isPrimaryKey"
|
||||
/>
|
||||
<span>{{ column.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input v-model="column.defaultValue" class="h-7 min-w-28 font-mono text-xs" :disabled="column.markedForDrop" />
|
||||
<Input
|
||||
v-model="column.defaultValue"
|
||||
class="h-7 min-w-28 font-mono text-xs"
|
||||
:disabled="column.markedForDrop"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input v-model="column.comment" class="h-7 min-w-32 text-xs" :disabled="column.markedForDrop" />
|
||||
@@ -347,11 +385,11 @@ watch(open, (value) => {
|
||||
@click="toggleDropColumn(column)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
{{ column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop') }}
|
||||
{{ column.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
|
||||
</Button>
|
||||
<Button v-else variant="ghost" size="sm" class="h-7 gap-1" @click="removeNewColumn(column)">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.remove') }}
|
||||
{{ t("structureEditor.remove") }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -367,7 +405,10 @@ watch(open, (value) => {
|
||||
v-for="(label, i) in indexColLabels"
|
||||
:key="i"
|
||||
class="relative border-b border-r px-2 py-2 text-left"
|
||||
:style="{ width: indexColWidths[i] + 'px', minWidth: indexColWidths[i] + 'px' }"
|
||||
:style="{
|
||||
width: indexColWidths[i] + 'px',
|
||||
minWidth: indexColWidths[i] + 'px',
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
<div
|
||||
@@ -380,21 +421,42 @@ watch(open, (value) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="index in indexes" :key="index.id" :class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''">
|
||||
<tr
|
||||
v-for="index in indexes"
|
||||
:key="index.id"
|
||||
:class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''"
|
||||
>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input v-model="index.name" class="h-7 text-xs" :disabled="!!index.original || index.markedForDrop" />
|
||||
<Input
|
||||
v-model="index.name"
|
||||
class="h-7 text-xs"
|
||||
:disabled="!!index.original || index.markedForDrop"
|
||||
/>
|
||||
</td>
|
||||
<td class="overflow-hidden border-b border-r px-2 py-1.5">
|
||||
<DropdownMenu v-if="!index.original && !index.markedForDrop">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" class="h-7 w-full justify-between font-mono text-xs">
|
||||
<span class="truncate">{{ toColumnNames(index.columns) || t('structureEditor.indexColumnsPlaceholder') }}</span>
|
||||
<span class="truncate">{{
|
||||
toColumnNames(index.columns) || t("structureEditor.indexColumnsPlaceholder")
|
||||
}}</span>
|
||||
<ChevronDown class="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="max-h-60 min-w-48 overflow-y-auto" side="bottom" :side-offset="2" :avoid-collisions="false" @interactOutside="colSearch = ''">
|
||||
<DropdownMenuContent
|
||||
class="max-h-60 min-w-48 overflow-y-auto"
|
||||
side="bottom"
|
||||
:side-offset="2"
|
||||
:avoid-collisions="false"
|
||||
@interactOutside="colSearch = ''"
|
||||
>
|
||||
<div class="px-1.5 pb-1 pt-0.5">
|
||||
<Input v-model="colSearch" class="h-6 text-xs" :placeholder="t('grid.search')" @click.stop />
|
||||
<Input
|
||||
v-model="colSearch"
|
||||
class="h-6 text-xs"
|
||||
:placeholder="t('grid.search')"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="col in filteredColumnNames"
|
||||
@@ -408,21 +470,30 @@ watch(open, (value) => {
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span v-else class="font-mono text-xs text-muted-foreground">{{ toColumnNames(index.columns) }}</span>
|
||||
<span v-else class="font-mono text-xs text-muted-foreground">{{
|
||||
toColumnNames(index.columns)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input v-model="index.isUnique" type="checkbox" class="h-3.5 w-3.5" :disabled="!!index.original || index.markedForDrop" />
|
||||
<span>{{ index.isUnique ? t('structureEditor.yes') : t('structureEditor.no') }}</span>
|
||||
<input
|
||||
v-model="index.isUnique"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5"
|
||||
:disabled="!!index.original || index.markedForDrop"
|
||||
/>
|
||||
<span>{{ index.isUnique ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<span v-if="index.original" class="text-muted-foreground">{{ index.indexType || 'BTREE' }}</span>
|
||||
<span v-if="index.original" class="text-muted-foreground">{{
|
||||
index.indexType || "BTREE"
|
||||
}}</span>
|
||||
<Select
|
||||
v-else-if="indexTypeOptions.length > 0"
|
||||
:model-value="index.indexType || 'BTREE'"
|
||||
:disabled="index.markedForDrop"
|
||||
@update:model-value="(v: any) => index.indexType = String(v ?? '')"
|
||||
@update:model-value="(v: any) => (index.indexType = String(v ?? ''))"
|
||||
>
|
||||
<SelectTrigger class="h-7 font-mono text-xs">
|
||||
<SelectValue />
|
||||
@@ -443,13 +514,26 @@ watch(open, (value) => {
|
||||
<DropdownMenu v-if="!index.original && !index.markedForDrop">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" class="h-7 w-full justify-between font-mono text-xs">
|
||||
<span class="truncate">{{ index.includedColumns.join(', ') || t('structureEditor.includedColumnsPlaceholder') }}</span>
|
||||
<span class="truncate">{{
|
||||
index.includedColumns.join(", ") || t("structureEditor.includedColumnsPlaceholder")
|
||||
}}</span>
|
||||
<ChevronDown class="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="max-h-60 min-w-48 overflow-y-auto" side="bottom" :side-offset="2" :avoid-collisions="false" @interactOutside="colSearch = ''">
|
||||
<DropdownMenuContent
|
||||
class="max-h-60 min-w-48 overflow-y-auto"
|
||||
side="bottom"
|
||||
:side-offset="2"
|
||||
:avoid-collisions="false"
|
||||
@interactOutside="colSearch = ''"
|
||||
>
|
||||
<div class="px-1.5 pb-1 pt-0.5">
|
||||
<Input v-model="colSearch" class="h-6 text-xs" :placeholder="t('grid.search')" @click.stop />
|
||||
<Input
|
||||
v-model="colSearch"
|
||||
class="h-6 text-xs"
|
||||
:placeholder="t('grid.search')"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="col in filteredColumnNames"
|
||||
@@ -463,7 +547,7 @@ watch(open, (value) => {
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span v-else class="text-muted-foreground text-xs">{{ index.includedColumns.join(', ') }}</span>
|
||||
<span v-else class="text-muted-foreground text-xs">{{ index.includedColumns.join(", ") }}</span>
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input
|
||||
@@ -475,15 +559,10 @@ watch(open, (value) => {
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<span v-if="index.original" class="text-muted-foreground text-xs">{{ index.comment }}</span>
|
||||
<Input
|
||||
v-else
|
||||
v-model="index.comment"
|
||||
class="h-7 text-xs"
|
||||
:disabled="index.markedForDrop"
|
||||
/>
|
||||
<Input v-else v-model="index.comment" class="h-7 text-xs" :disabled="index.markedForDrop" />
|
||||
</td>
|
||||
<td class="border-b px-2 py-1.5">
|
||||
<Badge v-if="index.isPrimary" variant="outline">{{ t('structureEditor.primary') }}</Badge>
|
||||
<Badge v-if="index.isPrimary" variant="outline">{{ t("structureEditor.primary") }}</Badge>
|
||||
<Button
|
||||
v-else-if="index.original"
|
||||
variant="ghost"
|
||||
@@ -492,11 +571,11 @@ watch(open, (value) => {
|
||||
@click="toggleDropIndex(index)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
{{ index.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop') }}
|
||||
{{ index.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
|
||||
</Button>
|
||||
<Button v-else variant="ghost" size="sm" class="h-7 gap-1" @click="removeNewIndex(index)">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.remove') }}
|
||||
{{ t("structureEditor.remove") }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -505,17 +584,23 @@ watch(open, (value) => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="foreignKeys" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<div v-if="foreignKeys.length === 0" class="py-10 text-center text-sm text-muted-foreground">{{ t('structureEditor.emptyReadonly') }}</div>
|
||||
<div v-if="foreignKeys.length === 0" class="py-10 text-center text-sm text-muted-foreground">
|
||||
{{ t("structureEditor.emptyReadonly") }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="fk in foreignKeys" :key="fk.name" class="rounded-md border px-3 py-2 text-xs">
|
||||
<div class="font-medium">{{ fk.name }}</div>
|
||||
<div class="mt-1 font-mono text-muted-foreground">{{ fk.column }} -> {{ fk.ref_table }}.{{ fk.ref_column }}</div>
|
||||
<div class="mt-1 font-mono text-muted-foreground">
|
||||
{{ fk.column }} -> {{ fk.ref_table }}.{{ fk.ref_column }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="triggers" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<div v-if="triggers.length === 0" class="py-10 text-center text-sm text-muted-foreground">{{ t('structureEditor.emptyReadonly') }}</div>
|
||||
<div v-if="triggers.length === 0" class="py-10 text-center text-sm text-muted-foreground">
|
||||
{{ t("structureEditor.emptyReadonly") }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="trigger in triggers" :key="trigger.name" class="rounded-md border px-3 py-2 text-xs">
|
||||
<div class="font-medium">{{ trigger.name }}</div>
|
||||
@@ -528,23 +613,36 @@ watch(open, (value) => {
|
||||
|
||||
<div class="flex min-w-0 flex-col rounded-md border">
|
||||
<div class="flex items-center justify-between border-b px-3 py-2 text-xs font-medium">
|
||||
<span>{{ t('structureEditor.sqlPreview') }}</span>
|
||||
<span>{{ t("structureEditor.sqlPreview") }}</span>
|
||||
<Badge variant="secondary">{{ pendingStatements.length }}</Badge>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-3">
|
||||
<div v-if="warnings.length" class="mb-3 space-y-1">
|
||||
<div v-for="warning in warnings" :key="warning" class="flex gap-2 rounded-md border border-yellow-300/40 bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
<div
|
||||
v-for="warning in warnings"
|
||||
:key="warning"
|
||||
class="flex gap-2 rounded-md border border-yellow-300/40 bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-700 dark:text-yellow-300"
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{{ warning }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre v-if="pendingStatements.length" class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-3 font-mono text-xs leading-5">{{ pendingStatements.join('\n') }}</pre>
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t('structureEditor.noChanges') }}</div>
|
||||
<pre
|
||||
v-if="pendingStatements.length"
|
||||
class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-3 font-mono text-xs leading-5"
|
||||
>{{ pendingStatements.join("\n") }}</pre
|
||||
>
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("structureEditor.noChanges") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -552,16 +650,16 @@ watch(open, (value) => {
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="saving" @click="open = false">
|
||||
<X class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('dangerDialog.cancel') }}
|
||||
{{ t("dangerDialog.cancel") }}
|
||||
</Button>
|
||||
<Button :disabled="!canApply" @click="applyChanges">
|
||||
<Loader2 v-if="saving" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Save v-else class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.apply') }}
|
||||
{{ t("structureEditor.apply") }}
|
||||
</Button>
|
||||
<Badge v-if="!saving && pendingStatements.length && warnings.length === 0" variant="outline" class="h-8">
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.ready') }}
|
||||
{{ t("structureEditor.ready") }}
|
||||
</Badge>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Dialog, DialogHeader, DialogTitle,
|
||||
DialogFooter, DialogScrollContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogScrollContent } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { TransferProgress } from "@/lib/api";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { nextTransferTerminalState } from "@/lib/transferProgressState";
|
||||
import {
|
||||
ArrowRightLeft, Check, X, Loader2, Square, CheckSquare,
|
||||
} from "lucide-vue-next";
|
||||
import { ArrowRightLeft, Check, X, Loader2, Square, CheckSquare } from "lucide-vue-next";
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
@@ -33,9 +26,7 @@ const store = useConnectionStore();
|
||||
|
||||
const SQL_TYPES: DatabaseType[] = ["mysql", "postgres", "sqlite", "sqlserver", "oracle", "clickhouse", "duckdb"];
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((c) => SQL_TYPES.includes(c.db_type))
|
||||
);
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => SQL_TYPES.includes(c.db_type)));
|
||||
|
||||
// Source state
|
||||
const sourceConnectionId = ref("");
|
||||
@@ -72,17 +63,18 @@ const filteredTables = computed(() => {
|
||||
return q ? sourceTables.value.filter((t) => t.toLowerCase().includes(q)) : sourceTables.value;
|
||||
});
|
||||
|
||||
const allSelected = computed(() =>
|
||||
filteredTables.value.length > 0 && filteredTables.value.every((t) => selectedTables.value.has(t))
|
||||
const allSelected = computed(
|
||||
() => filteredTables.value.length > 0 && filteredTables.value.every((t) => selectedTables.value.has(t)),
|
||||
);
|
||||
|
||||
const canStart = computed(() =>
|
||||
sourceConnectionId.value &&
|
||||
sourceDatabase.value &&
|
||||
targetConnectionId.value &&
|
||||
targetDatabase.value &&
|
||||
selectedTables.value.size > 0 &&
|
||||
sourceConnectionId.value + sourceDatabase.value !== targetConnectionId.value + targetDatabase.value
|
||||
const canStart = computed(
|
||||
() =>
|
||||
sourceConnectionId.value &&
|
||||
sourceDatabase.value &&
|
||||
targetConnectionId.value &&
|
||||
targetDatabase.value &&
|
||||
selectedTables.value.size > 0 &&
|
||||
sourceConnectionId.value + sourceDatabase.value !== targetConnectionId.value + targetDatabase.value,
|
||||
);
|
||||
|
||||
function toggleSelectAll() {
|
||||
@@ -128,7 +120,8 @@ async function loadTables() {
|
||||
loadingTables.value = true;
|
||||
try {
|
||||
const config = store.getConfig(sourceConnectionId.value);
|
||||
const needsSchema = config?.db_type === "postgres" || config?.db_type === "sqlserver" || config?.db_type === "oracle";
|
||||
const needsSchema =
|
||||
config?.db_type === "postgres" || config?.db_type === "sqlserver" || config?.db_type === "oracle";
|
||||
if (needsSchema) {
|
||||
const schemas = await api.listSchemas(sourceConnectionId.value, sourceDatabase.value);
|
||||
sourceSchema.value = schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
||||
@@ -136,7 +129,9 @@ async function loadTables() {
|
||||
sourceSchema.value = "";
|
||||
}
|
||||
const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value);
|
||||
sourceTables.value = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name);
|
||||
sourceTables.value = tables
|
||||
.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE")
|
||||
.map((t) => t.name);
|
||||
selectedTables.value = new Set(sourceTables.value);
|
||||
} catch {
|
||||
sourceTables.value = [];
|
||||
@@ -148,7 +143,10 @@ async function loadTables() {
|
||||
const skipSourceWatch = ref(false);
|
||||
|
||||
watch(sourceConnectionId, (id) => {
|
||||
if (skipSourceWatch.value) { skipSourceWatch.value = false; return; }
|
||||
if (skipSourceWatch.value) {
|
||||
skipSourceWatch.value = false;
|
||||
return;
|
||||
}
|
||||
sourceDatabase.value = "";
|
||||
sourceTables.value = [];
|
||||
selectedTables.value.clear();
|
||||
@@ -210,12 +208,15 @@ async function startTransfer() {
|
||||
|
||||
// Auto-detect target schema
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
const targetNeedsSchema = targetConfig?.db_type === "postgres" || targetConfig?.db_type === "sqlserver" || targetConfig?.db_type === "oracle";
|
||||
const targetNeedsSchema =
|
||||
targetConfig?.db_type === "postgres" || targetConfig?.db_type === "sqlserver" || targetConfig?.db_type === "oracle";
|
||||
if (targetNeedsSchema && !targetSchema.value) {
|
||||
try {
|
||||
const schemas = await api.listSchemas(targetConnectionId.value, targetDatabase.value);
|
||||
targetSchema.value = schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
||||
} catch { /* use empty */ }
|
||||
} catch {
|
||||
/* use empty */
|
||||
}
|
||||
}
|
||||
|
||||
const request: api.TransferRequest = {
|
||||
@@ -238,11 +239,14 @@ async function startTransfer() {
|
||||
transferProgress.value = new Map(transferProgress.value);
|
||||
currentTable.value = progress.table;
|
||||
|
||||
const nextState = nextTransferTerminalState({
|
||||
done: overallDone.value,
|
||||
cancelled: overallCancelled.value,
|
||||
error: overallError.value,
|
||||
}, progress);
|
||||
const nextState = nextTransferTerminalState(
|
||||
{
|
||||
done: overallDone.value,
|
||||
cancelled: overallCancelled.value,
|
||||
error: overallError.value,
|
||||
},
|
||||
progress,
|
||||
);
|
||||
overallDone.value = nextState.done;
|
||||
overallCancelled.value = nextState.cancelled;
|
||||
overallError.value = nextState.error;
|
||||
@@ -266,12 +270,12 @@ function getConnectionType(id: string): DatabaseType {
|
||||
return store.connections.find((c) => c.id === id)?.db_type ?? "mysql";
|
||||
}
|
||||
|
||||
const completedTables = computed(() =>
|
||||
[...transferProgress.value.values()].filter((p) => p.status === "tableDone" || p.status === "done").length
|
||||
const completedTables = computed(
|
||||
() => [...transferProgress.value.values()].filter((p) => p.status === "tableDone" || p.status === "done").length,
|
||||
);
|
||||
|
||||
const totalTransferred = computed(() =>
|
||||
[...transferProgress.value.values()].reduce((sum, p) => sum + p.rowsTransferred, 0)
|
||||
[...transferProgress.value.values()].reduce((sum, p) => sum + p.rowsTransferred, 0),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -281,7 +285,7 @@ const totalTransferred = computed(() =>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<ArrowRightLeft class="w-4 h-4" />
|
||||
{{ t('transfer.title') }}
|
||||
{{ t("transfer.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -289,11 +293,13 @@ const totalTransferred = computed(() =>
|
||||
<div v-if="!isTransferring" class="grid gap-4 py-3">
|
||||
<!-- Source Section -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">{{ t('transfer.source') }}</div>
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{{ t("transfer.source") }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('transfer.sourceConnection') }}</Label>
|
||||
<Label class="text-xs">{{ t("transfer.sourceConnection") }}</Label>
|
||||
<Select v-model="sourceConnectionId">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div v-if="sourceConnectionId" class="flex items-center gap-1.5">
|
||||
@@ -314,7 +320,7 @@ const totalTransferred = computed(() =>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('transfer.sourceDatabase') }}</Label>
|
||||
<Label class="text-xs">{{ t("transfer.sourceDatabase") }}</Label>
|
||||
<Select v-model="sourceDatabase" :disabled="!sourceDatabases.length">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
@@ -329,11 +335,13 @@ const totalTransferred = computed(() =>
|
||||
|
||||
<!-- Target Section -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">{{ t('transfer.target') }}</div>
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{{ t("transfer.target") }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('transfer.targetConnection') }}</Label>
|
||||
<Label class="text-xs">{{ t("transfer.targetConnection") }}</Label>
|
||||
<Select v-model="targetConnectionId">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div v-if="targetConnectionId" class="flex items-center gap-1.5">
|
||||
@@ -354,7 +362,7 @@ const totalTransferred = computed(() =>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('transfer.targetDatabase') }}</Label>
|
||||
<Label class="text-xs">{{ t("transfer.targetDatabase") }}</Label>
|
||||
<Select v-model="targetDatabase" :disabled="!targetDatabases.length">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
@@ -371,11 +379,19 @@ const totalTransferred = computed(() =>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{{ t('transfer.tables') }}
|
||||
<span v-if="sourceTables.length" class="text-muted-foreground/60">({{ selectedTables.size }}/{{ sourceTables.length }})</span>
|
||||
{{ t("transfer.tables") }}
|
||||
<span v-if="sourceTables.length" class="text-muted-foreground/60"
|
||||
>({{ selectedTables.size }}/{{ sourceTables.length }})</span
|
||||
>
|
||||
</div>
|
||||
<Button v-if="sourceTables.length" variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleSelectAll">
|
||||
{{ allSelected ? t('transfer.deselectAll') : t('transfer.selectAll') }}
|
||||
<Button
|
||||
v-if="sourceTables.length"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 text-xs px-2"
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
{{ allSelected ? t("transfer.deselectAll") : t("transfer.selectAll") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -388,13 +404,16 @@ const totalTransferred = computed(() =>
|
||||
|
||||
<div v-if="loadingTables" class="flex items-center gap-2 text-xs text-muted-foreground py-4 justify-center">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
{{ t('common.loading') }}
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="!sourceConnectionId || !sourceDatabase" class="text-xs text-muted-foreground py-4 text-center">
|
||||
{{ t('transfer.selectSourceFirst') }}
|
||||
<div
|
||||
v-else-if="!sourceConnectionId || !sourceDatabase"
|
||||
class="text-xs text-muted-foreground py-4 text-center"
|
||||
>
|
||||
{{ t("transfer.selectSourceFirst") }}
|
||||
</div>
|
||||
<div v-else-if="sourceTables.length === 0" class="text-xs text-muted-foreground py-4 text-center">
|
||||
{{ t('transfer.noTables') }}
|
||||
{{ t("transfer.noTables") }}
|
||||
</div>
|
||||
<div v-else class="border rounded-md max-h-[200px] overflow-y-auto">
|
||||
<div
|
||||
@@ -412,24 +431,18 @@ const totalTransferred = computed(() =>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-2.5">
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer text-xs"
|
||||
@click="createTable = !createTable"
|
||||
>
|
||||
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="createTable = !createTable">
|
||||
<CheckSquare v-if="createTable" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t('transfer.createTable') }}
|
||||
{{ t("transfer.createTable") }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer text-xs"
|
||||
@click="truncateBefore = !truncateBefore"
|
||||
>
|
||||
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="truncateBefore = !truncateBefore">
|
||||
<CheckSquare v-if="truncateBefore" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t('transfer.truncateBefore') }}
|
||||
{{ t("transfer.truncateBefore") }}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Label class="text-xs shrink-0">{{ t('transfer.batchSize') }}</Label>
|
||||
<Label class="text-xs shrink-0">{{ t("transfer.batchSize") }}</Label>
|
||||
<Input v-model.number="batchSize" type="number" min="100" max="10000" step="100" class="h-7 text-xs w-24" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -439,19 +452,22 @@ const totalTransferred = computed(() =>
|
||||
<div v-else class="py-3 space-y-3">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{{ t('transfer.overallProgress') }}: {{ completedTables }} / {{ selectedTables.size }} {{ t('transfer.tables').toLowerCase() }}
|
||||
· {{ totalTransferred.toLocaleString() }} {{ t('grid.rows', { count: '' }).trim() }}
|
||||
{{ t("transfer.overallProgress") }}: {{ completedTables }} / {{ selectedTables.size }}
|
||||
{{ t("transfer.tables").toLowerCase() }} · {{ totalTransferred.toLocaleString() }}
|
||||
{{ t("grid.rows", { count: "" }).trim() }}
|
||||
</span>
|
||||
<span v-if="overallDone" class="text-green-600 font-medium">{{ t('transfer.completed') }}</span>
|
||||
<span v-else-if="overallCancelled" class="text-yellow-600 font-medium">{{ t('transfer.cancelled') }}</span>
|
||||
<span v-else-if="overallError" class="text-destructive font-medium">{{ t('transfer.failed') }}</span>
|
||||
<span v-if="overallDone" class="text-green-600 font-medium">{{ t("transfer.completed") }}</span>
|
||||
<span v-else-if="overallCancelled" class="text-yellow-600 font-medium">{{ t("transfer.cancelled") }}</span>
|
||||
<span v-else-if="overallError" class="text-destructive font-medium">{{ t("transfer.failed") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="overallError ? 'bg-destructive' : overallCancelled ? 'bg-yellow-500' : 'bg-primary'"
|
||||
:style="{ width: `${selectedTables.size ? (completedTables / selectedTables.size) * 100 : 0}%` }"
|
||||
:style="{
|
||||
width: `${selectedTables.size ? (completedTables / selectedTables.size) * 100 : 0}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -468,19 +484,27 @@ const totalTransferred = computed(() =>
|
||||
<Loader2 class="w-3 h-3 animate-spin text-primary" />
|
||||
<span>{{ transferProgress.get(table)!.rowsTransferred.toLocaleString() }}</span>
|
||||
</template>
|
||||
<template v-else-if="transferProgress.get(table)!.status === 'tableDone' || transferProgress.get(table)!.status === 'done'">
|
||||
<template
|
||||
v-else-if="
|
||||
transferProgress.get(table)!.status === 'tableDone' ||
|
||||
transferProgress.get(table)!.status === 'done'
|
||||
"
|
||||
>
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
<span>{{ transferProgress.get(table)!.rowsTransferred.toLocaleString() }}</span>
|
||||
</template>
|
||||
<template v-else-if="transferProgress.get(table)!.status === 'error'">
|
||||
<X class="w-3 h-3 text-destructive" />
|
||||
<span class="text-destructive truncate max-w-[160px]" :title="transferProgress.get(table)!.error ?? ''">
|
||||
<span
|
||||
class="text-destructive truncate max-w-[160px]"
|
||||
:title="transferProgress.get(table)!.error ?? ''"
|
||||
>
|
||||
{{ transferProgress.get(table)!.error }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="transferProgress.get(table)!.status === 'cancelled'">
|
||||
<X class="w-3 h-3 text-yellow-500" />
|
||||
<span>{{ t('transfer.cancelled') }}</span>
|
||||
<span>{{ t("transfer.cancelled") }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground/40">—</span>
|
||||
@@ -494,21 +518,21 @@ const totalTransferred = computed(() =>
|
||||
<DialogFooter>
|
||||
<template v-if="!isTransferring">
|
||||
<Button variant="outline" size="sm" @click="open = false">
|
||||
{{ t('transfer.cancel') }}
|
||||
{{ t("transfer.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!canStart" @click="startTransfer">
|
||||
<ArrowRightLeft class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ t('transfer.start') }}
|
||||
{{ t("transfer.start") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="overallDone || overallCancelled || overallError">
|
||||
<Button size="sm" @click="open = false">
|
||||
{{ t('common.close') }}
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="destructive" size="sm" @click="cancelTransfer">
|
||||
{{ t('transfer.cancel') }}
|
||||
{{ t("transfer.cancel") }}
|
||||
</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { BadgeVariants } from '.'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { badgeVariants } from '.'
|
||||
import type { PrimitiveProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import type { BadgeVariants } from ".";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Primitive } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { badgeVariants } from ".";
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
const props = defineProps<
|
||||
PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"];
|
||||
class?: HTMLAttributes["class"];
|
||||
}
|
||||
>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as Badge } from './Badge.vue'
|
||||
export { default as Badge } from "./Badge.vue";
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
|
||||
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
);
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '.'
|
||||
import type { PrimitiveProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import type { ButtonVariants } from ".";
|
||||
import { Primitive } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from ".";
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: ButtonVariants["variant"];
|
||||
size?: ButtonVariants["size"];
|
||||
class?: HTMLAttributes["class"];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
})
|
||||
as: "button",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as Button } from './Button.vue'
|
||||
export { default as Button } from "./Button.vue";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
|
||||
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
|
||||
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'icon': 'size-8',
|
||||
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3',
|
||||
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3",
|
||||
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
);
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
|
||||
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { ContextMenuRootEmits, ContextMenuRootProps } from "reka-ui";
|
||||
import { ContextMenuRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<ContextMenuRootProps>()
|
||||
const emits = defineEmits<ContextMenuRootEmits>()
|
||||
const props = defineProps<ContextMenuRootProps>();
|
||||
const emits = defineEmits<ContextMenuRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRoot
|
||||
data-slot="context-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<ContextMenuRoot data-slot="context-menu" v-bind="forwarded">
|
||||
<slot />
|
||||
</ContextMenuRoot>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'reka-ui'
|
||||
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { CheckIcon } from "lucide-vue-next";
|
||||
import { ContextMenuCheckboxItem, ContextMenuItemIndicator, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
|
||||
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>();
|
||||
const emits = defineEmits<ContextMenuCheckboxItemEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuCheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
<ContextMenuItemIndicator>
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ContextMenuContentEmits, ContextMenuContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ContextMenuContent, ContextMenuPortal, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ContextMenuContentEmits>()
|
||||
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes["class"] }>();
|
||||
const emits = defineEmits<ContextMenuContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,10 +22,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<ContextMenuContent
|
||||
data-slot="context-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 text-popover-foreground min-w-36 rounded-xl p-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-context-menu-content-available-height) origin-(--reka-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
|
||||
props.class,
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 text-popover-foreground min-w-36 rounded-xl p-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-context-menu-content-available-height) origin-(--reka-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuContent>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuGroupProps } from 'reka-ui'
|
||||
import { ContextMenuGroup } from 'reka-ui'
|
||||
import type { ContextMenuGroupProps } from "reka-ui";
|
||||
import { ContextMenuGroup } from "reka-ui";
|
||||
|
||||
const props = defineProps<ContextMenuGroupProps>()
|
||||
const props = defineProps<ContextMenuGroupProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuGroup
|
||||
data-slot="context-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<ContextMenuGroup data-slot="context-menu-group" v-bind="props">
|
||||
<slot />
|
||||
</ContextMenuGroup>
|
||||
</template>
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuItem,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ContextMenuItemEmits, ContextMenuItemProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ContextMenuItem, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(defineProps<ContextMenuItemProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
})
|
||||
const emits = defineEmits<ContextMenuItemEmits>()
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
ContextMenuItemProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(),
|
||||
{
|
||||
variant: "default",
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<ContextMenuItemEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,10 +30,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-2 rounded-md px-2 py-1 text-[13px] data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-2 rounded-md px-2 py-1 text-[13px] data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ContextMenuLabel } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ContextMenuLabelProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ContextMenuLabel } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes["class"]; inset?: boolean }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuPortalProps } from 'reka-ui'
|
||||
import { ContextMenuPortal } from 'reka-ui'
|
||||
import type { ContextMenuPortalProps } from "reka-ui";
|
||||
import { ContextMenuPortal } from "reka-ui";
|
||||
|
||||
const props = defineProps<ContextMenuPortalProps>()
|
||||
const props = defineProps<ContextMenuPortalProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuPortal
|
||||
data-slot="context-menu-portal"
|
||||
v-bind="props"
|
||||
>
|
||||
<ContextMenuPortal data-slot="context-menu-portal" v-bind="props">
|
||||
<slot />
|
||||
</ContextMenuPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'reka-ui'
|
||||
import {
|
||||
ContextMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from "reka-ui";
|
||||
import { ContextMenuRadioGroup, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<ContextMenuRadioGroupProps>()
|
||||
const emits = defineEmits<ContextMenuRadioGroupEmits>()
|
||||
const props = defineProps<ContextMenuRadioGroupProps>();
|
||||
const emits = defineEmits<ContextMenuRadioGroupEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<ContextMenuRadioGroup data-slot="context-menu-radio-group" v-bind="forwarded">
|
||||
<slot />
|
||||
</ContextMenuRadioGroup>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'reka-ui'
|
||||
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ContextMenuItemIndicator,
|
||||
ContextMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { CheckIcon } from "lucide-vue-next";
|
||||
import { ContextMenuItemIndicator, ContextMenuRadioItem, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ContextMenuRadioItemEmits>()
|
||||
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes["class"] }>();
|
||||
const emits = defineEmits<ContextMenuRadioItemEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
<ContextMenuItemIndicator>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuSeparator,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ContextMenuSeparatorProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ContextMenuSeparator } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
:class="cn('text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuSubEmits, ContextMenuSubProps } from 'reka-ui'
|
||||
import {
|
||||
ContextMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import type { ContextMenuSubEmits, ContextMenuSubProps } from "reka-ui";
|
||||
import { ContextMenuSub, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<ContextMenuSubProps>()
|
||||
const emits = defineEmits<ContextMenuSubEmits>()
|
||||
const props = defineProps<ContextMenuSubProps>();
|
||||
const emits = defineEmits<ContextMenuSubEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSub
|
||||
data-slot="context-menu-sub"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<ContextMenuSub data-slot="context-menu-sub" v-bind="forwarded">
|
||||
<slot />
|
||||
</ContextMenuSub>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuPortal,
|
||||
ContextMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ContextMenuPortal, ContextMenuSubContent, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>();
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuSubTriggerProps } from 'reka-ui'
|
||||
import type { ContextMenuSubTriggerProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRightIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ContextMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronRightIcon } from "lucide-vue-next";
|
||||
import { ContextMenuSubTrigger, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes["class"]; inset?: boolean }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,10 +19,12 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
data-slot="context-menu-sub-trigger"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-2 rounded-md px-2 py-1 text-[13px] data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-2 rounded-md px-2 py-1 text-[13px] data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ContextMenuTriggerProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ContextMenuTrigger, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<ContextMenuTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<ContextMenuTriggerProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuTrigger
|
||||
data-slot="context-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('select-none', props.class)"
|
||||
>
|
||||
<ContextMenuTrigger data-slot="context-menu-trigger" v-bind="forwardedProps" :class="cn('select-none', props.class)">
|
||||
<slot />
|
||||
</ContextMenuTrigger>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export { default as ContextMenu } from './ContextMenu.vue'
|
||||
export { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.vue'
|
||||
export { default as ContextMenuContent } from './ContextMenuContent.vue'
|
||||
export { default as ContextMenuGroup } from './ContextMenuGroup.vue'
|
||||
export { default as ContextMenuItem } from './ContextMenuItem.vue'
|
||||
export { default as ContextMenuLabel } from './ContextMenuLabel.vue'
|
||||
export { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.vue'
|
||||
export { default as ContextMenuRadioItem } from './ContextMenuRadioItem.vue'
|
||||
export { default as ContextMenuSeparator } from './ContextMenuSeparator.vue'
|
||||
export { default as ContextMenuShortcut } from './ContextMenuShortcut.vue'
|
||||
export { default as ContextMenuSub } from './ContextMenuSub.vue'
|
||||
export { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'
|
||||
export { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.vue'
|
||||
export { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'
|
||||
export { default as ContextMenu } from "./ContextMenu.vue";
|
||||
export { default as ContextMenuCheckboxItem } from "./ContextMenuCheckboxItem.vue";
|
||||
export { default as ContextMenuContent } from "./ContextMenuContent.vue";
|
||||
export { default as ContextMenuGroup } from "./ContextMenuGroup.vue";
|
||||
export { default as ContextMenuItem } from "./ContextMenuItem.vue";
|
||||
export { default as ContextMenuLabel } from "./ContextMenuLabel.vue";
|
||||
export { default as ContextMenuRadioGroup } from "./ContextMenuRadioGroup.vue";
|
||||
export { default as ContextMenuRadioItem } from "./ContextMenuRadioItem.vue";
|
||||
export { default as ContextMenuSeparator } from "./ContextMenuSeparator.vue";
|
||||
export { default as ContextMenuShortcut } from "./ContextMenuShortcut.vue";
|
||||
export { default as ContextMenuSub } from "./ContextMenuSub.vue";
|
||||
export { default as ContextMenuSubContent } from "./ContextMenuSubContent.vue";
|
||||
export { default as ContextMenuSubTrigger } from "./ContextMenuSubTrigger.vue";
|
||||
export { default as ContextMenuTrigger } from "./ContextMenuTrigger.vue";
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui";
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
const props = defineProps<DialogRootProps>();
|
||||
const emits = defineEmits<DialogRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<DialogRoot v-slot="slotProps" data-slot="dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
import type { DialogCloseProps } from "reka-ui";
|
||||
import { DialogClose } from "reka-ui";
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
const props = defineProps<DialogCloseProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<DialogClose data-slot="dialog-close" v-bind="props">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import DialogOverlay from './DialogOverlay.vue'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { XIcon } from "lucide-vue-next";
|
||||
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DialogOverlay from "./DialogOverlay.vue";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const props = withDefaults(
|
||||
defineProps<DialogContentProps & { class?: HTMLAttributes["class"]; showCloseButton?: boolean }>(),
|
||||
{
|
||||
showCloseButton: true,
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,15 +32,16 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
as-child
|
||||
>
|
||||
<DialogClose v-if="showCloseButton" data-slot="dialog-close" as-child>
|
||||
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm">
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DialogDescriptionProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogDescription, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { DialogClose } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
showCloseButton?: boolean
|
||||
}>(), {
|
||||
showCloseButton: false,
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
class?: HTMLAttributes["class"];
|
||||
showCloseButton?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showCloseButton: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<DialogClose v-if="showCloseButton" as-child>
|
||||
<Button variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="outline"> Close </Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('gap-2 flex flex-col', props.class)"
|
||||
>
|
||||
<div data-slot="dialog-header" :class="cn('gap-2 flex flex-col', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DialogOverlayProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogOverlay } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { XIcon } from "lucide-vue-next";
|
||||
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>();
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,19 +32,19 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}"
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<DialogClose class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary">
|
||||
<XIcon class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DialogTitleProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogTitle, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
import type { DialogTriggerProps } from "reka-ui";
|
||||
import { DialogTrigger } from "reka-ui";
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
const props = defineProps<DialogTriggerProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<DialogTrigger data-slot="dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as DialogClose } from './DialogClose.vue'
|
||||
export { default as DialogContent } from './DialogContent.vue'
|
||||
export { default as DialogDescription } from './DialogDescription.vue'
|
||||
export { default as DialogFooter } from './DialogFooter.vue'
|
||||
export { default as DialogHeader } from './DialogHeader.vue'
|
||||
export { default as DialogOverlay } from './DialogOverlay.vue'
|
||||
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||
export { default as DialogTitle } from './DialogTitle.vue'
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||
export { default as Dialog } from "./Dialog.vue";
|
||||
export { default as DialogClose } from "./DialogClose.vue";
|
||||
export { default as DialogContent } from "./DialogContent.vue";
|
||||
export { default as DialogDescription } from "./DialogDescription.vue";
|
||||
export { default as DialogFooter } from "./DialogFooter.vue";
|
||||
export { default as DialogHeader } from "./DialogHeader.vue";
|
||||
export { default as DialogOverlay } from "./DialogOverlay.vue";
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue";
|
||||
export { default as DialogTitle } from "./DialogTitle.vue";
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue";
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui";
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
const props = defineProps<DropdownMenuRootProps>();
|
||||
const emits = defineEmits<DropdownMenuRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dropdown-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<DropdownMenuRoot v-slot="slotProps" data-slot="dropdown-menu" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { CheckIcon } from "lucide-vue-next";
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuItemIndicator, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>();
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuContent, DropdownMenuPortal, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
align: 'start',
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
const props = withDefaults(defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(), {
|
||||
align: "start",
|
||||
sideOffset: 4,
|
||||
});
|
||||
const emits = defineEmits<DropdownMenuContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,7 +25,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 text-popover-foreground min-w-32 rounded-lg p-1 ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-dropdown-menu-content-available-height) w-(--reka-dropdown-menu-trigger-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 text-popover-foreground min-w-32 rounded-lg p-1 ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-dropdown-menu-content-available-height) w-(--reka-dropdown-menu-trigger-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuGroupProps } from 'reka-ui'
|
||||
import { DropdownMenuGroup } from 'reka-ui'
|
||||
import type { DropdownMenuGroupProps } from "reka-ui";
|
||||
import { DropdownMenuGroup } from "reka-ui";
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>()
|
||||
const props = defineProps<DropdownMenuGroupProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup
|
||||
data-slot="dropdown-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<DropdownMenuGroup data-slot="dropdown-menu-group" v-bind="props">
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DropdownMenuItemProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(),
|
||||
{
|
||||
variant: "default",
|
||||
},
|
||||
);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'inset', 'variant', 'class')
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,7 +29,12 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DropdownMenuLabel, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DropdownMenuLabelProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuLabel, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"]; inset?: boolean }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'inset')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset");
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from 'reka-ui'
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui";
|
||||
import { DropdownMenuRadioGroup, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>();
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<DropdownMenuRadioGroup data-slot="dropdown-menu-radio-group" v-bind="forwarded">
|
||||
<slot />
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { CheckIcon } from "lucide-vue-next";
|
||||
import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DropdownMenuSeparatorProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuSeparator } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
const props = defineProps<
|
||||
DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
}
|
||||
>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
:class="cn('text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from 'reka-ui'
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui";
|
||||
import { DropdownMenuSub, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<DropdownMenuSubProps>()
|
||||
const emits = defineEmits<DropdownMenuSubEmits>()
|
||||
const props = defineProps<DropdownMenuSubProps>();
|
||||
const emits = defineEmits<DropdownMenuSubEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>();
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 text-popover-foreground min-w-[96px] rounded-lg p-1 ring-1 duration-100 cn-menu-translucent z-50 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 text-popover-foreground min-w-[96px] rounded-lg p-1 ring-1 duration-100 cn-menu-translucent z-50 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRightIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronRightIcon } from "lucide-vue-next";
|
||||
import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"]; inset?: boolean }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'inset')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset");
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,10 +18,12 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuTriggerProps } from 'reka-ui'
|
||||
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
|
||||
import type { DropdownMenuTriggerProps } from "reka-ui";
|
||||
import { DropdownMenuTrigger, useForwardProps } from "reka-ui";
|
||||
|
||||
const props = defineProps<DropdownMenuTriggerProps>()
|
||||
const props = defineProps<DropdownMenuTriggerProps>();
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
const forwardedProps = useForwardProps(props);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<DropdownMenuTrigger data-slot="dropdown-menu-trigger" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export { default as DropdownMenu } from './DropdownMenu.vue'
|
||||
export { default as DropdownMenu } from "./DropdownMenu.vue";
|
||||
|
||||
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
|
||||
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
|
||||
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
|
||||
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
|
||||
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
|
||||
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
|
||||
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
|
||||
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
|
||||
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
|
||||
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
|
||||
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'
|
||||
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
|
||||
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
|
||||
export { DropdownMenuPortal } from 'reka-ui'
|
||||
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue";
|
||||
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue";
|
||||
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue";
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue";
|
||||
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue";
|
||||
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue";
|
||||
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue";
|
||||
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue";
|
||||
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue";
|
||||
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue";
|
||||
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue";
|
||||
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue";
|
||||
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue";
|
||||
export { DropdownMenuPortal } from "reka-ui";
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
defaultValue?: string | number;
|
||||
modelValue?: string | number;
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', payload: string | number): void
|
||||
}>()
|
||||
(e: "update:modelValue", payload: string | number): void;
|
||||
}>();
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,9 +27,11 @@ const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-slot="input"
|
||||
:class="cn(
|
||||
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
:class="
|
||||
cn(
|
||||
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
export { default as Input } from "./Input.vue";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { LabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Label } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { LabelProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Label } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default as Label } from './Label.vue'
|
||||
export { default as Label } from "./Label.vue";
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaViewport,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ScrollBar from './ScrollBar.vue'
|
||||
import type { ScrollAreaRootProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ScrollBar from "./ScrollBar.vue";
|
||||
|
||||
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot
|
||||
data-slot="scroll-area"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('relative', props.class)"
|
||||
>
|
||||
<ScrollAreaRoot data-slot="scroll-area" v-bind="delegatedProps" :class="cn('relative', props.class)">
|
||||
<ScrollAreaViewport
|
||||
data-slot="scroll-area-viewport"
|
||||
class="size-full overflow-auto rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaScrollbarProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ScrollAreaScrollbarProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
orientation: 'vertical',
|
||||
})
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes["class"] }>(), {
|
||||
orientation: "vertical",
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -17,11 +17,13 @@ const delegatedProps = reactiveOmit(props, 'class')
|
||||
data-slot="scroll-area-scrollbar"
|
||||
:data-orientation="orientation"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
class="rounded-full relative flex-1 bg-border"
|
||||
/>
|
||||
<ScrollAreaThumb data-slot="scroll-area-thumb" class="rounded-full relative flex-1 bg-border" />
|
||||
</ScrollAreaScrollbar>
|
||||
</template>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as ScrollArea } from './ScrollArea.vue'
|
||||
export { default as ScrollBar } from './ScrollBar.vue'
|
||||
export { default as ScrollArea } from "./ScrollArea.vue";
|
||||
export { default as ScrollBar } from "./ScrollBar.vue";
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { SelectRootEmits, SelectRootProps } from "reka-ui";
|
||||
import { SelectRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
const props = defineProps<SelectRootProps>();
|
||||
const emits = defineEmits<SelectRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="select"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<SelectRoot v-slot="slotProps" data-slot="select" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
position: 'item-aligned',
|
||||
align: 'center',
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
const props = withDefaults(defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(), {
|
||||
position: "item-aligned",
|
||||
align: "center",
|
||||
});
|
||||
const emits = defineEmits<SelectContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,20 +27,23 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
data-slot="select-content"
|
||||
:data-align-trigger="position === 'item-aligned'"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
:class="
|
||||
cn(
|
||||
'text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:data-position="position"
|
||||
:class="cn(
|
||||
'data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectGroup } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SelectGroupProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { SelectGroup } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup
|
||||
data-slot="select-group"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('scroll-my-1 p-1', props.class)"
|
||||
>
|
||||
<SelectGroup data-slot="select-group" v-bind="delegatedProps" :class="cn('scroll-my-1 p-1', props.class)">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItemProps } from 'reka-ui'
|
||||
import type { SelectItemProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
useForwardProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { CheckIcon } from "lucide-vue-next";
|
||||
import { SelectItem, SelectItemIndicator, SelectItemText, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItemTextProps } from 'reka-ui'
|
||||
import { SelectItemText } from 'reka-ui'
|
||||
import type { SelectItemTextProps } from "reka-ui";
|
||||
import { SelectItemText } from "reka-ui";
|
||||
|
||||
const props = defineProps<SelectItemTextProps>()
|
||||
const props = defineProps<SelectItemTextProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItemText
|
||||
data-slot="select-item-text"
|
||||
v-bind="props"
|
||||
>
|
||||
<SelectItemText data-slot="select-item-text" v-bind="props">
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SelectLabelProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { SelectLabel } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel
|
||||
data-slot="select-label"
|
||||
:class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)"
|
||||
>
|
||||
<SelectLabel data-slot="select-label" :class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)">
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||
import type { SelectScrollDownButtonProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDownIcon } from 'lucide-vue-next'
|
||||
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronDownIcon } from "lucide-vue-next";
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<ChevronDownIcon />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user