chore: add oxfmt + husky + lint-staged for code formatting and CI format checks

This commit is contained in:
t8y2
2026-05-05 15:41:34 +08:00
parent c295bf5780
commit 3acd9280ac
154 changed files with 5787 additions and 3681 deletions
+7
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
npx lint-staged
cd src-tauri && cargo fmt && git add -u .
+9
View File
@@ -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
View File
@@ -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"
}
}
+373 -8
View File
@@ -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
View File
@@ -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>
+4 -4
View File
@@ -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>
+507 -329
View File
@@ -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>
+232 -154
View File
@@ -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>
+61 -43
View File
@@ -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>
+195 -61
View File
@@ -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>
+26 -21
View File
@@ -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>
+67 -68
View File
@@ -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>
+30 -33
View File
@@ -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(() => {
+14 -11
View File
@@ -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>
+11 -8
View File
@@ -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>
+47 -24
View File
@@ -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
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.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>
+12 -2
View File
@@ -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>
+64 -54
View File
@@ -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>
+46 -18
View File
@@ -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>
+3 -3
View File
@@ -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">
+41 -37
View File
@@ -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>
+80 -33
View File
@@ -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>
+55 -24
View File
@@ -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"> &middot; {{ 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>
+53 -36
View File
@@ -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>
+32 -22
View File
@@ -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>
+47 -30
View File
@@ -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>
+175 -107
View File
@@ -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>
+18 -21
View File
@@ -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
+131 -103
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escaped = json.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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>
+74 -64
View File
@@ -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>
+97 -25
View File
@@ -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>
+51 -31
View File
@@ -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>
+3 -3
View File
@@ -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
View File
@@ -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>
+98 -74
View File
@@ -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>
+14 -12
View File
@@ -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>
+14 -13
View File
@@ -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>;
+11 -11
View File
@@ -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>
+27 -22
View File
@@ -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>
+14 -14
View File
@@ -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";
+6 -10
View File
@@ -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>
+4 -7
View File
@@ -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>
+25 -26
View File
@@ -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>
+14 -9
View File
@@ -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>
+20 -14
View File
@@ -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>
+5 -8
View File
@@ -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>
+13 -8
View File
@@ -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>
+8 -8
View File
@@ -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>
+4 -7
View File
@@ -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>
+10 -10
View File
@@ -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>
+15 -15
View File
@@ -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";
+18 -16
View File
@@ -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
View File
@@ -1 +1 @@
export { default as Input } from './Input.vue'
export { default as Input } from "./Input.vue";
+7 -7
View File
@@ -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
View File
@@ -1 +1 @@
export { default as Label } from './Label.vue'
export { default as Label } from "./Label.vue";
+9 -17
View File
@@ -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"
+16 -14
View File
@@ -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>
+2 -2
View File
@@ -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";
+6 -10
View File
@@ -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>
+26 -31
View File
@@ -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>
+8 -12
View File
@@ -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>
+9 -14
View File
@@ -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>
+4 -7
View File
@@ -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>
+6 -9
View File
@@ -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