Compare commits

...
189 Commits
Author SHA1 Message Date
t8y2 44e6e53f17 chore(release): prepare v0.6.25 2026-09-27 12:38:03 +08:00
t8y2 deaa867498 feat(admin): add table grants for postgres and starrocks
Closes #10251
2026-09-27 12:04:10 +08:00
t8y2 627a8498d0 feat(sql): add schema qualification completion setting
Closes #9219
2026-09-27 12:04:10 +08:00
t8y2 4a7b8f0f27 feat(grid): add configurable column width layout
Closes #10366
2026-09-27 12:04:10 +08:00
二丫讲梵 72f8dfeb7c fix(mcp): repair HTTP service startup and simplify settings 2026-09-27 12:04:10 +08:00
二丫讲梵 2b26c16683 feat(database-browser): add context menus and load names first 2026-09-27 12:04:09 +08:00
t8y2 b4aba36dc9 fix(release): exclude host display libraries from AppImage 2026-09-27 12:04:09 +08:00
二丫讲梵 e6edd02bbe feat(ai): add plugin recommendations and agent context 2026-09-27 12:04:09 +08:00
zipg 1b6017b9c5 fix(grid): merge condition bar typing into one undo step 2026-09-27 10:44:05 +08:00
zipg e092e5bae9 fix(desktop): batch debug log writes to avoid UI stalls 2026-09-27 10:36:55 +08:00
zipg b3f78b5871 fix(i18n): fix Korean jump-to-page placeholder and add guard 2026-09-27 10:35:40 +08:00
zipg b715ce1fdf fix(objects): load objects without waiting for slow schema list 2026-09-27 10:34:26 +08:00
zipg 94d9c2d026 fix(generate): dedupe unique-constraint columns by default 2026-09-27 10:29:47 +08:00
zipg 49b6347c4a fix(mcp): update pnpm-installed MCP server with @latest 2026-09-27 10:28:32 +08:00
Loc Nguyen 6ee12ab0a5 feat(mongodb): complete bulkWrite operations in completions 2026-09-27 10:27:16 +08:00
zipg 26138a6236 fix(import): skip styled blank rows in Excel import 2026-09-27 10:26:02 +08:00
Guoyu Su 9962796a04 fix(iotdb): recognize JDBC rpc_compress option 2026-09-27 10:24:49 +08:00
二丫讲梵 997d217cb2 fix(plugins): wrap marketplace permission badges to stop overflow 2026-09-27 10:24:49 +08:00
zipg 720597235c fix(grid): toast feedback after saving data extractor config 2026-09-27 10:24:49 +08:00
zipg f0a628843b fix(settings): point debug-logging search hit at the About tab 2026-09-27 10:24:49 +08:00
t8y2 35e2f83ff9 style(plugins): format media bridge callbacks 2026-09-27 10:00:30 +08:00
t8y2 422908e78f feat(plugins): add clipboard image and media URL APIs 2026-09-27 09:52:03 +08:00
github-actions[bot] 0ea6598c90 chore(docs): refresh contributor activity 2026-09-26 18:11:21 +00:00
t8y2 749d72d874 feat(grid): export selections with data extractors
Closes #10360
2026-09-26 16:45:22 +00:00
t8y2 5a272565bb feat(plugins): reconnect after connection config changes
Closes #9974
2026-09-26 16:45:22 +00:00
t8y2 0a4e46a25f feat(duckdb): support column rename
Closes #9506
2026-09-26 16:45:21 +00:00
Manimaran 3c46f8d440 feat(import): add option to skip duplicate rows 2026-09-26 23:14:03 +08:00
Loc Nguyen daebae9b50 fix(redis): bound collection value pages by payload size 2026-09-26 22:48:24 +08:00
t8y2 ed863e9e59 fix(desktop): prevent window drag from tab whitespace
Closes #10194
2026-09-26 14:27:19 +00:00
t8y2 b4f503e1f1 fix(editor): bound analysis for large source documents
Closes #9208
2026-09-26 14:27:19 +00:00
t8y2 94b5856e8d fix(kafka): avoid reverse DNS for SASL IP brokers
Closes #10257
2026-09-26 14:27:19 +00:00
_Mr丶s ゛😁® 59d136fcf2 feat(import): collapsible import window with background progress 2026-09-26 22:20:55 +08:00
二丫讲梵 5c0ce7e4dc feat(history): add unified MCP execution history 2026-09-26 22:18:38 +08:00
Loc Nguyen dd82d948a9 feat(mongodb): complete collection method option completion keys 2026-09-26 21:48:22 +08:00
zipg 21a47de988 fix(vastbase): keep pk, unique and indexes in structure copy 2026-09-26 21:47:53 +08:00
zdzfeer 4425fb72c6 fix(grid): align expanded condition caret with wrapped text 2026-09-26 21:47:10 +08:00
Jinpy c5e1cc35d2 fix(plugins): transfer standalone ArrayBuffer in fileTransfer.write 2026-09-26 21:46:36 +08:00
二丫讲梵 572757b933 feat(ai): confirm before resending a chat message 2026-09-26 21:46:05 +08:00
zipg 3829513cfb fix(dameng): show declared char/byte length unit in table structure 2026-09-26 21:45:33 +08:00
zipg 33cac93254 fix(connection): per-id saves stop cross-client connection clobber 2026-09-26 21:45:03 +08:00
zipg a8006a1fdd fix(schema): list table sequences for kingbase and vastbase 2026-09-26 21:44:29 +08:00
t8y2 2b38509768 fix(grid): improve filter rule actions 2026-09-26 21:11:02 +08:00
t8y2 ec38b8c371 feat(mqtt): collapse individual message payloads
Closes #9307
2026-09-26 12:45:31 +00:00
t8y2 8b3843dfd8 fix(grid): keep filter editor open after apply
Closes #10181
2026-09-26 12:45:30 +00:00
t8y2 8a385c3348 fix(sidebar): preserve searched database activation
Closes #10131
2026-09-26 12:45:30 +00:00
Jinpy dcd16e0d0a fix(mcp-bridge): bind call-plugin-tool to the connection's own plugin 2026-09-26 14:22:29 +08:00
t8y2 72f32d630f feat(editor): add new-result execution toolbar action
Closes #10128
2026-09-26 05:56:42 +00:00
Jinpy 7a7edfb2b8 fix(mcp-bridge): read full request bodies and refuse browser calls 2026-09-26 13:51:15 +08:00
Jinpy d1d02547fd fix(plugins): list command and menu contributions in manifest schema 2026-09-26 13:49:16 +08:00
Jinpy 2851da891e fix(plugin-installer): keep atomic writes and rollbacks lossless 2026-09-26 13:47:17 +08:00
zipg 79c69e5097 fix(oracle): parse dblink suffix so alias projection rewrite applies 2026-09-26 13:45:19 +08:00
zipg 9dc7e1982c fix(cli): distinguish missing connections table from failed store open 2026-09-26 13:42:51 +08:00
zipg 4ac59ddf3c fix(dameng): reject binary LOB columns in keyless row predicates 2026-09-26 13:40:43 +08:00
zipg 347eea66b7 fix(postgres): add information_schema fallback tier for batch columns 2026-09-26 13:39:25 +08:00
t8y2 d2df8d1633 feat(grid): add direct table structure navigation
Closes #9937
2026-09-26 04:59:56 +00:00
t8y2 66bfc86c78 fix(query): avoid preflight probes for Oracle and PostgreSQL
Refs: #9960
2026-09-26 12:44:50 +08:00
t8y2 2024810250 fix(dameng): speed up table DDL metadata lookup
Closes #10075
2026-09-26 04:23:32 +00:00
t8y2 6b67957ecf fix(linux): preserve Wayland in AppImage bundles
Closes #8333
2026-09-26 03:06:08 +00:00
github-actions[bot] 99d80f7a83 chore(packages): release 0.4.98 [skip node-packages-release] 2026-09-26 00:37:29 +00:00
github-actions[bot] b89b9b8cac chore: bump module versions [skip ci] 2026-09-26 00:22:11 +00:00
t8y2 2d3f3a5b73 chore(release): prepare v0.6.24 2026-09-26 08:01:12 +08:00
知食分子 4ad9c5f38f fix(desktop): restore legacy webview tints and active tab background 2026-09-26 07:20:45 +08:00
zipg b504b3e8a9 fix(transfer): gate IF NOT EXISTS DDL on PostgreSQL 9.5 for index and sequence 2026-09-26 06:58:21 +08:00
二丫讲梵 ce13d6b568 fix: center panel resize grips in gutters 2026-09-26 06:55:26 +08:00
二丫讲梵 2ad6f4c98e fix(ai): compact composer controls based on overflow 2026-09-26 06:51:19 +08:00
zipg fe8737550c fix(sqlserver): preserve computed column DDL in view and export 2026-09-26 06:48:05 +08:00
zipg 20577142fa fix(agent): install sqlite ssh worker per remote arch from offline zip 2026-09-26 06:47:45 +08:00
弈秋 8da0c59f42 fix(plugins): stop marketplace card version badge clipping in narrow panel 2026-09-26 06:46:59 +08:00
dienaso ec9935d3ea fix(completion): keep FROM tables across blank lines in one statement 2026-09-26 06:23:21 +08:00
zipg 1364293202 fix(objects): reset view source saving state on panel reload 2026-09-26 06:08:08 +08:00
jearhe 8be259be5f fix(plugins): make plugin uninstall state consistent on failure 2026-09-26 06:07:31 +08:00
zipg 88dccd7cde fix(import): stream JSON table import and drop the 100 MB cap 2026-09-26 06:06:54 +08:00
zipg 6d28365c93 fix(data-grid): match PK columns case-insensitively for Vastbase 2026-09-26 06:06:16 +08:00
zipg 1efd41cfaa fix(ssh): keep concrete agent stream types for Windows agent auth 2026-09-26 06:03:59 +08:00
Guoyu Su e6106072d5 fix(doris): honor MySQL TLS configuration 2026-09-26 03:05:45 +08:00
zipg 8e0b2ff496 fix(core): cache resolved secret codec per storage instance 2026-09-26 03:04:58 +08:00
二丫讲梵 81b1a1e88e feat(objects): default table panel to DDL tab 2026-09-26 03:02:29 +08:00
t8y2 6e3709c73b fix(sidebar): stop sticky headers at connection boundaries
Closes #9996
2026-09-26 02:26:53 +08:00
github-actions[bot] cc456c3d4f chore(docs): refresh contributor activity 2026-09-25 18:12:27 +00:00
弈秋 ea3cb4f38a fix(mcp): skip plugins without an mcp/tools surface 2026-09-26 02:07:58 +08:00
jearhe 55a4b9f450 fix(plugins): refresh frontend contributions on plugin changes 2026-09-26 01:35:00 +08:00
t8y2 a2cd9fb5da fix(ci): refresh standalone DuckDB lockfile
Refs: #10176
2026-09-26 01:33:34 +08:00
zipg 08773d93f7 fix(formatter): stop line comments swallowing following tokens 2026-09-26 01:33:32 +08:00
zipg c224b3c972 fix(editor): keep batch column session across empty results 2026-09-26 01:32:04 +08:00
zipg 64ba6a537b fix(iris): paginate table data via %VID window and fix count quoting 2026-09-26 01:30:34 +08:00
zipg 29cd60be4f fix(sql-file): flush import batches by buffered bytes for large dumps 2026-09-26 01:29:05 +08:00
t8y2 05958bd21a fix(oceanbase): read PL source from ALL_SOURCE first
Closes #10212
2026-09-26 01:25:24 +08:00
t8y2 e98c7792e7 fix(agent): release external Windows process locks
Closes #10176
2026-09-26 01:25:23 +08:00
t8y2 de827a922e fix(ssh): support Windows OpenSSH agent pipes
Closes #10313
2026-09-26 01:25:23 +08:00
zipg e0121fd649 fix(dialog): keep dialogs opaque over custom background image 2026-09-26 00:40:25 +08:00
Thanadon Kongkanun d6c803b17b fix(sql-schema): omit source sequence default in target DDL 2026-09-26 00:38:40 +08:00
Guoyu Su 40bfe02074 fix(mysql): fall back completion metadata for incompatible servers 2026-09-26 00:37:10 +08:00
Hand Sonic fc0d576f6d feat(web): manage MCP HTTP access from settings 2026-09-26 00:29:23 +08:00
ccq 705ea7464d feat(export): run data dictionary PDF exports in background 2026-09-26 00:04:52 +08:00
弈秋 4baec21fb7 fix(plugins): invalidate icon cache on updates outside plugin center 2026-09-26 00:03:21 +08:00
zipg 7255a194cb fix(grid): Ctrl+A selects SQL preview text, not table data 2026-09-25 23:59:04 +08:00
zipg e91340ee15 fix(postgres): restore 9.x table DDL and sequence export 2026-09-25 23:57:36 +08:00
Thanadon Kongkanun 6db2589e37 fix(grid): contain query grid scrollbars below sticky header 2026-09-25 23:56:08 +08:00
zipg 070e09c615 fix(iris): display %Boolean columns as stored 1/0 values 2026-09-25 23:52:18 +08:00
t8y2 a71e4534ae chore(frontend): format Tauri event listeners 2026-09-25 22:52:43 +08:00
ccq ff07fe0d02 fix(duckdb): render timestamptz with session time zone 2026-09-25 22:49:26 +08:00
_Mr丶s ゛😁® 8444fdbf09 feat(dialog): resizable DDL dialog with maximize on double-click 2026-09-25 19:51:46 +08:00
zipg bfc834c779 ci(rust): rename drivers coverage feature to dbx-sql-data/openapi 2026-09-25 19:50:38 +08:00
t8y2 c29ec0d58e fix(schema-diff): focus modified table DDL changes
Closes #10110
2026-09-25 19:20:11 +08:00
t8y2 7265e03817 fix(mysql): retry table listing on selected database
Closes #10094
2026-09-25 19:20:11 +08:00
t8y2 135e5a91a7 fix(deep-link): preserve cold-start connection links
Closes #10090
2026-09-25 19:20:11 +08:00
t8y2 02886a4682 docs(readme): add message queue and plugin system sections 2026-09-25 18:11:04 +08:00
t8y2 67fef902f4 docs: point readme hero image at 100+ version 2026-09-25 16:30:51 +08:00
t8y2 a3038e25ea docs: raise advertised database count from 90+ to 100+ 2026-09-25 16:21:54 +08:00
t8y2 0fbdd86ac3 feat(updates): use compact one-line lists and underline tabs in update center 2026-09-25 16:12:15 +08:00
zipg 94a8fd6790 fix(sidebar): re-read displayed range when paging window drifts 2026-09-25 16:00:09 +08:00
zipg 577154cf5a fix(sqlserver-legacy): support view and trigger DDL retrieval 2026-09-25 15:58:32 +08:00
弈秋 ca583c849d fix(gbase8s): show locale/charset picker in routed create-db dialog 2026-09-25 15:56:56 +08:00
弈秋 79d172fcff fix(support-info): show Windows marketing name in support info 2026-09-25 15:54:58 +08:00
t8y2 e0da2d09d3 refactor(workspace): split SQL and database driver crates 2026-09-25 15:51:58 +08:00
t8y2 b99c60aff6 fix(sql-library): load saved files earlier at startup 2026-09-25 14:06:37 +08:00
t8y2 cb16b0f8be fix(updates): hide HTML comment markers in release notes 2026-09-25 12:45:40 +08:00
t8y2 b8274363b8 feat(mcp): add native installs and resource discovery 2026-09-25 12:37:26 +08:00
github-actions[bot] 3144762194 chore(packages): release 0.4.97 [skip node-packages-release] 2026-09-25 03:40:39 +00:00
github-actions[bot] 3818692ce2 chore: bump module versions [skip ci] 2026-09-25 03:20:19 +00:00
t8y2 6bb7105b4c docs(mcp): document sqlite-bundled flag for no-default-features runs 2026-09-25 11:05:16 +08:00
t8y2 c43ca13ae3 chore(release): prepare v0.6.23 2026-09-25 10:42:03 +08:00
zipg 27b76680ad fix(redis): delete whole subtree by prefix on group delete 2026-09-25 10:38:15 +08:00
zipg 2b8329f0f2 fix(export): declare utf8mb4 charset in MySQL export script 2026-09-25 10:34:55 +08:00
zipg 06b09768c2 fix(mysql): reapply group_concat floor only below server value 2026-09-25 10:34:20 +08:00
zipg eac2f14bfc fix(schema): fall back to SHOW CREATE MATERIALIZED VIEW on Doris 2026-09-25 10:32:54 +08:00
zipg ade47c9f3e fix(schema): return object source for routine and trigger DDL 2026-09-25 10:31:31 +08:00
t8y2 c4e17f7eb7 test(grid): stub RecycleScroller module in batch paste spec 2026-09-25 09:59:20 +08:00
Jinpy ec697a438c fix(plugins): keep dock plus picker inside window and open downward 2026-09-25 09:42:22 +08:00
_Mr丶s ゛😁® 986faa7584 fix(editor): trigger column completion after SELECT keyword 2026-09-25 09:41:04 +08:00
Guoyu Su 66682b12a6 fix(transfer): preserve rows in MySQL generated-only tables 2026-09-25 09:39:47 +08:00
zipg 70569e556f fix(schema-diff): correct Oracle/JDBC column, index and view diffs 2026-09-25 09:38:29 +08:00
zipg ba422f96f8 fix(query): single-column results editable on case-preserving engines 2026-09-25 09:37:11 +08:00
zipg a77a92a80c fix(dameng): resolve empty schema via session current schema 2026-09-25 09:35:54 +08:00
ezreal b3e243d516 fix(plugins): mirror right-docked shortcut drag preview 2026-09-25 09:34:34 +08:00
zipg 3411e5c864 fix(ci): remove unused storage imports breaking clippy gate 2026-09-25 09:33:15 +08:00
t8y2 48d66d04d0 fix(startup): reduce initial loading and unblock app restoration 2026-09-25 09:03:15 +08:00
t8y2 55c07f5852 fix(dev): stabilize macOS signing and isolate test keys 2026-09-25 02:39:41 +08:00
github-actions[bot] 7b825db71b chore(docs): refresh contributor activity 2026-09-24 18:12:39 +00:00
t8y2 26a015cd86 feat(plugins): add AI tools and consent-gated data access 2026-09-25 01:56:26 +08:00
t8y2 1bc6f70cd8 fix(ai): initialize conversation bindings before default action selection 2026-09-25 01:56:25 +08:00
zipg c4a82a1e7a fix(schema): load types and comments for JDBC Oracle synonyms 2026-09-25 00:45:48 +08:00
zipg 86a0cd67ef fix(schema): complete synonyms in JDBC Oracle table completion 2026-09-25 00:22:01 +08:00
zipg ca9bfae5b9 fix(oracle): render placeholder for user-defined type columns 2026-09-24 23:48:53 +08:00
zhouwenqing 3640f6827e feat(web): embed static dist and add win7 static browser release 2026-09-24 23:22:40 +08:00
dadong 37fc2f8a9b fix(update_cache): clear existing ready dir before rename on Windows 2026-09-24 23:18:10 +08:00
64673a 3266c014e5 fix(sqlite): avoid corrupting the SSH worker during parallel uploads 2026-09-24 23:17:44 +08:00
t8y2 ab468350ec fix(core): drop needless return in test secret key path 2026-09-24 22:43:48 +08:00
Jinpy ccfb108aa1 perf(plugins): dock open latency and tab strip UX fixes 2026-09-24 22:43:24 +08:00
大峰 46cc9be7d9 feat(editor): highlight unknown SQL objects 2026-09-24 22:34:15 +08:00
OOMEcho cd24f69daa feat(grid): add column header tooltip toggle 2026-09-24 22:33:50 +08:00
Loc Nguyen 3294ade2cc fix(mongodb): apply substring filters on non-string columns 2026-09-24 22:33:24 +08:00
t8y2 97eef1e725 fix(core): gate default secret key path resolution out of test builds 2026-09-24 22:19:23 +08:00
t8y2 007c23b4e7 fix(drivers): add missing query_timings_ms to salesforce driver 2026-09-24 21:59:20 +08:00
高桥凉介 c4754dc49f feat(updater): add entry to roll back to older versions 2026-09-24 21:32:05 +08:00
Thanadon Kongkanun 4a67591641 fix(grid): keep large JSON numbers exact in copied SQL 2026-09-24 21:31:44 +08:00
xianghu908 a6696f07ed fix(data-grid): placeholder for pending rows in original row number 2026-09-24 21:31:13 +08:00
Tara 5c66bf5e09 feat(postgres): browse PostgreSQL event triggers 2026-09-24 21:30:42 +08:00
onenewcode b140f84ac4 feat(docs): export data dictionary as PDF 2026-09-24 19:53:58 +08:00
t8y2 2d4ee24660 chore(core): rustfmt secret codec test imports 2026-09-24 19:44:31 +08:00
jyking 0f708c2913 fix(mysql): fall back group_concat on TXSQL truncated variable error 2026-09-24 19:43:42 +08:00
Abeautifulsnow 900f99797f feat(grid): show query elapsed time in adaptive units 2026-09-24 19:33:19 +08:00
zipg a6f841293b fix(updates): include MCP updates in toolbar update check 2026-09-24 19:20:27 +08:00
AiLi 1eb429e137 fix(tabs): close tab tooltips immediately and refine styling 2026-09-24 19:19:01 +08:00
efeire d0497e0ca6 feat(query): cross-database timing details, drop OceanBase audit query 2026-09-24 19:17:27 +08:00
Abeautifulsnow 4b9a1c4d5c feat(plugins): add marketplace sort modes with release dates 2026-09-24 19:16:02 +08:00
jearhe 4c1eaa6b16 feat(plugins): open plugin workbench from context menu 2026-09-24 19:14:53 +08:00
zipg 05a72de255 feat(grid): add stop button to loading and refresh overlay 2026-09-24 19:13:32 +08:00
t8y2 124d7ae6cc fix(core): isolate Linux keyring access from async runtimes 2026-09-24 18:56:08 +08:00
t8y2 c5b9b9ab5d fix(docs): add Salesforce connection entry and icon 2026-09-24 18:32:53 +08:00
邹超 f87b5d1568 feat(salesforce): add Salesforce SOQL data source 2026-09-24 18:25:23 +08:00
t8y2 bf3e356540 fix(core): stop no-keyring builds creating the global fallback key 2026-09-24 17:57:12 +08:00
ezreal dc22e8c974 feat(plugins): configurable plugin shortcut dock positions 2026-09-24 17:55:23 +08:00
人家故里 b03d51db31 fix(docs): correct documentation typos 2026-09-24 17:30:44 +08:00
OOMEcho 55d4b0b613 feat(objects): open routines in source tab on double click 2026-09-24 17:26:50 +08:00
Thanadon Kongkanun 41ec53442c fix(mysql): recognize TDSQL truncated group_concat_max_len error 2026-09-24 17:23:25 +08:00
jearhe 78e717bea6 refactor(plan): converge estimated plan capability and strategy 2026-09-24 17:21:38 +08:00
高桥凉介 c5eb0a7a4a feat(query): pin, rename and label result run tabs 2026-09-24 17:18:12 +08:00
dienaso a593a2b200 fix(sql): keep SQL Server table hints out of soft statement starts 2026-09-24 17:18:06 +08:00
zipg d11d5acdfa fix(mysql): retry connect without optional setup when rejected 2026-09-24 16:55:50 +08:00
64673a ad18b8b4ea fix(sqlite): run remote batch saves in a worker transaction 2026-09-24 16:27:03 +08:00
github-actions[bot] 4b5d4e6aa7 chore: bump module versions [skip ci] 2026-09-24 08:19:06 +00:00
Jinpy 92f92f6cdb feat(plugins): bridge clipboard reads with consent gate 2026-09-24 16:16:49 +08:00
弈秋 1335ec032c test(mcp): canonicalize fixture path assertions 2026-09-24 16:16:23 +08:00
Mo-Moore a73bc4df69 fix(oceanbase-oracle): complete tables across schemas 2026-09-24 16:16:18 +08:00
t8y2 53184faf87 chore(release): prepare v0.6.22 2026-09-24 15:47:30 +08:00
t8y2 9db4615c6c feat(ai): per-partner promo badges on ai provider presets 2026-09-24 15:41:48 +08:00
t8y2 80141e3531 chore(sponsors): swap AICodeMirror with AstraFlow and lead ai presets 2026-09-24 14:54:34 +08:00
t8y2 d58b3c6a2d fix(core): finish platform key provider Result migration 2026-09-24 14:40:00 +08:00
871 changed files with 56598 additions and 5496 deletions
+6
View File
@@ -38,3 +38,9 @@ packages/**/*.test.ts linguist-vendored
# bytes and fail the guard for a contributor who touched neither file.
crates/dbx-core/assets/docs-export.js -text -diff linguist-generated
crates/dbx-core/assets/docs-export.css -text -diff linguist-generated
# The pnpm launcher fixtures are captured from real installations and baked into
# the Rust unit tests with include_str!, so the bytes those tests hand to the shim
# parser must not depend on the contributor's core.autocrlf: a CRLF checkout
# silently rewrites all three files.
src-tauri/tests/fixtures/** text eol=lf
+9 -2
View File
@@ -1,6 +1,13 @@
export const rustGroups = {
foundation: ["dbx-types", "dbx-platform", "dbx-sql", "dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime"],
drivers: ["dbx-drivers", "dbx-sqlite-worker"],
foundation: [
"dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql",
"dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime",
],
drivers: [
"dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers",
"dbx-sqlite-worker",
],
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp"],
};
+27 -8
View File
@@ -1,4 +1,4 @@
import { appendFileSync } from "node:fs";
import { appendFileSync, existsSync, readdirSync } from "node:fs";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -12,7 +12,21 @@ export function changedPaths(base, root) {
}).split("\0").filter(Boolean);
}
export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false }) {
export function knownJavaDrivers(root, nativeDrivers) {
try {
const driversRoot = path.join(root, "agents/drivers");
return readdirSync(driversRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory()
&& existsSync(path.join(driversRoot, entry.name, "build.gradle"))
&& !nativeDrivers.includes(entry.name))
.map((entry) => entry.name)
.sort();
} catch {
return [];
}
}
export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false, javaDrivers }) {
const unknownDiff = files === null;
files ??= [];
const packages = metadata.packages.filter((pkg) => metadata.workspace_members.includes(pkg.id));
@@ -28,7 +42,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
if (owner) affected.add(owner[0]);
else if (/^(?:crates|src-tauri)\//.test(file)) unknownRust = true;
if (file.startsWith("plugins/connection-types/")) affected.add("dbx-types");
if (file.startsWith("plugins/dialects/")) affected.add("dbx-sql");
if (file.startsWith("plugins/dialects/")) affected.add("dbx-sql-dialect");
}
const knownGroups = new Set(Object.values(rustGroups).flat());
const unknownMember = packages.some((pkg) => !knownGroups.has(pkg.name));
@@ -49,12 +63,17 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
.filter(([, names]) => names.some((name) => affected.has(name)))
.map(([group]) => ({ group }));
const nativeDrivers = [...goAgents.map((agent) => agent.driver), ...rustAgents];
const jdbcDrivers = javaDrivers ?? knownJavaDrivers(root, nativeDrivers);
const nativeChanges = new Set(nativeDrivers.filter((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`))));
const javaDriverChanges = jdbcDrivers.some((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`)));
// Driver-scoped JDBC/Go/Rust trees are not "shared Agent inputs". Unknown
// agents/drivers/<name>/ paths still fail open to full coverage below.
const sharedAgents = ciChanged || sharedRust || files.some((file) => file.startsWith("agents/")
&& !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)))
&& !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))
&& !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)))
|| files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml"
|| file === "crates/dbx-drivers/assets/agent-protocol-v2.json");
const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0);
|| file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json");
const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0 && !javaDriverChanges);
if (allAgents) {
for (const driver of nativeDrivers) nativeChanges.add(driver);
}
@@ -62,8 +81,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
const goMatrix = goAgents.filter((agent) => nativeChanges.has(agent.driver));
const nativeRustMatrix = rustAgents.filter((driver) => nativeChanges.has(driver)).map((driver) => ({ driver }));
const liveMatrix = integrationCases.filter((entry) => nativeChanges.has(entry.driver));
const java = allAgents;
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0;
const java = allAgents || javaDriverChanges;
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges;
return {
rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember,
affected_packages: [...affected].sort(),
+32 -4
View File
@@ -12,9 +12,22 @@ import { assertCoverage, parseCoverage } from "./ci-rust-coverage.mjs";
const root = "/fixture";
const graph = {
"dbx-types": [], "dbx-platform": [], "dbx-formats": [], "dbx-sql": ["dbx-types"],
"dbx-types": [], "dbx-platform": [], "dbx-formats": [], "dbx-sql-core": ["dbx-types"],
"dbx-sql-dialect": ["dbx-types"], "dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
"dbx-sql-schema": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
"dbx-sql": ["dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-sql-schema", "dbx-types"],
"dbx-ai-provider": ["dbx-platform"], "dbx-plugin-runtime": ["dbx-platform", "dbx-types"],
"dbx-sqlite-worker": [], "dbx-drivers": ["dbx-platform", "dbx-sql", "dbx-types", "dbx-sqlite-worker"],
"dbx-driver-support": ["dbx-platform", "dbx-types"],
"dbx-driver-agent": ["dbx-platform", "dbx-sql-core", "dbx-types"],
"dbx-driver-elasticsearch": ["dbx-driver-support", "dbx-types"],
"dbx-driver-mongodb": ["dbx-driver-support", "dbx-types"],
"dbx-driver-mysql": ["dbx-driver-support", "dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
"dbx-driver-postgres": ["dbx-driver-support", "dbx-sql-core", "dbx-types"],
"dbx-driver-redis": ["dbx-driver-support", "dbx-types"],
"dbx-driver-sqlserver": ["dbx-driver-support", "dbx-sql-core", "dbx-sql-data", "dbx-types"],
"dbx-sqlite-worker": [], "dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support",
"dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
"dbx-core": ["dbx-drivers", "dbx-sql", "dbx-types", "dbx-platform", "dbx-ai-provider", "dbx-plugin-runtime", "dbx-formats"],
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp"],
};
@@ -51,7 +64,7 @@ test("core and consumer changes do not pull unrelated foundation test groups", (
const cli = plan(["crates/dbx-cli/src/main.rs"]);
assert.deepEqual(groups(cli), ["application"]);
assert.equal(cli.agents, false);
assert.deepEqual(groups(plan(["crates/dbx-drivers/src/db/postgres.rs"])), ["drivers", "application"]);
assert.deepEqual(groups(plan(["crates/dbx-driver-postgres/src/postgres.rs"])), ["drivers", "application"]);
});
test("code generation and test-only dependencies participate in impact analysis", () => {
@@ -106,9 +119,24 @@ for (const driver of ["duckdb", "tdengine", ...goAgents.map((entry) => entry.dri
});
}
test("known JDBC driver changes only select Java agent tests", () => {
const result = plan(["agents/drivers/oceanbase-oracle/src/main/java/Agent.java"], {
agentsChanged: true,
javaDrivers: ["oceanbase-oracle", "dameng"],
});
assert.equal(result.rust, false);
assert.equal(result.agents, true);
assert.equal(result.agent_java, true);
assert.equal(result.agent_go.include.length, 0);
assert.equal(result.agent_rust.include.length, 0);
assert.equal(result.agent_integration.include.length, 0);
assert.equal(result.duckdb_changed, false);
assert.equal(result.fast, true);
});
test("shared Agent inputs and unknown native modules never silently lose coverage", () => {
for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle",
"agents/drivers/new-driver/main.go", "crates/dbx-drivers/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
"agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
const result = plan([file]);
assert.equal(result.agent_go.include.length, 10, file);
assert.equal(result.agent_integration.include.length, 16, file);
+4 -1
View File
@@ -12,10 +12,13 @@ export function rustCommand(action, group, mode) {
if (mode === "full") capabilityFeatures.push("system-fonts");
const appFeatures = ["dbx", "dbx-core", "dbx-web"].flatMap((name) => capabilityFeatures.map((feature) => `${name}/${feature}`));
const foundationFeatures = ["dbx-types/mq-admin", "dbx-types/openapi", "dbx-sql/duckdb-sidecar", "dbx-sql/openapi",
"dbx-sql-data/openapi", "dbx-sql-schema/duckdb-sidecar",
"dbx-platform/downloads", "dbx-platform/host-prompts", "dbx-platform/test-support", "dbx-plugin-runtime/default", "dbx-plugin-runtime/test-support"];
const features = group === "foundation" ? foundationFeatures : group === "drivers" ? [
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "test-support"].map((feature) => `dbx-drivers/${feature}`),
"dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql/openapi",
"dbx-driver-agent/test-support", "dbx-driver-mysql/test-support", "dbx-driver-redis/test-support",
"dbx-driver-support/test-support",
"dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql-data/openapi",
] : appFeatures;
const packages = group === "workspace" ? ["--workspace"] : rustGroups[group].flatMap((name) => ["--package", name]);
const command = action === "test" ? ["nextest", "run", "--no-fail-fast"] : action === "doctest" ? ["test", "--doc"] : [action];
+33
View File
@@ -0,0 +1,33 @@
class DbxMcp < Formula
desc "Native MCP server for DBX database connections, schema, and safe queries"
homepage "https://github.com/t8y2/dbx"
version "__VERSION__"
license "Apache-2.0"
on_macos do
if Hardware::CPU.intel?
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-darwin-x64.tar.gz"
sha256 "__SHA_DARWIN_X64__"
else
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-darwin-arm64.tar.gz"
sha256 "__SHA_DARWIN_ARM64__"
end
end
on_linux do
if Hardware::CPU.intel?
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-linux-x64-gnu.tar.gz"
sha256 "__SHA_LINUX_X64__"
else
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-linux-arm64-gnu.tar.gz"
sha256 "__SHA_LINUX_ARM64__"
end
end
def install
bin.install "dbx-mcp"
end
test do
assert_match version.to_s, shell_output("#{bin}/dbx-mcp --version")
end
end
@@ -0,0 +1,114 @@
$ErrorActionPreference = 'Stop'
$source = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../../docs/public/install-mcp.ps1') -Raw
. ([scriptblock]::Create(($source -replace '(?m)^Install-DbxMcp\s*$', '')))
$originalHome = $HOME
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('dbx-mcp-ps-test-' + [Guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($testRoot) | Out-Null
$realTar = (Get-Command tar).Source
$script:count = 0
function Assert-True($Condition, [string]$Message) { if (!$Condition) { throw $Message } }
function Get-McpPlatform { return $script:platform }
function Set-InstallPath([string]$Directory) { $script:configuredPath = $Directory }
function tar.exe { & $realTar @args }
function Get-Download([string]$Url, [string]$Destination) {
$script:requests += $Url
if ($script:mode -eq 'offline') { return $false }
if ($script:mode -in 'mirror', 'github' -and $Url.Contains('registry.npmjs.org')) { return $false }
if ($script:mode -eq 'github' -and $Url.Contains('registry.npmmirror.com')) { return $false }
$file = switch -Regex ($Url) {
'/latest$' { 'latest.json'; break }
'/matching-refs/' { 'refs.json'; break }
'/0\.4\.96$' { 'metadata.json'; break }
'\.tgz$' { 'npm.tgz'; break }
'/SHA256SUMS$' { 'SHA256SUMS'; break }
'\.zip$' { 'github.zip'; break }
default { throw "Unexpected URL: $Url" }
}
Copy-Item -LiteralPath (Join-Path $script:fixture $file) -Destination $Destination -Force
return $true
}
function New-Fixture([string]$Platform, [string]$Mode) {
$script:platform = $Platform
$script:mode = $Mode
$script:requests = @()
$script:fixture = Join-Path $testRoot ([Guid]::NewGuid().ToString('N'))
$homePath = Join-Path $script:fixture "DBX User's home"
[IO.Directory]::CreateDirectory($homePath) | Out-Null
Set-Variable HOME -Scope Global -Force -Value $homePath
$packageBin = Join-Path $script:fixture 'source/package/bin'
[IO.Directory]::CreateDirectory($packageBin) | Out-Null
[IO.File]::WriteAllText((Join-Path $packageBin 'dbx-mcp.exe'), 'fixture binary')
& $realTar -czf (Join-Path $script:fixture 'npm.tgz') -C (Join-Path $script:fixture 'source') 'package/bin/dbx-mcp.exe'
if ($LASTEXITCODE) { throw 'Fixture tar failed' }
Add-Type -AssemblyName System.IO.Compression.FileSystem
[IO.Compression.ZipFile]::CreateFromDirectory($packageBin, (Join-Path $script:fixture 'github.zip'))
$stream = [IO.File]::OpenRead((Join-Path $script:fixture 'npm.tgz'))
$hasher = [Security.Cryptography.SHA512]::Create()
try { $integrity = 'sha512-' + [Convert]::ToBase64String($hasher.ComputeHash($stream)) }
finally { $stream.Dispose(); $hasher.Dispose() }
@{ version = '0.4.96' } | ConvertTo-Json | Set-Content (Join-Path $script:fixture 'latest.json')
@{ version = '0.4.96'; dist = @{ integrity = $integrity } } | ConvertTo-Json | Set-Content (Join-Path $script:fixture 'metadata.json')
@(@{ ref = 'refs/tags/packages-v0.4.9' }, @{ ref = 'refs/tags/packages-v0.4.96' }, @{ ref = 'refs/tags/packages-v9.0.0-beta' }) | ConvertTo-Json | Set-Content (Join-Path $script:fixture 'refs.json')
$hash = (Get-FileHash (Join-Path $script:fixture 'github.zip') -Algorithm SHA256).Hash
"$hash dbx-mcp-$Platform.zip" | Set-Content (Join-Path $script:fixture 'SHA256SUMS')
$script:binary = Join-Path $HOME '.dbx/bin/dbx-mcp.exe'
$script:marker = Join-Path $HOME '.dbx/bin/.dbx-mcp-version'
}
function Expect-Failure([string]$Pattern) {
try { Install-DbxMcp | Out-Null } catch {
Assert-True ($_.Exception.Message -match $Pattern) "Unexpected error: $_"
return
}
throw 'Expected installation to fail'
}
try {
foreach ($platformName in @('win32-arm64', 'win32-x64')) {
foreach ($sourceMode in @('npm', 'mirror', 'github')) {
New-Fixture $platformName $sourceMode
$output = Install-DbxMcp | Out-String
Assert-True ((Get-Content -LiteralPath $binary -Raw) -eq 'fixture binary') 'First install binary differs'
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.96') 'Marker differs'
Assert-True ($output.Contains((ConvertTo-Json -InputObject $binary -Compress))) 'Config is not the escaped absolute path'
Assert-True ($configuredPath -eq (Split-Path $binary)) 'PATH target differs'
Assert-True (!(Get-ChildItem -LiteralPath (Split-Path $binary) -Directory -Force)) 'Temporary install directory leaked'
$rerun = Install-DbxMcp | Out-String
Assert-True ($rerun.Contains('dbx-mcp 0.4.96 already up to date')) 'Rerun not idempotent'
'0.4.95' | Set-Content -LiteralPath $marker
$updated = Install-DbxMcp | Out-String
Assert-True ($updated.Contains('Installed dbx-mcp 0.4.96')) 'Marker-based upgrade failed'
Remove-Item -LiteralPath $binary
$repaired = Install-DbxMcp | Out-String
Assert-True ($repaired.Contains('Installed dbx-mcp 0.4.96')) 'Orphan marker prevented installation'
$script:count++
Write-Output "PASS $platformName $sourceMode install/rerun/update/configs"
}
}
foreach ($sourceMode in @('npm', 'github', 'offline')) {
New-Fixture 'win32-x64' 'npm'
Install-DbxMcp | Out-Null
'0.4.95' | Set-Content -LiteralPath $marker
$script:mode = $sourceMode
if ($sourceMode -eq 'npm') { 'corrupted' | Set-Content (Join-Path $fixture 'npm.tgz') }
if ($sourceMode -eq 'github') { 'corrupted' | Set-Content (Join-Path $fixture 'github.zip') }
Expect-Failure $(if ($sourceMode -eq 'offline') { 'Unable to resolve' } else { 'verification failed' })
Assert-True ((Get-Content -LiteralPath $binary -Raw) -eq 'fixture binary') 'Failure replaced binary'
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.95') 'Failure replaced marker'
$script:count++
Write-Output "PASS $sourceMode failure preserves installation"
}
New-Fixture 'win32-x64' 'npm'
Install-DbxMcp | Out-Null
'0.4.97' | Set-Content -LiteralPath $marker
$script:mode = 'mirror'
$newer = Install-DbxMcp | Out-String
Assert-True ($newer.Contains('keeping current installation')) 'Older mirror downgraded installation'
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.97') 'Downgrade changed marker'
$script:count++
Write-Output 'PASS older mirror cannot downgrade installation'
Write-Output "PowerShell installer scenarios passed: $script:count"
} finally {
Set-Variable HOME -Scope Global -Force -Value $originalHome
Remove-Item -LiteralPath $testRoot -Recurse -Force
}
+201
View File
@@ -0,0 +1,201 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
const installer = resolve("docs/public/install-mcp.sh");
const platforms = ["darwin-arm64", "darwin-x64", "linux-arm64-gnu", "linux-x64-gnu"];
function fixture(context, platform = "darwin-arm64") {
const root = realpathSync(mkdtempSync(join(tmpdir(), "dbx-mcp-test-")));
context.after(() => rmSync(root, { recursive: true, force: true }));
const home = join(root, "DBX User's 中文");
const mocks = join(root, "mocks");
const source = join(root, "source");
mkdirSync(home);
mkdirSync(mocks);
mkdirSync(join(source, "package/bin"), { recursive: true });
const binary = join(source, "package/bin/dbx-mcp");
writeFileSync(binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\n", { mode: 0o755 });
copyFileSync(binary, join(source, "dbx-mcp"));
for (const [archive, member] of [["npm.tgz", "package/bin/dbx-mcp"], ["github.tar.gz", "dbx-mcp"]]) {
const result = spawnSync("tar", ["-czf", join(root, archive), "-C", source, member]);
assert.equal(result.status, 0, result.stderr.toString());
}
const integrity = "sha512-" + createHash("sha512").update(readFileSync(join(root, "npm.tgz"))).digest("base64");
const checksum = createHash("sha256").update(readFileSync(join(root, "github.tar.gz"))).digest("hex");
writeFileSync(join(root, "latest.json"), JSON.stringify({ description: 'ignore "version":"99.0.0"', version: "0.4.96" }));
writeFileSync(join(root, "metadata.json"), JSON.stringify({ name: "test", version: "0.4.96", dist: { integrity } }));
writeFileSync(join(root, "refs.json"), JSON.stringify([
{ ref: "refs/tags/packages-v0.4.9", object: {} },
{ ref: "refs/tags/packages-v0.4.96", object: {} },
{ ref: "refs/tags/packages-v1.0.0-beta.1", object: {} },
{ ref: "refs/tags/packages-v0.4.10", object: {} },
]));
writeFileSync(join(root, "SHA256SUMS"), `${checksum} dbx-mcp-${platform}.tar.gz\n`);
const routes = {};
for (const registry of ["https://registry.npmjs.org", "https://registry.npmmirror.com"]) {
routes[`${registry}/@dbx-app/mcp-server/latest`] = "latest.json";
routes[`${registry}/@dbx-app/mcp-${platform}/0.4.96`] = "metadata.json";
routes[`${registry}/@dbx-app/mcp-${platform}/-/mcp-${platform}-0.4.96.tgz`] = "npm.tgz";
}
routes["https://api.github.com/repos/t8y2/dbx/git/matching-refs/tags/packages-v"] = "refs.json";
routes["https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/SHA256SUMS"] = "SHA256SUMS";
routes[`https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/dbx-mcp-${platform}.tar.gz`] = "github.tar.gz";
writeFileSync(join(root, "routes.json"), JSON.stringify(routes));
writeFileSync(join(mocks, "curl"), `#!${process.execPath}
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
const url = args.find(arg => arg.startsWith('https://'));
const root = process.env.FIXTURE;
fs.appendFileSync(path.join(root, 'requests.log'), url + '\\n');
if (process.env.FAIL_URL && new RegExp(process.env.FAIL_URL).test(url)) process.exit(22);
const routes = JSON.parse(fs.readFileSync(path.join(root, 'routes.json')));
if (!routes[url]) process.exit(22);
fs.copyFileSync(path.join(root, routes[url]), args[args.indexOf('-o') + 1]);
`, { mode: 0o755 });
writeFileSync(join(mocks, "uname"), `#!/bin/sh\ncase "$1" in -s) echo '${platform.startsWith("darwin") ? "Darwin" : "Linux"}';; -m) echo '${platform.includes("arm64") ? "aarch64" : "x86_64"}';; esac\n`, { mode: 0o755 });
const installedBinary = join(home, ".dbx/bin/dbx-mcp");
const marker = join(home, ".dbx/bin/.dbx-mcp-version");
const run = (extra = {}) => spawnSync("sh", [installer], {
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, ...extra },
encoding: "utf8", timeout: 30_000,
});
return { root, home, mocks, binary: installedBinary, marker, run };
}
for (const platform of platforms) {
test(`native install and absolute configs: ${platform}`, (context) => {
const setup = fixture(context, platform);
const result = setup.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(spawnSync(setup.binary, ["--version"], { encoding: "utf8" }).stdout.trim(), "dbx-mcp 0.4.96");
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n");
const json = JSON.parse(result.stdout.split("\n").find((line) => line.startsWith('{"mcpServers"')));
assert.equal(json.mcpServers.dbx.command, setup.binary);
const tomlCommand = result.stdout.split("\n").find((line) => line.startsWith("command = ")).slice(10);
assert.equal(JSON.parse(tomlCommand), setup.binary);
assert.deepEqual(readdirSync(join(setup.home, ".dbx/bin")).sort(), [".dbx-mcp-version", "dbx-mcp"]);
});
}
test("rerun is idempotent, binary version precedes marker, legacy marker enables update", (context) => {
const setup = fixture(context);
assert.equal(setup.run().status, 0);
writeFileSync(setup.marker, "0.4.95\n");
const unchanged = setup.run();
assert.equal(unchanged.status, 0, unchanged.stderr);
assert.match(unchanged.stdout, /dbx-mcp 0.4.96 already up to date/);
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.95\n");
writeFileSync(setup.binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\nexit 1\n");
const updated = setup.run();
assert.equal(updated.status, 0, updated.stderr);
assert.match(updated.stdout, /Installed dbx-mcp 0.4.96/);
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n");
writeFileSync(setup.binary, "#!/bin/sh\nexit 1\n");
assert.match(setup.run().stdout, /already up to date/);
assert.equal(readFileSync(join(setup.home, ".zshrc"), "utf8").match(/# added by dbx installer/g).length, 1);
});
for (const [name, failUrl, expected] of [
["npmmirror", "registry\\.npmjs\\.org", "registry.npmmirror.com"],
["GitHub", "registry\\.", "github.com/t8y2/dbx/releases/download/packages-v0.4.96"],
]) {
test(`falls back to ${name} for metadata and archive`, (context) => {
const setup = fixture(context);
const result = setup.run({ FAIL_URL: failUrl });
assert.equal(result.status, 0, result.stderr);
assert.ok(readFileSync(join(setup.root, "requests.log"), "utf8").includes(expected));
});
}
test("download fallback retains the version selected by npmjs", (context) => {
const setup = fixture(context);
const result = setup.run({ FAIL_URL: "registry\\.npmjs\\.org/.*mcp-darwin" });
assert.equal(result.status, 0, result.stderr);
assert.match(readFileSync(join(setup.root, "requests.log"), "utf8"), /npmmirror.com\/.*0.4.96.tgz/);
});
test("an older mirror version cannot downgrade an existing native installation", (context) => {
const setup = fixture(context);
assert.equal(setup.run().status, 0);
const newer = "#!/bin/sh\nprintf 'dbx-mcp 0.4.97\\n'\n";
writeFileSync(setup.binary, newer);
writeFileSync(setup.marker, "0.4.97\n");
const result = setup.run({ FAIL_URL: "registry\\.npmjs\\.org" });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /keeping current installation/);
assert.equal(readFileSync(setup.binary, "utf8"), newer);
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.97\n");
});
test("offline exits with one concise error and preserves the existing installation", (context) => {
const setup = fixture(context);
assert.equal(setup.run().status, 0);
const original = readFileSync(setup.binary);
const result = setup.run({ FAIL_URL: ".*" });
assert.notEqual(result.status, 0);
assert.equal(result.stdout, "");
assert.equal(result.stderr.trim().split("\n").length, 1);
assert.match(result.stderr, /existing installation unchanged/);
assert.deepEqual(readFileSync(setup.binary), original);
});
for (const source of ["npm", "github"]) {
test(`rejects ${source} checksum mismatch without replacing anything`, (context) => {
const setup = fixture(context);
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
writeFileSync(setup.binary, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
writeFileSync(setup.marker, "0.4.95\n");
const original = readFileSync(setup.binary);
writeFileSync(join(setup.root, source === "npm" ? "npm.tgz" : "github.tar.gz"), "corrupted");
const result = setup.run(source === "github" ? { FAIL_URL: "registry\\." } : {});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /verification failed/);
assert.deepEqual(readFileSync(setup.binary), original);
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.95\n");
assert.ok(!existsSync(join(setup.home, ".zshrc")));
});
}
test("stale marker without binary reinstalls, and bash users get migration guidance", (context) => {
const setup = fixture(context);
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
writeFileSync(setup.marker, "0.4.96\n");
writeFileSync(join(setup.mocks, "dbx-mcp-server"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
const result = setup.run({ SHELL: "/bin/bash" });
assert.equal(result.status, 0, result.stderr);
assert.ok(existsSync(setup.binary));
assert.ok(existsSync(join(setup.home, ".bash_profile")));
assert.match(result.stdout, /npm rm -g @dbx-app\/mcp-server/);
});
test("formula generator validates all hashes and both publishing workflows invoke it", (context) => {
const setup = fixture(context);
const sums = join(setup.root, "formula-sums");
const output = join(setup.root, "dbx-mcp.rb");
writeFileSync(sums, platforms.map((platform) => `${"a".repeat(64)} dbx-mcp-${platform}.tar.gz`).join("\n"));
const generate = () => spawnSync(process.execPath, [".github/scripts/render-mcp-formula.mjs", "0.4.96", sums, output], { encoding: "utf8" });
assert.equal(generate().status, 0);
const formula = readFileSync(output, "utf8");
assert.match(formula, /bin.install "dbx-mcp"/);
assert.doesNotMatch(formula, /__[A-Z_]+__/);
assert.equal((formula.match(/sha256 /g) ?? []).length, 4);
writeFileSync(sums, "invalid");
assert.notEqual(generate().status, 0);
assert.equal(readFileSync(output, "utf8"), formula);
for (const workflow of ["mcp-release", "publish-packages"]) {
const text = readFileSync(`.github/workflows/${workflow}.yml`, "utf8");
const checkoutIndex = text.indexOf("actions/checkout@");
const renderIndex = text.indexOf("render-mcp-formula.mjs");
assert.notEqual(checkoutIndex, -1);
assert.notEqual(renderIndex, -1);
assert.ok(checkoutIndex < renderIndex);
assert.match(text, /render-mcp-formula.mjs/);
assert.match(text, /git add .*Formula\/dbx-mcp.rb/);
}
});
+1 -1
View File
@@ -30,7 +30,7 @@ export const STATIC_LABEL_SPECS = {
"tests-only": { color: "bfd4f2", description: "Changes only tests, fixtures, or snapshots" },
};
const CORE_CRATE_PATH = /^crates\/dbx-(?:core|drivers|sql|formats|types|platform|ai-provider|plugin-runtime)\//;
const CORE_CRATE_PATH = /^crates\/dbx-(?:core|driver(?:s|-[^/]+)|sql(?:-[^/]+)?|formats|types|platform|ai-provider|plugin-runtime)\//;
const TYPE_LABELS = new Set(["bug", "enhancement", "documentation", "maintenance"]);
const LABEL_PALETTE = [
+3 -3
View File
@@ -108,8 +108,8 @@ test("recognizes extracted core crates and their database sources", () => {
const result = evaluatePullRequestLabels({
title: "fix(sql): preserve PostgreSQL query contracts",
changedFiles: [
"crates/dbx-drivers/src/db/postgres.rs",
"crates/dbx-sql/src/sql.rs",
"crates/dbx-driver-postgres/src/postgres.rs",
"crates/dbx-sql-core/src/sql.rs",
"crates/dbx-types/src/types.rs",
"crates/dbx-platform/src/process.rs",
"crates/dbx-ai-provider/src/ai.rs",
@@ -129,7 +129,7 @@ test("collapses broad area and database changes", () => {
changedFiles: [
"agents/drivers/mysql/build.gradle",
"apps/desktop/src/components/grid/GeometryViewer.vue",
"crates/dbx-drivers/src/db/postgres.rs",
"crates/dbx-driver-postgres/src/postgres.rs",
"crates/dbx-mcp/src/main.rs",
"crates/dbx-web/src/main.rs",
"docs/content/docs/geometry.mdx",
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
upstream_plugin="$script_dir/linuxdeploy-plugin-gtk-upstream.sh"
[[ -x "$upstream_plugin" ]] || {
echo "Missing executable upstream GTK plugin: $upstream_plugin" >&2
exit 1
}
# linuxdeploy queries this before invoking the plugin. Preserve the upstream
# protocol response without requiring an AppDir.
for argument in "$@"; do
case "$argument" in
--plugin-api-version | --help)
exec "$upstream_plugin" "$@"
;;
esac
done
appdir=''
arguments=("$@")
for ((index = 0; index < ${#arguments[@]}; index += 1)); do
case "${arguments[index]}" in
--appdir)
index=$((index + 1))
appdir=${arguments[index]:-}
;;
--appdir=*)
appdir=${arguments[index]#--appdir=}
;;
esac
done
[[ -n "$appdir" ]] || {
echo "GTK plugin wrapper did not receive --appdir" >&2
exit 1
}
"$upstream_plugin" "$@"
# AppImages must use the target system's display stack. Bundling Ubuntu's
# Wayland/XKB/XCB libraries ahead of a newer host Mesa stack can abort before
# GTK creates a window (tauri-apps/tauri#15976).
display_library_patterns=(
'libwayland-*.so*'
'libxkbcommon.so*'
'libxcb-randr.so*'
'libxcb-render.so*'
'libxcb-shm.so*'
'libXau.so*'
'libXdmcp.so*'
)
for pattern in "${display_library_patterns[@]}"; do
find "$appdir/usr" \( -type f -o -type l \) -name "$pattern" -print -delete
done
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
die() {
echo "AppImage bundler setup failed: $*" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
die "usage: $0 <Linux target triple>"
fi
: "${XDG_CACHE_HOME:?XDG_CACHE_HOME must point to the isolated release cache}"
target=$1
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
case "$target" in
x86_64-unknown-linux-gnu)
tools_arch=x86_64
linuxdeploy_sha256=36a2d7e274d12e1050d0e9ecfe11d339ed54720b2bec464c286d53f8b07f5c62
;;
aarch64-unknown-linux-gnu)
tools_arch=aarch64
linuxdeploy_sha256=556ab80baa98e600aa80f0dcedfb70bca0e1ce7e9f147fb345be3fcc3e91b2b1
;;
*)
die "unsupported Linux target: $target"
;;
esac
# Tauri CLI 2.11.4 only downloads these tools when their cache entries are
# absent, so an older runner cache can keep generating an AppRun hook that
# unconditionally forces X11. Pin the tools from the accepted upstream fix
# until a stable Tauri CLI release includes tauri-apps/tauri#16062.
tauri_fix_commit=8e7028331ad37ac2db74d4ec20e66be5cacf2c40
linuxdeploy_revision=07333c6
gtk_plugin_sha256=ef6b9a980417243bc62e0241b51dc49876032afd1bab9b4762389f961b406d9b
tools_dir="$XDG_CACHE_HOME/tauri"
mkdir -p "$tools_dir"
download_path=''
cleanup() {
if [[ -n "$download_path" ]]; then
rm -f -- "$download_path"
fi
}
trap cleanup EXIT
install_verified_tool() {
local name=$1
local url=$2
local expected_sha256=$3
local destination="$tools_dir/$name"
local actual_sha256
if [[ -f "$destination" ]]; then
actual_sha256=$(sha256sum "$destination")
actual_sha256=${actual_sha256%% *}
if [[ "$actual_sha256" == "$expected_sha256" ]]; then
chmod 0755 "$destination"
echo "Using verified cached AppImage tool: $name"
return
fi
fi
download_path=$(mktemp "$tools_dir/.${name}.XXXXXX")
curl --fail --location --retry 3 --retry-all-errors --silent --show-error \
"$url" --output "$download_path"
actual_sha256=$(sha256sum "$download_path")
actual_sha256=${actual_sha256%% *}
[[ "$actual_sha256" == "$expected_sha256" ]] \
|| die "$name SHA-256 mismatch: expected $expected_sha256, got $actual_sha256"
chmod 0755 "$download_path"
mv -f -- "$download_path" "$destination"
download_path=''
echo "Installed verified AppImage tool: $name"
}
linuxdeploy_name="linuxdeploy-${tools_arch}.AppImage"
install_verified_tool \
"$linuxdeploy_name" \
"https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy-${linuxdeploy_revision}/${linuxdeploy_name}" \
"$linuxdeploy_sha256"
gtk_plugin_name=linuxdeploy-plugin-gtk-upstream.sh
install_verified_tool \
"$gtk_plugin_name" \
"https://raw.githubusercontent.com/tauri-apps/tauri/${tauri_fix_commit}/crates/tauri-bundler/src/bundle/linux/appimage/linuxdeploy-plugin-gtk.sh" \
"$gtk_plugin_sha256"
upstream_gtk_plugin="$tools_dir/$gtk_plugin_name"
gtk_plugin="$tools_dir/linuxdeploy-plugin-gtk.sh"
install -m 0755 "$script_dir/linuxdeploy-plugin-gtk-wrapper.sh" "$gtk_plugin"
bash -n "$upstream_gtk_plugin" "$gtk_plugin"
if grep -Eq '^[[:space:]]*(export[[:space:]]+)?GDK_BACKEND[[:space:]]*=' "$upstream_gtk_plugin"; then
die "pinned GTK plugin still contains an active GDK_BACKEND assignment"
fi
echo "Prepared Wayland-capable AppImage tools for $target in $tools_dir"
+21
View File
@@ -0,0 +1,21 @@
import { readFileSync, writeFileSync } from "node:fs";
const [version, sumsPath, outputPath] = process.argv.slice(2);
if (!/^\d+\.\d+\.\d+$/.test(version ?? "") || !sumsPath || !outputPath) {
throw new Error("Usage: render-mcp-formula.mjs <version> <SHA256SUMS> <output.rb>");
}
const sums = readFileSync(sumsPath, "utf8").trim().split(/\r?\n/);
let formula = readFileSync(new URL("./dbx-mcp.rb.template", import.meta.url), "utf8").replaceAll("__VERSION__", version);
for (const [platform, placeholder] of [
["darwin-arm64", "__SHA_DARWIN_ARM64__"],
["darwin-x64", "__SHA_DARWIN_X64__"],
["linux-arm64-gnu", "__SHA_LINUX_ARM64__"],
["linux-x64-gnu", "__SHA_LINUX_X64__"],
]) {
const matches = sums.map((line) => line.trim().split(/\s+/)).filter(([, name]) => name === `dbx-mcp-${platform}.tar.gz`);
if (matches.length !== 1 || !/^[a-fA-F0-9]{64}$/.test(matches[0][0])) {
throw new Error(`Missing, duplicate, or invalid SHA256 for ${platform}`);
}
formula = formula.replaceAll(placeholder, matches[0][0].toLowerCase());
}
writeFileSync(outputPath, formula);
+41
View File
@@ -0,0 +1,41 @@
@echo off
setlocal EnableDelayedExpansion
:: 脚本所在目录(等价于 ROOT)
set ROOT=%~dp0
:: 去掉末尾反斜杠
set ROOT=%ROOT:~0,-1%
set DBX_PACKAGE_ROOT=%ROOT%
:: static dir
if "%DBX_STATIC_DIR%"=="" (
:: dist静态资源文件已直接嵌入到二进制文件中,环境变量中没有设置的话,直接用二进制中的静态文件
rem set DBX_STATIC_DIR=%ROOT%\dist
)
:: data dir
if "%DBX_DATA_DIR%"=="" (
set DBX_DATA_DIR=%ROOT%\data
)
:: port
if "%DBX_PORT%"=="" (
set DBX_PORT=4224
)
:: base path
set BASE_PATH=%DBX_PUBLIC_BASE_PATH%
if "%BASE_PATH%"=="" set BASE_PATH=/
:: 如果不是以 / 开头,补上 /
echo %BASE_PATH% | findstr /r "^/" >nul || (
set BASE_PATH=/!BASE_PATH!
)
echo DBX_DATA_DIR=%DBX_DATA_DIR%
echo DBX browser UI: http://127.0.0.1:%DBX_PORT%%BASE_PATH%
cd /d "%ROOT%"
:: 执行程序
dbx-web.exe %*
@@ -2,7 +2,7 @@
set -euo pipefail
die() {
echo "AppImage input-method verification failed: $*" >&2
echo "AppImage runtime verification failed: $*" >&2
exit 1
}
@@ -37,6 +37,38 @@ appdir="$work_dir/squashfs-root"
[[ -d "$appdir" ]] || die "AppImage extraction did not create squashfs-root"
appdir=$(realpath "$appdir")
gtk_hook="$appdir/apprun-hooks/linuxdeploy-plugin-gtk.sh"
[[ -f "$gtk_hook" ]] || die "missing GTK AppRun hook"
if grep -Eq '^[[:space:]]*(export[[:space:]]+)?GDK_BACKEND[[:space:]]*=' "$gtk_hook"; then
die "GTK AppRun hook overrides GDK_BACKEND"
fi
for requested_backend in wayland x11; do
preserved_backend=$(
APPDIR="$appdir" GDK_BACKEND="$requested_backend" XDG_DATA_DIRS='' \
bash --noprofile --norc -c 'source "$APPDIR/apprun-hooks/linuxdeploy-plugin-gtk.sh"; printf "%s" "$GDK_BACKEND"'
)
[[ "$preserved_backend" == "$requested_backend" ]] \
|| die "GTK AppRun hook changed explicit GDK_BACKEND=$requested_backend to '$preserved_backend'"
done
echo "Verified GTK AppRun hook preserves explicit Wayland and X11 backends"
display_library_patterns=(
'libwayland-*.so*'
'libxkbcommon.so*'
'libxcb-randr.so*'
'libxcb-render.so*'
'libxcb-shm.so*'
'libXau.so*'
'libXdmcp.so*'
)
for pattern in "${display_library_patterns[@]}"; do
bundled_library=$(find "$appdir/usr" \( -type f -o -type l \) -name "$pattern" -print -quit)
[[ -z "$bundled_library" ]] \
|| die "bundled display-stack library must come from the host: ${bundled_library#"$appdir"/}"
done
echo "Verified AppImage relies on the host Wayland/XKB/XCB display stack"
require_artifact() {
local basename=$1
local match
+11 -2
View File
@@ -160,6 +160,10 @@ jobs:
- name: GitHub script tests
run: node --test .github/scripts/*.test.mjs
- name: PowerShell MCP installer tests
shell: pwsh
run: ./.github/scripts/install-mcp-powershell.test.ps1
packages:
needs: changes
if: needs.changes.outputs.packages == 'true'
@@ -956,7 +960,8 @@ jobs:
- 'scripts/run-check.mjs'
- 'crates/dbx-core/src/**'
- 'crates/dbx-drivers/src/**'
- 'crates/dbx-sql/src/**'
- 'crates/dbx-driver-*/src/**'
- 'crates/dbx-sql*/src/**'
- 'crates/dbx-formats/src/**'
- 'crates/dbx-types/src/**'
- 'crates/dbx-plugin-runtime/src/**'
@@ -998,7 +1003,7 @@ jobs:
- '.github/workflows/ci.yml'
agents:
- 'agents/**'
- 'crates/dbx-drivers/assets/agent-protocol-v2.json'
- 'crates/dbx-driver-agent/assets/agent-protocol-v2.json'
- 'crates/dbx-core/Cargo.toml'
- 'Cargo.toml'
- 'Cargo.lock'
@@ -1041,6 +1046,7 @@ jobs:
- 'src-tauri/src/commands/update.rs'
- 'crates/dbx-core/Cargo.toml'
- 'crates/dbx-drivers/**'
- 'crates/dbx-driver-*/**'
- 'crates/dbx-platform/**'
- 'crates/dbx-core/src/host/update.rs'
- 'Cargo.toml'
@@ -1052,6 +1058,9 @@ jobs:
- 'vendor/wry/**'
github_scripts:
- '.github/scripts/**'
- 'docs/public/install-mcp*'
- '.github/workflows/mcp-release.yml'
- '.github/workflows/publish-packages.yml'
- '.github/workflows/plugin-release-reusable.yml'
- '.github/workflows/ci.yml'
- 'apps/desktop/src/types/database.ts'
+17 -3
View File
@@ -931,8 +931,19 @@ jobs:
publish-homebrew-formula:
name: Publish Homebrew formula
runs-on: ubuntu-latest
needs: [prepare, publish-cli-github-release]
needs: [prepare, publish-cli-github-release, publish-mcp-github-release]
steps:
- uses: actions/checkout@v5
- name: Download MCP release checksums
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
mkdir -p release-assets
gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" \
--pattern SHA256SUMS --dir release-assets --clobber
- name: Download native CLI archives and compute SHA256
id: cli-hash
env:
@@ -1045,14 +1056,17 @@ jobs:
sed -i "s/__SHA_LINUX_ARM64__/${SHA_LINUX_ARM64}/g" Formula/dbx-cli.rb
sed -i "s/__SHA_LINUX_X64__/${SHA_LINUX_X64}/g" Formula/dbx-cli.rb
node "$GITHUB_WORKSPACE/.github/scripts/render-mcp-formula.mjs" \
"${CLI_VERSION}" "$GITHUB_WORKSPACE/release-assets/SHA256SUMS" Formula/dbx-mcp.rb
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/dbx-cli.rb
git add Formula/dbx-cli.rb Formula/dbx-mcp.rb
if git diff --cached --quiet; then
echo "CLI Homebrew formula is already up to date."
else
git commit -m "dbx-cli ${CLI_VERSION}"
git commit -m "dbx-cli and dbx-mcp ${CLI_VERSION}"
git push
echo "::notice::CLI Homebrew formula updated to ${CLI_VERSION}"
fi
+14 -1
View File
@@ -25,6 +25,9 @@ jobs:
name: Publish to Homebrew & Scoop
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Extract version from tag
id: version
run: |
@@ -91,11 +94,17 @@ jobs:
- name: Publish Homebrew Cask
env:
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ steps.version.outputs.version }}"
SHA_ARM64="${{ steps.hashes.outputs.sha_dmg_arm64 }}"
SHA_X64="${{ steps.hashes.outputs.sha_dmg_x64 }}"
MCP_VERSION="$(curl -fsSL https://registry.npmjs.org/@dbx-app/mcp-server/latest | jq -er '.version')"
[[ "$MCP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1
gh release download "packages-v${MCP_VERSION}" --repo "${GITHUB_REPOSITORY}" \
--pattern SHA256SUMS --dir artifacts --clobber
git clone --depth 1 \
"https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/${REPO_OWNER}/homebrew-tap.git" \
homebrew-tap
@@ -137,9 +146,13 @@ jobs:
sed -i "s/__SHA_ARM64__/${SHA_ARM64}/g" Casks/dbx.rb
sed -i "s/__SHA_X64__/${SHA_X64}/g" Casks/dbx.rb
mkdir -p Formula
node "$GITHUB_WORKSPACE/.github/scripts/render-mcp-formula.mjs" \
"${MCP_VERSION}" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" Formula/dbx-mcp.rb
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/dbx.rb
git add Casks/dbx.rb Formula/dbx-mcp.rb
if git diff --cached --quiet; then
echo "Homebrew Cask is already up to date."
+99 -3
View File
@@ -144,6 +144,9 @@ jobs:
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
# The GTK plugin wrapper removes these after its own deployment pass,
# while linuxdeploy must also exclude them from its later dependency pass.
LINUXDEPLOY_EXCLUDED_LIBRARIES: "libwayland-*.so*;libxkbcommon.so*;libxcb-randr.so*;libxcb-render.so*;libxcb-shm.so*;libXau.so*;libXdmcp.so*"
# Keep releases working until the shared S3 secrets are configured.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
@@ -256,6 +259,15 @@ jobs:
# instead of loading potentially incompatible modules from the host.
sudo apt-get install -y fcitx5-frontend-gtk3 ibus-gtk3 libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils
- name: Prepare Wayland-capable AppImage bundler
if: startsWith(matrix.platform, 'ubuntu')
shell: bash
env:
XDG_CACHE_HOME: ${{ runner.temp }}/dbx-tauri-cache
run: |
echo "XDG_CACHE_HOME=${XDG_CACHE_HOME}" >> "$GITHUB_ENV"
./.github/scripts/prepare-appimage-bundler.sh "${{ matrix.target }}"
- name: Setup Tauri signing key
shell: bash
run: |
@@ -281,7 +293,7 @@ jobs:
assetNamePattern: ${{ matrix.asset_pattern }}
args: --target ${{ matrix.target }}
- name: Verify AppImage input methods
- name: Verify AppImage runtime bundle
if: startsWith(matrix.platform, 'ubuntu')
shell: bash
run: ./.github/scripts/verify-appimage-input-methods.sh "target/${{ matrix.target }}/release/bundle/appimage"
@@ -703,6 +715,89 @@ jobs:
Remove-Item -LiteralPath $workDir -Recurse -Force
}
build-windows-7-static-browser:
needs: release-ready
runs-on: windows-2022
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-win7-windows-msvc
arch: x86_64
env:
CARGO_INCREMENTAL: "0"
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
steps:
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Install frontend dependencies
run: pnpm install
- name: Build frontend
run: pnpm build
- name: Setup Rust for Windows 7
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-07-22
components: rust-src
- name: Install NASM
run: choco install nasm -y --no-progress
- name: Add NASM to PATH
shell: pwsh
run: Add-Content $env:GITHUB_PATH "C:\Program Files\NASM"
- name: Build DBX for Windows 7 ${{ matrix.arch }}
shell: pwsh
run: |
cargo build --locked --package dbx-web --release --no-default-features --features embed-static,duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled --target ${{ matrix.target }} -Z build-std=std,panic_abort
- name: Audit Windows 7 PE imports
shell: pwsh
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/${{ matrix.target }}/release/dbx-web.exe
- name: Upload Windows portable ${{ matrix.arch }} ZIP
timeout-minutes: 20
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
$arch = "${{ matrix.arch }}"
$portableRoot = "portable"
$portableDir = Join-Path $portableRoot "DBX_${version}_${arch}"
$zipName = "dbx-web_${version}_${arch}-browser-static.zip"
$zipSha256 = "dbx-web_${version}_${arch}-browser-static.zip.sha256"
$exePath = "target/${{ matrix.target }}/release/dbx-web.exe"
if (!(Test-Path $exePath)) {
Write-Error "Missing Windows executable: $exePath"
exit 1
}
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
Copy-Item $exePath (Join-Path $portableDir "dbx-web.exe") -Force
Copy-Item "LICENSE" (Join-Path $portableDir "LICENSE") -Force
Copy-Item "README.md" (Join-Path $portableDir "README.md") -Force
Copy-Item ".github\scripts\start-dbx-web.bat" (Join-Path $portableDir "start.bat") -Force
Compress-Archive -Path (Join-Path $portableDir "*") -DestinationPath $zipName -Force
$zip_sha256 = (Get-FileHash -LiteralPath $zipName -Algorithm SHA256).Hash.ToLowerInvariant()
Set-Content -Path $zipSha256 -Value $zip_sha256 -Encoding utf8NoBOM
gh release upload "${env:GITHUB_REF_NAME}" $zipName $zipSha256 --repo "${env:GITHUB_REPOSITORY}" --clobber
static-browser:
# Fully static musl builds of the browser (dbx-web) variant. No glibc
# dependency, so the tarball runs on any Linux distribution (verified in
@@ -799,7 +894,7 @@ jobs:
RUSTFLAGS: ${{ matrix.rustflags }}
run: |
cargo zigbuild --release -p dbx-web --target ${{ matrix.target }} \
--no-default-features --features "duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled"
--no-default-features --features "embed-static,duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled"
- name: Package static browser runtime
env:
@@ -938,7 +1033,8 @@ jobs:
publish:
# Keep the release as a draft until every release-blocking platform job passes.
# Nix packaging is advisory and runs independently from publication.
# Nix packaging and the Windows 7 static-browser build are advisory and
# upload their release assets independently of publication.
needs: [cleanup-release-signatures, docker-manifest, jdbc-plugin, static-browser]
runs-on: ubuntu-latest
steps:
+17
View File
@@ -44,6 +44,23 @@ make docs # preview the documentation site
make cargo-check-fast # fast Rust checks
```
### macOS Development Signing
Use `make dev`, `make dev-fast`, or `pnpm dev:tauri`. These entry points sign each rebuilt debug executable with a stable, local-only development identity before launching it. The first launch may still ask you to select **Always Allow** for DBX's existing Keychain item; subsequent rebuilds keep the same code identity. Direct `pnpm tauri dev` bypasses this setup.
The first run creates a dedicated signing keychain and a self-signed certificate under `~/Library/Application Support/DBX/development-signing/`. It adds only this keychain to your user search list, without changing the default keychain or system trust settings. The directory is owner-only (`0700`); its files, including the generated password used to unlock this development-only signing keychain, are owner-only (`0600`). No release private key or DBX connection-encryption key is exported or replaced. Keep this local identity across rebuilds; do not commit or share it. An incomplete, corrupt, or expired identity fails explicitly rather than silently rotating or falling back to ad-hoc signing.
The runner is restricted to `debug/dbx`, preserves Cargo feature and application arguments, and does not run for Linux, Windows, or release packaging. Custom `CARGO_TARGET_*_RUNNER` variables must be unset for these macOS development entry points.
Core, desktop and Web storage test fixtures use `dbx_core::persistence::test_storage` (the `test-support` dev-dependency feature). They resolve their own data-directory keys before migration preflight, without accessing the user's Keychain or inheriting `DBX_SECRET_KEY` / `DBX_SECRET_KEY_FILE`. Keep the fixture directory and its key together when testing database copies.
```bash
node --test scripts/dev-tauri.test.mjs
DBX_TEST_MACOS_KEYCHAIN=1 node --test scripts/dev-tauri.test.mjs
```
The opt-in macOS integration test creates and removes a temporary signing keychain. It verifies that an ad-hoc rebuild is denied and that two different builds signed with the same identity can read the same test item with system interaction disabled.
### JDBC Agent Drivers
Agent driver projects live under `agents/`. Java/JDBC driver builds and tests require JDK 21; Gradle can auto-download the toolchain when available.
+30 -11
View File
@@ -42,6 +42,23 @@ make docs # 本地预览文档站
make cargo-check-fast # 快速 Rust 检查
```
### macOS 开发签名
请使用 `make dev`、`make dev-fast` 或 `pnpm dev:tauri`。这些入口会在每次重新编译后、启动前,用固定的本地开发身份签名。首次启动仍可能需要对 DBX 原有钥匙串条目选择一次“始终允许”,之后重编译保持相同代码身份。直接执行 `pnpm tauri dev` 会绕过此流程。
首次运行会在 `~/Library/Application Support/DBX/development-signing/` 创建独立签名钥匙串和自签开发证书,仅将该钥匙串加入当前用户的搜索列表,不更改默认钥匙串或系统信任设置。目录权限为 `0700`,文件权限为 `0600`;其中保存的随机密码只用于解锁这个开发签名钥匙串。不使用发布私钥,也不导出或更换已有连接的加密密钥。请保留这套本地身份,不要提交或分享;配置不完整、损坏或证书过期时会明确报错,不会自动换证书或降级为临时签名。
签名 runner 仅接受 `debug/dbx`,保留 Cargo feature 和应用参数,不影响 Linux、Windows 或正式打包。使用这些 macOS 开发入口时,需要取消自定义的 `CARGO_TARGET_*_RUNNER` 环境变量。
Core、桌面端和 Web 的 Storage 测试夹具使用 `dbx_core::persistence::test_storage`(仅由开发依赖启用 `test-support` feature),在迁移预检查前选择测试目录自己的密钥,不访问用户钥匙串,也不继承 `DBX_SECRET_KEY` / `DBX_SECRET_KEY_FILE`。测试数据库复制时,应将夹具目录及其密钥一起保留。
```bash
node --test scripts/dev-tauri.test.mjs
DBX_TEST_MACOS_KEYCHAIN=1 node --test scripts/dev-tauri.test.mjs
```
第二条为 macOS 集成验证:创建并清理临时签名钥匙串,禁止系统交互,证明临时签名重编译会被拒绝,而采用同一稳定身份的两个不同构建仍能读取同一测试条目。
### JDBC Agent 驱动
Agent 驱动工程在 `agents/` 目录。Java/JDBC 驱动构建和测试需要 JDK 21;环境允许时 Gradle 可以自动下载对应 toolchain。
@@ -57,18 +74,18 @@ cd agents
## 项目结构
| 路径 | 说明 |
| --- | --- |
| `apps/desktop/src/` | Vue 前端 |
| `src-tauri/` | Tauri 桌面端壳层与命令层 |
| `crates/dbx-core/` | 共享 Rust 数据库逻辑 |
| `crates/dbx-web/` | Docker / Web HTTP 后端 |
| `packages/cli/` | `@dbx-app/cli` |
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
| 路径 | 说明 |
| ----------------------- | --------------------------------- |
| `apps/desktop/src/` | Vue 前端 |
| `src-tauri/` | Tauri 桌面端壳层与命令层 |
| `crates/dbx-core/` | 共享 Rust 数据库逻辑 |
| `crates/dbx-web/` | Docker / Web HTTP 后端 |
| `packages/cli/` | `@dbx-app/cli` |
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
| `packages/mongo-shell/` | 桌面端内部 MongoDB 编辑器解析工具 |
| `docs/` | 官方文档站 |
| `examples/` | 配置与自动化示例 |
| `agents/` | JDBC Agent 驱动工程 |
| `docs/` | 官方文档站 |
| `examples/` | 配置与自动化示例 |
| `agents/` | JDBC Agent 驱动工程 |
## 开发约定
@@ -84,6 +101,8 @@ cd agents
一个 PR 只做一类事。文档 PR 不要夹带无关代码;修 Bug 时也不要顺手大重构,除非重构是修复所必需的。
提交前和推送后请按 [PR 提交与 CI 核查](docs/testing/pr-preflight.md) 检查多语言完整性、相关工作流及远端状态。
### 提交说明
提交信息用自然语言写清楚即可:
Generated
+275 -13
View File
@@ -2047,7 +2047,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.6.21"
version = "0.6.25"
dependencies = [
"anyhow",
"axum",
@@ -2128,7 +2128,7 @@ dependencies = [
[[package]]
name = "dbx-cli"
version = "0.4.96"
version = "0.4.98"
dependencies = [
"async-trait",
"dbx-core",
@@ -2158,6 +2158,7 @@ dependencies = [
"crc",
"csv",
"dbx-ai-provider",
"dbx-core",
"dbx-drivers",
"dbx-formats",
"dbx-platform",
@@ -2211,6 +2212,173 @@ dependencies = [
"zstd",
]
[[package]]
name = "dbx-driver-agent"
version = "0.1.0"
dependencies = [
"chrono",
"crc",
"dbx-platform",
"dbx-sql-core",
"dbx-types",
"flate2",
"futures",
"log",
"percent-encoding",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2 0.10.9",
"sysinfo",
"tar",
"tempfile",
"tokio",
"tokio-util",
"uuid",
"zip",
"zstd",
]
[[package]]
name = "dbx-driver-elasticsearch"
version = "0.1.0"
dependencies = [
"dbx-driver-support",
"dbx-types",
"percent-encoding",
"regex",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
"uuid",
]
[[package]]
name = "dbx-driver-mongodb"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dbx-driver-support",
"dbx-types",
"futures",
"json5",
"log",
"mongodb",
"percent-encoding",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"uuid",
]
[[package]]
name = "dbx-driver-mysql"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-driver-support",
"dbx-sql-core",
"dbx-sql-dialect",
"dbx-types",
"futures",
"log",
"mysql_async",
"mysql_common",
"percent-encoding",
"rust_decimal",
"serde_json",
"sqlparser",
"tokio",
"tokio-util",
]
[[package]]
name = "dbx-driver-postgres"
version = "0.1.0"
dependencies = [
"bytes",
"chrono",
"dbx-driver-support",
"dbx-sql-core",
"dbx-types",
"deadpool-postgres",
"futures",
"log",
"openssl",
"percent-encoding",
"portpicker",
"postgres-openssl",
"rustls 0.23.43",
"rustls-pemfile 2.2.0",
"serde_json",
"sqlparser",
"tempfile",
"tokio",
"tokio-openssl",
"tokio-postgres",
"tokio-postgres-rustls",
"tokio-util",
"uuid",
"webpki-roots 0.26.11",
]
[[package]]
name = "dbx-driver-redis"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"dbx-driver-support",
"dbx-types",
"redis",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "dbx-driver-sqlserver"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-driver-support",
"dbx-sql-core",
"dbx-sql-data",
"dbx-types",
"futures",
"log",
"serde",
"serde_json",
"sqlparser",
"tiberius",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "dbx-driver-support"
version = "0.1.0"
dependencies = [
"bytes",
"dbx-platform",
"dbx-types",
"portpicker",
"reqwest 0.12.28",
"russh",
"serde",
"serde_json",
"sqlparser",
"tokio",
"tokio-util",
"uuid",
]
[[package]]
name = "dbx-drivers"
version = "0.1.0"
@@ -2221,8 +2389,18 @@ dependencies = [
"chrono",
"crc",
"csv",
"dbx-driver-agent",
"dbx-driver-elasticsearch",
"dbx-driver-mongodb",
"dbx-driver-mysql",
"dbx-driver-postgres",
"dbx-driver-redis",
"dbx-driver-sqlserver",
"dbx-driver-support",
"dbx-platform",
"dbx-sql",
"dbx-sql-core",
"dbx-sql-data",
"dbx-sql-dialect",
"dbx-sqlite-worker",
"dbx-types",
"deadpool-postgres",
@@ -2231,7 +2409,6 @@ dependencies = [
"json5",
"libsqlite3-hotbundle",
"log",
"mongodb",
"mysql_async",
"mysql_common",
"nix 0.31.3",
@@ -2240,7 +2417,6 @@ dependencies = [
"percent-encoding",
"portpicker",
"postgres-openssl",
"redis",
"regex",
"reqwest 0.12.28",
"rusqlite",
@@ -2284,7 +2460,7 @@ dependencies = [
[[package]]
name = "dbx-mcp"
version = "0.4.96"
version = "0.4.98"
dependencies = [
"async-trait",
"axum",
@@ -2349,14 +2525,69 @@ dependencies = [
[[package]]
name = "dbx-sql"
version = "0.1.0"
dependencies = [
"dbx-sql-core",
"dbx-sql-data",
"dbx-sql-dialect",
"dbx-sql-schema",
"dbx-types",
]
[[package]]
name = "dbx-sql-core"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-types",
"encoding_rs",
"log",
"regex",
"serde",
"serde_json",
"sqlparser",
"unicode-ident",
"winnow 1.0.4",
]
[[package]]
name = "dbx-sql-data"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-sql-core",
"dbx-sql-dialect",
"dbx-types",
"serde",
"serde_json",
"sqlparser",
"unicode-width",
"utoipa",
]
[[package]]
name = "dbx-sql-dialect"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-types",
"insta",
"log",
"notify",
"percent-encoding",
"serde",
"serde_yaml",
"tempfile",
"tokio",
]
[[package]]
name = "dbx-sql-schema"
version = "0.1.0"
dependencies = [
"dbx-sql-core",
"dbx-sql-dialect",
"dbx-types",
"log",
"rayon",
"regex",
"rusqlite",
@@ -2364,12 +2595,6 @@ dependencies = [
"serde_json",
"serde_yaml",
"sqlparser",
"tempfile",
"tokio",
"unicode-ident",
"unicode-width",
"utoipa",
"winnow 1.0.4",
]
[[package]]
@@ -2396,7 +2621,7 @@ dependencies = [
[[package]]
name = "dbx-web"
version = "0.6.21"
version = "0.6.25"
dependencies = [
"aes-gcm 0.10.3",
"argon2",
@@ -2408,9 +2633,11 @@ dependencies = [
"dbx-mcp",
"futures",
"log",
"mime_guess",
"pbkdf2 0.12.2",
"redis",
"reqwest 0.12.28",
"rust-embed",
"rustls 0.23.43",
"serde",
"serde_json",
@@ -7654,6 +7881,41 @@ dependencies = [
"wasm-bindgen-futures",
]
[[package]]
name = "rust-embed"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
dependencies = [
"mime_guess",
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.119",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
dependencies = [
"sha2 0.11.0",
"walkdir",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["src-tauri", "crates/dbx-core", "crates/dbx-drivers", "crates/dbx-sql", "crates/dbx-plugin-runtime", "crates/dbx-formats", "crates/dbx-types", "crates/dbx-platform", "crates/dbx-ai-provider", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
members = ["src-tauri", "crates/dbx-core", "crates/dbx-driver-agent", "crates/dbx-driver-support", "crates/dbx-driver-elasticsearch", "crates/dbx-driver-mongodb", "crates/dbx-driver-mysql", "crates/dbx-driver-postgres", "crates/dbx-driver-redis", "crates/dbx-driver-sqlserver", "crates/dbx-drivers", "crates/dbx-sql", "crates/dbx-sql-core", "crates/dbx-sql-data", "crates/dbx-sql-dialect", "crates/dbx-sql-schema", "crates/dbx-plugin-runtime", "crates/dbx-formats", "crates/dbx-types", "crates/dbx-platform", "crates/dbx-ai-provider", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
exclude = ["vendor/tauri-plugin-updater", "vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/webview2-com-sys", "vendor/wry"]
[patch.crates-io]
+1 -1
View File
@@ -82,7 +82,7 @@ dev: node_modules/.modules.yaml check-tauri-dev-port
dev-fast: node_modules/.modules.yaml check-tauri-dev-port
# os-keyring must stay in sync with src-tauri defaults: without it the keychain
# key is unreadable and the secret-store migration wizard reappears on every launch.
RUST_MIN_STACK=16777216 $(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled,os-keyring
RUST_MIN_STACK=16777216 $(PNPM) dev:tauri -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled,os-keyring
dev-web: node_modules/.modules.yaml
$(PNPM) dev:web
+34 -12
View File
@@ -1,8 +1,8 @@
<div align="center">
<p style="font-size: 18px; white-space: nowrap;"><strong>90+ databases in 25 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
<p style="font-size: 18px; white-space: nowrap;"><strong>100+ databases in 25 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
<p>
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX screenshot" width="820" />
<img src="https://dl.dbxio.com/assets/readme-hero-20260925.png" alt="DBX screenshot" width="820" />
</p>
<p>
@@ -103,13 +103,13 @@
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
<img src="docs/public/sponsors/astraflow-card.png" alt="AstraFlow" width="175" />
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">
<img src="docs/public/sponsors/aicodemirror-card.png" alt="AICodeMirror" width="175" />
</a>
</td>
<td>
UCloud is the first public cloud provider listed on China's STAR Market, with 28 global regions for cloud hosting, databases, and CDN; its AstraFlow platform offers one-click access to 200+ mainstream LLMs.
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
Special thanks to AICodeMirror for sponsoring this project! AICodeMirror provides a high-stability official relay service for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 7×24 dedicated support. Official-channel pricing for Claude Code / Codex / Gemini is as low as 38% / 2% / 9% of list price, with extra discounts on top-ups! AICodeMirror offers a special benefit for DBX users: register through this link to enjoy 20% off your first top-up, and enterprise customers up to 25% off.
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">Visit AICodeMirror</a>
</td>
</tr>
<tr>
@@ -125,13 +125,13 @@
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">
<img src="docs/public/sponsors/aicodemirror-card.png" alt="AICodeMirror" width="175" />
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
<img src="docs/public/sponsors/astraflow-card.png" alt="AstraFlow" width="175" />
</a>
</td>
<td>
Special thanks to AICodeMirror for sponsoring this project! AICodeMirror provides a high-stability official relay service for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 7×24 dedicated support. Official-channel pricing for Claude Code / Codex / Gemini is as low as 38% / 2% / 9% of list price, with extra discounts on top-ups! AICodeMirror offers a special benefit for DBX users: register through this link to enjoy 20% off your first top-up, and enterprise customers up to 25% off.
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">Visit AICodeMirror</a>
UCloud is the first public cloud provider listed on China's STAR Market, with 28 global regions for cloud hosting, databases, and CDN; its AstraFlow platform offers one-click access to 200+ mainstream LLMs.
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
</td>
</tr>
<tr>
@@ -208,13 +208,23 @@
<p>Native app on macOS, Windows, and Linux. Self-host via Docker for team access. Web version for browser-only environments. Same feature set. Same connections.</p>
</td>
</tr>
<tr>
<td>
<h3>📨 Not just databases</h3>
<p>Message queues and middleware get first-class consoles: Kafka, RocketMQ, RabbitMQ, Pulsar, and MQTT, plus Nacos, Consul, ZooKeeper, and etcd. Inspect topics and messages next to your databases — no extra tool.</p>
</td>
<td>
<h3>🧩 Plugin ecosystem</h3>
<p>Extend DBX with signed, sandboxed plugins from the built-in store — S3, Kubernetes, LDAP, and more. Build your own with the Go / TypeScript SDK.</p>
</td>
</tr>
</table>
## Features
### 90+ Databases, One Tool
### 100+ Databases, One Tool
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~25 MB app. No bundled Chromium.
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. All in a single ~25 MB app. No bundled Chromium.
### Query Editor
@@ -254,6 +264,18 @@ Virtual-scrolled table that handles large result sets. Inline editing, SQL previ
- **Redis** — key pattern search, batch key operations, command runner, TTL editing, and all data types (String, Hash, List, Set, ZSet, Stream)
- **MongoDB** — document CRUD with pagination, Atlas & replica set URL connection
### Message Queue & Middleware Consoles
- **Kafka / RocketMQ / RabbitMQ / Pulsar** — topics, consumer groups, message browsing, query and trace, broker monitoring, permissions and policies
- **MQTT** — topic tree navigation, subscribe, and publish
- **Nacos / Consul / ZooKeeper / etcd** — service discovery, KV / config browsing, health, and ACL
### Plugin System
- **Extensible by design** — new connection types and tools arrive as plugins: S3 browsing, Kubernetes, LDAP, and more from the built-in store
- **Signed & sandboxed** — every plugin package is signature-verified before install; plugin UI runs sandboxed with its own sidecar process
- **Build your own** — Go / TypeScript SDKs with `npx @dbx-app/plugin-cli` scaffolding; publish to the Marketplace via [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store)
### Safety & Connectivity
SSH tunnel (key & password) · database and AI proxy settings · auto-reconnect on connection loss · confirmation dialogs for destructive operations · encrypted config export/import · color-coded connections · driver store and optional JDBC plugin
+35 -13
View File
@@ -1,8 +1,8 @@
<div align="center">
<p style="font-size: 18px; white-space: nowrap;"><strong>25 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
<p style="font-size: 18px; white-space: nowrap;"><strong>25 MB 驾驭 100+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
<p>
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX 截图" width="820" />
<img src="https://dl.dbxio.com/assets/readme-hero-20260925.png" alt="DBX 截图" width="820" />
</p>
<p>
@@ -103,13 +103,13 @@
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
<img src="docs/public/sponsors/astraflow-card.png" alt="AstraFlow" width="175" />
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">
<img src="docs/public/sponsors/aicodemirror-card.png" alt="AICodeMirror" width="175" />
</a>
</td>
<td>
UCloud 优刻得是国内首家公有云科创板上市公司,覆盖国内、亚洲、欧洲、北美等 28 个地域的云主机、数据库、CDN 等服务,注册享新客优惠 0.9 折起;星图 AstraFlow 大模型平台支持主流 200+ 大模型一键调用。
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 DBX 用户提供了特别福利,通过此链接注册的用户,可享受首充 8 折,企业客户最高可享 7.5 折!
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">访问 AICodeMirror</a>
</td>
</tr>
<tr>
@@ -125,13 +125,13 @@
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">
<img src="docs/public/sponsors/aicodemirror-card.png" alt="AICodeMirror" width="175" />
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
<img src="docs/public/sponsors/astraflow-card.png" alt="AstraFlow" width="175" />
</a>
</td>
<td>
感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 DBX 用户提供了特别福利,通过此链接注册的用户,可享受首充 8 折,企业客户最高可享 7.5 折!
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">访问 AICodeMirror</a>
UCloud 优刻得是国内首家公有云科创板上市公司,覆盖国内、亚洲、欧洲、北美等 28 个地域的云主机、数据库、CDN 等服务,注册享新客优惠 0.9 折起;星图 AstraFlow 大模型平台支持主流 200+ 大模型一键调用。
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
</td>
</tr>
<tr>
@@ -208,11 +208,21 @@
<p>macOS、Windows、Linux 原生应用。通过 Docker 自托管供团队访问。Web 版本适配纯浏览器环境。同样的功能,同样的连接配置。</p>
</td>
</tr>
<tr>
<td>
<h3>📨 不止数据库</h3>
<p>消息队列与中间件同样是一等公民:Kafka、RocketMQ、RabbitMQ、Pulsar、MQTT 控制台,外加 Nacos、Consul、ZooKeeper、etcd。在数据库旁边直接查看主题与消息,无需再开一个工具。</p>
</td>
<td>
<h3>🧩 插件生态</h3>
<p>从内置商店安装经过签名校验的沙箱插件扩展 DBX——S3、Kubernetes、LDAP 等。也可以用 Go / TypeScript SDK 开发自己的插件。</p>
</td>
</tr>
</table>
## 功能特性
### 90+ 种数据库,一个工具搞定
### 100+ 种数据库,一个工具搞定
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 25 MB 的应用里,不内嵌 Chromium。
@@ -254,6 +264,18 @@ CodeMirror 6 语法高亮、元数据感知自动补全、`Cmd+Enter` 执行、
- **Redis** — 模式匹配搜索、批量键操作、命令执行器、TTL 编辑,全数据类型支持(String、Hash、List、Set、ZSet、Stream)
- **MongoDB** — 文档增删改查、分页浏览,支持 Atlas 和副本集 URL 直连
### 消息队列与中间件控制台
- **Kafka / RocketMQ / RabbitMQ / Pulsar** — 主题、消费组、消息浏览 / 查询 / 追踪、Broker 监控、权限与策略
- **MQTT** — 主题树导航、订阅与发布
- **Nacos / Consul / ZooKeeper / etcd** — 服务发现、KV / 配置浏览、健康状态与 ACL
### 插件系统
- **为扩展而生** — 新的连接类型与工具以插件形式接入:S3 浏览、Kubernetes、LDAP 等,内置商店一键安装
- **签名与沙箱** — 插件包安装前签名校验,插件 UI 运行在沙箱中并拥有独立 sidecar 进程
- **开发你的插件** — Go / TypeScript SDK,`npx @dbx-app/plugin-cli` 一行起步,通过 [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) 发布到商店
### 安全与连接
SSH 隧道(密钥和密码认证)· 数据库和 AI 代理设置 · 断线自动重连 · 危险操作确认对话框 · 加密导出/导入连接配置 · 连接颜色标记 · 驱动商店与可选 JDBC 插件
@@ -568,12 +590,12 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
<details>
<summary><strong>DBX 和 DBeaver / TablePlus / Beekeeper Studio 有什么区别?</strong></summary>
DBX 仅 25 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
DBX 仅 25 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 100+ 数据库、桌面端、Docker 和 Web。
</details>
<details>
<summary><strong>支持哪些数据库?</strong></summary>
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Kafka、RocketMQ、RabbitMQ、Pulsar、MQTT)。
</details>
<details>
@@ -32,6 +32,9 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
private boolean sessionAffinity;
private boolean pooledConnectionPoisoned;
@Override
public boolean supportsQueryTiming() { return true; }
@Override
public final Connection getConnection() {
return connection;
@@ -185,8 +188,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
long prepareStarted = System.nanoTime();
Connection conn = requireConnected();
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
QueryTiming.record("session_prepare", prepareStarted);
return JdbcExecutor.current().execute(
conn,
sql,
@@ -204,8 +209,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
long prepareStarted = System.nanoTime();
Connection conn = requireConnected();
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
QueryTiming.record("session_prepare", prepareStarted);
return JdbcExecutor.current().executePage(
conn,
sql,
@@ -11,6 +11,9 @@ import java.util.Set;
import java.util.stream.Collectors;
public interface DatabaseAgent {
/** Opt in to request-boundary timing, including pooled connection lifecycle. */
default boolean supportsQueryTiming() { return false; }
void connect(ConnectParams params);
boolean testConnection(ConnectParams params);
@@ -7,7 +7,7 @@ import java.util.List;
/**
* Decode PostGIS EWKB / WKB byte streams (and PGgeometry / hex strings) into
* OGC Well-Known Text. Behaves identically to the Rust implementation in
* {@code crates/dbx-drivers/src/db/postgres.rs::ewkb_to_wkt}, including the
* {@code crates/dbx-driver-support/src/wkb.rs::wkb_to_wkt}, including the
* {@code Z} / {@code M} / {@code ZM} dimension suffixes and {@code EMPTY}
* geometries. Used by the JDBC agent layer so that PostgresLike databases
* such as HighGo / KingBase / Vastbase / openGauss / GaussDB return WKT for
@@ -140,7 +140,10 @@ public final class JdbcExecutor {
String trimmedSql = trimSql(sql);
long start = System.currentTimeMillis();
long phaseStarted = System.nanoTime();
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
QueryTiming.record("schema", phaseStarted);
phaseStarted = System.nanoTime();
try (Statement stmt = conn.createStatement()) {
activeStatements.add(stmt);
@@ -153,7 +156,10 @@ public final class JdbcExecutor {
}
// SQL dumps often contain BEGIN/COMMIT/ROLLBACK as executable statements.
// Do not translate them to Connection.commit(), which requires autoCommit=false.
QueryTiming.record("statement_prepare", phaseStarted);
phaseStarted = System.nanoTime();
boolean hasResultSet = stmt.execute(trimmedSql);
QueryTiming.record("jdbc_execute", phaseStarted);
long elapsed = System.currentTimeMillis() - start;
long affectedRows;
if (advancePastUpdateCounts) {
@@ -210,6 +216,7 @@ public final class JdbcExecutor {
ResultValueReader valueReader
) {
return unchecked(() -> {
long phaseStarted = System.nanoTime();
ResultSetMetaData meta = rs.getMetaData();
int colCount = meta.getColumnCount();
List<String> columns = new ArrayList<>(colCount);
@@ -226,6 +233,8 @@ public final class JdbcExecutor {
typeNameByIndex[i - 1] = typeName;
}
QueryTiming.record("metadata", phaseStarted);
phaseStarted = System.nanoTime();
List<List<Object>> rows = new ArrayList<>(initialRowCapacity(maxRows));
boolean truncated = false;
while (rs.next()) {
@@ -236,6 +245,7 @@ public final class JdbcExecutor {
rows.add(rowValues(rs, valueReader, sqlTypeByIndex, typeNameByIndex));
}
QueryTiming.record("fetch", phaseStarted);
return new QueryResult(columns, columnTypes, rows, 0L, executionTimeMs, truncated);
});
}
@@ -383,7 +393,10 @@ public final class JdbcExecutor {
String trimmedSql = trimSql(sql);
long start = System.currentTimeMillis();
long phaseStarted = System.nanoTime();
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
QueryTiming.record("schema", phaseStarted);
phaseStarted = System.nanoTime();
Statement stmt = conn.createStatement();
QuerySession createdSession = null;
@@ -395,7 +408,10 @@ public final class JdbcExecutor {
}
// Keep script transaction-control statements in the SQL stream.
// JDBC transaction APIs are reserved for executeTransaction.
QueryTiming.record("statement_prepare", phaseStarted);
phaseStarted = System.nanoTime();
boolean hasResultSet = stmt.execute(trimmedSql);
QueryTiming.record("jdbc_execute", phaseStarted);
long elapsed = System.currentTimeMillis() - start;
long affectedRows;
if (advancePastUpdateCounts) {
@@ -442,6 +458,7 @@ public final class JdbcExecutor {
);
}
phaseStarted = System.nanoTime();
ResultSet rs = stmt.getResultSet();
ResultSetMetaData meta = rs.getMetaData();
String sessionId = UUID.randomUUID().toString();
@@ -471,7 +488,10 @@ public final class JdbcExecutor {
);
createdSession = session;
targetSessions.put(sessionId, session);
QueryTiming.record("metadata", phaseStarted);
phaseStarted = System.nanoTime();
QueryPageResult page = readSessionPage(targetSessions, session, options.getPageSize(), elapsed);
QueryTiming.record("fetch", phaseStarted);
// Same reason as `execute`: pulling the first page's rows is
// part of the duration the caller waited for.
page.setExecution_time_ms(System.currentTimeMillis() - start);
@@ -724,7 +744,9 @@ public final class JdbcExecutor {
synchronized (session) {
long fetchStart = System.currentTimeMillis();
try {
long phaseStarted = System.nanoTime();
QueryPageResult page = readSessionPage(targetSessions, session, pageSize, 0L);
QueryTiming.record("fetch", phaseStarted);
// A later page reads its rows after `stmt.execute()` has long
// returned, so it has to measure the fetch itself instead of
// reporting 0ms while the caller waits.
@@ -89,6 +89,26 @@ public final class JsonRpcServer {
}
Object dispatchForRuntime(String method, JsonObject params) throws Exception {
boolean timedQuery = agent.supportsQueryTiming()
&& (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method)
|| AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method)
|| AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method));
try (QueryTiming timing = timedQuery ? QueryTiming.begin() : null) {
Object result = dispatchWithConnection(method, params);
// Capture after connection return/reset, which runs in the finally
// block below. Driver-only snapshots omit that lifecycle work.
if (timing != null) {
if (result instanceof QueryResult) {
((QueryResult) result).setQuery_timings_ms(timing.finish());
} else if (result instanceof QueryPageResult) {
((QueryPageResult) result).setQuery_timings_ms(timing.finish());
}
}
return result;
}
}
private Object dispatchWithConnection(String method, JsonObject params) throws Exception {
return AgentExecutionContext.withJdbcExecutor(jdbcExecutor, () -> {
AbstractJdbcAgent jdbcAgent = pooledJdbcAgent();
if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method)
@@ -98,7 +118,12 @@ public final class JsonRpcServer {
}
boolean manageConnection = jdbcAgent != null && requiresConnectedConnection(method);
if (manageConnection) {
jdbcAgent.beginPooledRequest();
long acquireStarted = System.nanoTime();
try {
jdbcAgent.beginPooledRequest();
} finally {
QueryTiming.record("pool_acquire", acquireStarted);
}
}
boolean succeeded = false;
try {
@@ -107,14 +132,19 @@ public final class JsonRpcServer {
return result;
} finally {
if (manageConnection) {
jdbcAgent.finishPooledRequest(
jdbcExecutor,
succeeded,
requiresSessionAffinity(method, params),
evictAfterRequest(method),
endsSessionAffinity(method),
preservesSchemaContext()
);
long releaseStarted = System.nanoTime();
try {
jdbcAgent.finishPooledRequest(
jdbcExecutor,
succeeded,
requiresSessionAffinity(method, params),
evictAfterRequest(method),
endsSessionAffinity(method),
preservesSchemaContext()
);
} finally {
QueryTiming.record("pool_release", releaseStarted);
}
}
}
});
@@ -79,7 +79,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
/**
* Wrap {@link AbstractJdbcAgent#resultValue} so PostGIS-style {@code geometry}
* and {@code geography} columns are decoded into WKT (matching the native
* tokio_postgres path in {@code crates/dbx-drivers/src/db/postgres.rs}).
* tokio_postgres path in {@code crates/dbx-driver-postgres/src/postgres.rs}).
*/
private JdbcExecutor.ColumnAwareResultValueReader geometryAwareResolver() {
return (rs, index, sqlType, columnTypeName) -> {
@@ -13,6 +13,7 @@ public final class QueryPageResult {
private long affected_rows;
private long execution_time_ms;
private Long server_execute_time_us;
private java.util.Map<String, Double> query_timings_ms;
/** Number of rows read by this JDBC cursor across all pages; never sent over JSON RPC. */
private transient long cursor_rows_read;
private boolean truncated;
@@ -100,6 +101,10 @@ public final class QueryPageResult {
return execution_time_ms;
}
public java.util.Map<String, Double> getQuery_timings_ms() { return query_timings_ms; }
public void setQuery_timings_ms(java.util.Map<String, Double> value) { query_timings_ms = value; }
public Long getServer_execute_time_us() {
return server_execute_time_us;
}
@@ -18,6 +18,7 @@ public final class QueryResult {
private long affected_rows;
private long execution_time_ms;
private Long server_execute_time_us;
private java.util.Map<String, Double> query_timings_ms;
private boolean truncated;
public QueryResult() {
@@ -85,6 +86,10 @@ public final class QueryResult {
return execution_time_ms;
}
public java.util.Map<String, Double> getQuery_timings_ms() { return query_timings_ms; }
public void setQuery_timings_ms(java.util.Map<String, Double> value) { query_timings_ms = value; }
public Long getServer_execute_time_us() {
return server_execute_time_us;
}
@@ -0,0 +1,38 @@
package com.dbx.agent;
import java.util.LinkedHashMap;
import java.util.Map;
/** Request-scoped, monotonic timings. Disabled unless a driver explicitly opens a scope. */
public final class QueryTiming implements AutoCloseable {
private static final ThreadLocal<QueryTiming> CURRENT = new ThreadLocal<>();
private final Map<String, Double> stages = new LinkedHashMap<>();
private final long started = System.nanoTime();
private final QueryTiming previous;
private QueryTiming() {
previous = CURRENT.get();
CURRENT.set(this);
}
public static QueryTiming begin() { return new QueryTiming(); }
public static void record(String stage, long started) {
QueryTiming timing = CURRENT.get();
if (timing != null) timing.stages.merge(stage, (System.nanoTime() - started) / 1_000_000.0, Double::sum);
}
public Map<String, Double> finish() {
Map<String, Double> result = new LinkedHashMap<>(stages);
result.put("agent_total", (System.nanoTime() - started) / 1_000_000.0);
return result;
}
@Override public void close() {
// A driver scope also works for direct calls. When invoked through the
// RPC boundary, carry its non-overlapping stages into the outer request.
// agent_total is a snapshot only; never merge nested totals.
if (previous != null) stages.forEach((key, value) -> previous.stages.merge(key, value, Double::sum));
if (previous == null) CURRENT.remove(); else CURRENT.set(previous);
}
}
@@ -22,7 +22,7 @@ class EwkbWktDecoderTest {
// ──────────────────────────────────────────────────────────────────────
// Cross-checked against the Rust ewkb_to_wkt tests in
// crates/dbx-drivers/src/db/postgres.rs (same hex inputs, same expected WKT).
// crates/dbx-driver-support/src/wkb.rs (same hex inputs, same expected WKT).
// ──────────────────────────────────────────────────────────────────────
@Test
@@ -1112,6 +1112,12 @@ class JdbcConnectionPoolingTest {
pageParams
));
assertTrue(firstPage.get("has_more").getAsBoolean());
JsonObject timings = firstPage.getAsJsonObject("query_timings_ms");
assertNotNull(timings, "all JDBC agents expose timings through JSON RPC");
assertTrue(timings.get("pool_acquire").getAsDouble() >= 0);
assertTrue(timings.get("jdbc_execute").getAsDouble() >= 0);
assertTrue(timings.get("fetch").getAsDouble() >= 0);
assertTrue(timings.get("pool_release").getAsDouble() >= 0);
String querySessionId = firstPage.get("session_id").getAsString();
Future<JsonObject> waiting = worker.submit(
@@ -53,6 +53,15 @@ public final class CacheAgent extends ConfiguredJdbcAgent {
Object value = rs.getBigDecimal(index);
return rs.wasNull() ? null : value;
});
case Types.BIT:
case Types.BOOLEAN:
// Caché has no boolean type: %Boolean is stored as 1/0 and only
// the JDBC driver turns it into a Java boolean, which would
// surface as true/false in the grid. Report the stored 1/0.
return unchecked(() -> {
int value = rs.getInt(index);
return rs.wasNull() ? null : value;
});
case Types.OTHER:
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
default:
@@ -159,6 +159,47 @@ class CacheAgentTest {
assertEquals(Collections.singletonList("getObject"), calls);
}
@Test
void readsBitColumnsAsStoredNumbers() {
for (int sqlType : new int[] {Types.BIT, Types.BOOLEAN}) {
List<String> calls = new ArrayList<>();
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getInt".equals(method.getName())) {
calls.add("getInt");
return 1;
}
if (method.getName().startsWith("get")) {
calls.add(method.getName());
throw new AssertionError("Caché %Boolean must not be read as a Java boolean");
}
if ("wasNull".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
Object value = new CacheAgent().resultValue(resultSet, 1, sqlType);
assertEquals(1, value);
assertEquals(Collections.singletonList("getInt"), calls);
}
}
@Test
void preservesNullForBitColumns() {
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getInt".equals(method.getName())) {
return 0;
}
if ("wasNull".equals(method.getName())) {
return true;
}
return defaultValue(method.getReturnType());
});
assertNull(new CacheAgent().resultValue(resultSet, 1, Types.BIT));
}
@Test
void readsColumnsAndUsesInlinePrimaryKey() {
List<String> calls = new ArrayList<>();
@@ -50,6 +50,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
@@ -68,6 +69,10 @@ public final class DamengAgent extends AbstractJdbcAgent {
private static final int DBMS_OUTPUT_ENABLE_TIMEOUT_SECS = 5;
private static final int DBMS_OUTPUT_ENABLE_NETWORK_TIMEOUT_MILLIS = 5_000;
private static final int VIEW_VALIDITY_BATCH_SIZE = 500;
// Dameng encodes the declared length unit of a character column in SCALE: see
// declaredWithCharacterLength.
private static final int CHARACTER_SCALE_VARIABLE = 7;
private static final int CHARACTER_SCALE_FIXED = 8;
private static final Pattern DATABASE_VERSION_MAJOR_PATTERN = Pattern.compile("(\\d+)\\.");
// Word-boundary match so a type or default merely containing the letters is not mistaken
// for the IDENTITY keyword.
@@ -569,7 +574,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
return queryConstrainedTables(schema, MetadataListConstraints.orNone(constraints));
}
private List<TableInfo> queryConstrainedTables(String schema, MetadataListConstraints constraints) {
private List<TableInfo> queryConstrainedTables(String rawSchema, MetadataListConstraints constraints) {
final String schema = normalizeSchema(rawSchema);
if (legacyJdbcMetadata) {
return withViewValidity(executeJdbcMetadataTables(schema, constraints), schema);
}
@@ -905,6 +911,43 @@ public final class DamengAgent extends AbstractJdbcAgent {
&& schema.equalsIgnoreCase(connectedUsername);
}
/**
* DM resolves an unqualified object name in the session's current schema, and dbx sends an
* unqualified metadata request whenever the editor's source table has no schema selected
* (the same Oracle-like contract `oracle` and `oceanbase-oracle` follow). Looking up
* `OWNER = ''` instead reported "no columns" for tables that exist, so column comments never
* reached the result-column tooltip (issue #10221).
*/
private String normalizeSchema(String schema) {
if (schema != null && !schema.isBlank()) {
return schema;
}
return effectiveMetadataSchema(currentSchemaOrBlank(), connectedUsername);
}
static String effectiveMetadataSchema(String currentSchema, String connectedUsername) {
if (currentSchema != null && !currentSchema.isBlank()) {
return currentSchema;
}
return connectedUsername == null ? "" : connectedUsername;
}
private String currentSchemaOrBlank() {
try (Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL")) {
if (rs.next()) {
String current = rs.getString(1);
if (current != null && !current.isBlank()) {
return current;
}
}
} catch (SQLException error) {
// A server without SYS_CONTEXT support still resolves an unqualified name to the
// connected user, which is what the caller falls back to.
}
return "";
}
private static boolean includesSupportedObjectTypes(MetadataListConstraints constraints) {
return constraints.includesTableLikeTypes()
|| constraints.objectTypeAllowed("PROCEDURE")
@@ -1095,7 +1138,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
return queryConstrainedObjects(schema, MetadataListConstraints.orNone(constraints));
}
private List<ObjectInfo> queryConstrainedObjects(String schema, MetadataListConstraints constraints) {
private List<ObjectInfo> queryConstrainedObjects(String rawSchema, MetadataListConstraints constraints) {
final String schema = normalizeSchema(rawSchema);
List<ObjectInfo> objects = queryConstrainedObjectsWithoutValidity(schema, constraints);
applyViewValidity(objects, schema);
return objects;
@@ -1349,7 +1393,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
@Override
public ObjectSource getObjectSource(String schema, String name, String objectType) {
public ObjectSource getObjectSource(String rawSchema, String name, String objectType) {
final String schema = normalizeSchema(rawSchema);
return unchecked(() -> {
String dbmsType = damengDdlObjectType(objectType);
RuntimeException dbmsError;
@@ -1775,7 +1820,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
@Override
public String getTableDdl(String schema, String table) {
public String getTableDdl(String rawSchema, String table) {
final String schema = normalizeSchema(rawSchema);
if (legacyJdbcMetadata) {
return super.getTableDdl(schema, table);
}
@@ -1869,7 +1915,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
public List<ColumnInfo> getColumns(String rawSchema, String table) {
final String schema = normalizeSchema(rawSchema);
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.getColumns(
requireConnected(),
@@ -1904,6 +1951,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
Set<String> identityColumns = identityColumns(schema, table);
// Lazily filled in only when a column's declared length is not visible in
// ALL_TAB_COLUMNS; the dictionary lookup costs an extra round trip.
Map<String, Integer> declaredColumnLengths = null;
List<ColumnInfo> result = new ArrayList<>();
// DATA_DEFAULT is a LONG column — it must be selected first and read first
// in JDBC, otherwise the data is truncated.
@@ -1940,7 +1990,15 @@ public final class DamengAgent extends AbstractJdbcAgent {
Integer dataLen = intObject(rs, "DATA_LENGTH");
Integer charLen = intObject(rs, "CHAR_LENGTH");
String charUsed = rs.getString("CHAR_USED");
String dataType = formatDataType(baseType, numPrec, numScale, dataLen, charLen, charUsed);
Integer declaredCharLen = null;
if (needsDeclaredCharacterLength(baseType, numScale, charLen)) {
if (declaredColumnLengths == null) {
declaredColumnLengths = declaredColumnLengths(schema, table);
}
declaredCharLen = declaredColumnLengths.get(name);
}
String dataType =
formatDataType(baseType, numPrec, numScale, dataLen, charLen, charUsed, declaredCharLen);
result.add(new ColumnInfo(
name,
@@ -2175,7 +2233,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
public List<IndexInfo> listIndexes(String rawSchema, String table) {
final String schema = normalizeSchema(rawSchema);
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.listIndexes(
requireConnected(),
@@ -2234,7 +2293,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
@Override
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
public List<ForeignKeyInfo> listForeignKeys(String rawSchema, String table) {
final String schema = normalizeSchema(rawSchema);
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.listForeignKeys(requireConnected(), schema, table);
}
@@ -2268,7 +2328,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
@Override
public List<TriggerInfo> listTriggers(String schema, String table) {
public List<TriggerInfo> listTriggers(String rawSchema, String table) {
final String schema = normalizeSchema(rawSchema);
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.listTriggers(schema, table);
}
@@ -2558,15 +2619,17 @@ public final class DamengAgent extends AbstractJdbcAgent {
Integer numScale,
Integer dataLen,
Integer charLen,
String charUsed
String charUsed,
Integer declaredCharLen
) {
return switch (base.toUpperCase(Locale.ROOT)) {
case "VARCHAR2", "VARCHAR", "CHAR" -> {
Integer length = characterLength(dataLen, charLen, charUsed);
yield length != null ? base + "(" + length + characterLengthUnit(charUsed) + ")" : base;
String unit = characterLengthUnit(base, numScale, charUsed);
Integer length = characterLength(dataLen, charLen, unit, declaredCharLen);
yield length != null ? base + "(" + length + unit + ")" : base;
}
case "NVARCHAR2", "NCHAR" -> {
Integer length = charLen != null ? charLen : dataLen;
Integer length = firstPositive(charLen, declaredCharLen, dataLen);
yield length != null ? base + "(" + length + ")" : base;
}
case "NUMBER", "NUMERIC", "DECIMAL" -> {
@@ -2580,16 +2643,18 @@ public final class DamengAgent extends AbstractJdbcAgent {
};
}
private static Integer characterLength(Integer dataLen, Integer charLen, String charUsed) {
String normalized = charUsed == null ? "" : charUsed.trim().toUpperCase(Locale.ROOT);
if ("B".equals(normalized) || "BYTE".equals(normalized)) {
return dataLen != null ? dataLen : charLen;
private static Integer characterLength(Integer dataLen, Integer charLen, String unit, Integer declaredCharLen) {
if (" BYTE".equals(unit)) {
return firstPositive(dataLen, declaredCharLen, charLen);
}
return charLen != null ? charLen : dataLen;
return firstPositive(charLen, declaredCharLen, dataLen);
}
private static String characterLengthUnit(String charUsed) {
if (charUsed == null) {
private static String characterLengthUnit(String base, Integer numScale, String charUsed) {
if (declaredWithCharacterLength(base, numScale)) {
return " CHAR";
}
if (charUsed == null || charUsed.isBlank()) {
return "";
}
return switch (charUsed.trim().toUpperCase(Locale.ROOT)) {
@@ -2599,6 +2664,83 @@ public final class DamengAgent extends AbstractJdbcAgent {
};
}
private static Integer firstPositive(Integer... values) {
for (Integer value : values) {
if (value != null && value > 0) {
return value;
}
}
return null;
}
/**
* Dameng keeps a character column's declared length unit in its SCALE field instead of in
* CHAR_USED, which reads 'B' for byte- and character-length columns alike: SCALE is 7 for
* VARCHAR/VARCHAR2/NVARCHAR2 and 8 for CHAR/NCHAR columns declared with character
* semantics, and 0 for byte semantics. Reading CHAR_USED alone rendered a column created as
* <code>VARCHAR2(100 CHAR)</code> as <code>VARCHAR2(400 BYTE)</code> on a UTF-8 server.
*/
private static boolean declaredWithCharacterLength(String base, Integer numScale) {
if (numScale == null) {
return false;
}
return switch (base.toUpperCase(Locale.ROOT)) {
case "VARCHAR2", "VARCHAR", "NVARCHAR2" -> numScale == CHARACTER_SCALE_VARIABLE;
case "CHAR", "NCHAR" -> numScale == CHARACTER_SCALE_FIXED;
default -> false;
};
}
/**
* NVARCHAR2/NCHAR columns leave CHAR_LENGTH at 0 in ALL_TAB_COLUMNS, and a character-length
* column always reports DATA_LENGTH in bytes, so the declared length has to come from
* elsewhere. Only ask when the cheaper columns cannot answer.
*/
private static boolean needsDeclaredCharacterLength(String base, Integer numScale, Integer charLen) {
if (charLen != null && charLen > 0) {
return false;
}
String type = base.toUpperCase(Locale.ROOT);
return switch (type) {
case "NVARCHAR2", "NCHAR" -> true;
case "VARCHAR2", "VARCHAR", "CHAR" -> declaredWithCharacterLength(type, numScale);
default -> false;
};
}
/**
* SYS.SYSCOLUMNS.LENGTH$ keeps the length exactly as declared, which ALL_TAB_COLUMNS cannot
* report for NVARCHAR2/NCHAR. The view is not granted to PUBLIC, so a permission error just
* means the caller keeps using the ALL_TAB_COLUMNS values instead of failing the request.
*/
private Map<String, Integer> declaredColumnLengths(String schema, String table) {
Map<String, Integer> result = new HashMap<>();
String sql = """
SELECT /*+ PARALLEL(1) */ c.NAME, c.LENGTH$
FROM SYS.SYSCOLUMNS c
JOIN SYS.SYSOBJECTS t ON c.ID = t.ID
JOIN SYS.SYSOBJECTS s ON t.SCHID = s.ID
WHERE s.NAME = ? AND t.NAME = ?
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String name = rs.getString(1);
int length = rs.getInt(2);
if (notBlank(name) && length > 0) {
result.put(name, length);
}
}
}
} catch (Exception error) {
LOGGER.log(Level.FINE, "Unable to read declared column lengths for " + schema + "." + table, error);
return Map.of();
}
return result;
}
private static Integer intObject(ResultSet rs, String column) throws Exception {
Object value = rs.getObject(column);
return value == null ? null : ((Number) value).intValue();
@@ -2704,15 +2846,39 @@ public final class DamengAgent extends AbstractJdbcAgent {
if (notBlank(tableComment) && !containsCommentOnTable(result.toString(), schema, table)) {
appendCommentStatement(result, "COMMENT ON TABLE " + tableRef + " IS '" + sqlStringBody(tableComment) + "';");
}
for (ColumnInfo column : getColumns(schema, table)) {
if (!notBlank(column.getComment()) || containsCommentOnColumn(result.toString(), schema, table, column.getName())) {
for (Map.Entry<String, String> column : tableColumnComments(schema, table).entrySet()) {
if (containsCommentOnColumn(result.toString(), schema, table, column.getKey())) {
continue;
}
appendCommentStatement(result, "COMMENT ON COLUMN " + tableRef + "." + JdbcIdentifiers.INSTANCE.doubleQuote(column.getName()) + " IS '" + sqlStringBody(column.getComment()) + "';");
appendCommentStatement(result, "COMMENT ON COLUMN " + tableRef + "." + JdbcIdentifiers.INSTANCE.doubleQuote(column.getKey()) + " IS '" + sqlStringBody(column.getValue()) + "';");
}
return result.toString();
}
private Map<String, String> tableColumnComments(String schema, String table) throws Exception {
Map<String, String> result = new LinkedHashMap<>();
String sql = """
SELECT /*+ PARALLEL(1) */ COLUMN_NAME, COMMENTS
FROM ALL_COL_COMMENTS
WHERE OWNER = ? AND TABLE_NAME = ? AND COMMENTS IS NOT NULL
ORDER BY COLUMN_NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String column = rs.getString(1);
String comment = rs.getString(2);
if (notBlank(column) && notBlank(comment)) {
result.put(column, comment);
}
}
}
}
return result;
}
private String appendIndependentIndexDdl(String ddl, String schema, String table) throws Exception {
StringBuilder result = new StringBuilder(ddl == null ? "" : ddl.trim());
for (IndexInfo index : independentIndexes(schema, table)) {
@@ -2729,6 +2895,84 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
private List<IndexInfo> independentIndexes(String schema, String table) throws Exception {
try {
return independentIndexesFromSystemCatalog(schema, table);
} catch (Exception systemCatalogError) {
if (isDamengConnectionError(systemCatalogError)) {
throw systemCatalogError;
}
try {
return independentIndexesFromDictionaryViews(schema, table);
} catch (Exception dictionaryViewError) {
dictionaryViewError.addSuppressed(systemCatalogError);
throw dictionaryViewError;
}
}
}
private List<IndexInfo> independentIndexesFromSystemCatalog(String schema, String table) throws Exception {
List<IndexInfo> result = new ArrayList<>();
// DM's ALL_INDEXES view performs privilege filtering across the complete catalog and can
// take tens of seconds in installations with very large index dictionaries (#10075).
// The DM JDBC driver and DBeaver instead resolve the requested table id first and read its
// SYS index rows directly. Keep the ALL_* implementation below as a compatibility and
// permission fallback for deployments that do not expose these catalog tables.
String sql = """
SELECT /*+ PARALLEL(1) */ index_object.NAME,
LISTAGG(column_object.NAME, ',') WITHIN GROUP (
ORDER BY SF_GET_INDEX_KEY_SEQ(index_metadata.KEYNUM, index_metadata.KEYINFO, column_object.COLID)
) AS COLUMNS,
index_metadata.ISUNIQUE,
index_metadata.TYPE$,
index_metadata.FLAG
FROM SYS.SYSOBJECTS schema_object
JOIN SYS.SYSOBJECTS table_object ON table_object.SCHID = schema_object.ID
AND table_object.TYPE$ = 'SCHOBJ' AND table_object.SUBTYPE$ = 'UTAB'
JOIN SYS.SYSOBJECTS index_object ON index_object.PID = table_object.ID
AND index_object.SUBTYPE$ = 'INDEX'
JOIN SYS.SYSINDEXES index_metadata ON index_metadata.ID = index_object.ID
JOIN SYS.SYSCOLUMNS column_object ON column_object.ID = table_object.ID
AND SF_COL_IS_IDX_KEY(
index_metadata.KEYNUM,
index_metadata.KEYINFO,
column_object.COLID
) = 1
WHERE schema_object.TYPE$ = 'SCH' AND schema_object.NAME = ? AND table_object.NAME = ?
AND NOT EXISTS (
SELECT 1
FROM SYS.SYSCONS constraint_metadata
WHERE constraint_metadata.TABLEID = table_object.ID
AND constraint_metadata.INDEXID = index_metadata.ID
AND constraint_metadata.TYPE$ IN ('P', 'U')
)
GROUP BY index_object.NAME, index_metadata.ISUNIQUE, index_metadata.TYPE$, index_metadata.FLAG
ORDER BY index_object.NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String indexName = rs.getString(1);
if (notBlank(indexName)) {
result.add(new IndexInfo(
indexName,
splitNonEmpty(coalesce(rs.getString(2)), ","),
"Y".equalsIgnoreCase(rs.getString(3)),
false,
null,
damengSystemIndexType(rs.getString(4), rs.getInt(5)),
null,
null
));
}
}
}
}
return result;
}
private List<IndexInfo> independentIndexesFromDictionaryViews(String schema, String table) throws Exception {
List<IndexInfo> result = new ArrayList<>();
// Primary-key and unique-constraint backing indexes are already represented in table DDL.
String sql = """
@@ -2774,6 +3018,13 @@ public final class DamengAgent extends AbstractJdbcAgent {
return result;
}
private static String damengSystemIndexType(String catalogType, int flags) {
if ((flags & 3) != 0) {
return "INTERNAL";
}
return "ST".equalsIgnoreCase(coalesce(catalogType).trim()) ? "SPATIAL" : catalogType;
}
static String indexDdl(String schema, String table, IndexInfo index) {
StringBuilder ddl = new StringBuilder("CREATE ");
if (index.getIs_unique()) {
@@ -21,7 +21,9 @@ import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLNonTransientConnectionException;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -111,6 +113,80 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(columnsSql.startsWith("SELECT /*+ PARALLEL(1) */"), columnsSql);
}
// #10221:dbx 在编辑器里的表没有选 schema 时,会以「不带 schema」的形态请求元数据
// (oracle / oceanbase-oracle 同款约定)。DM 会按会话当前 schema 解析未限定名,所以
// 这里必须先把空 schema 解析成当前 schema,否则会按 OWNER = '' 去查,列注释永远拿不到。
@Test
void resolvesBlankColumnSchemaFromTheSessionBeforeReadingComments() {
DamengAgent agent = new DamengAgent();
List<String> sessionQueries = new ArrayList<>();
List<String> boundOwners = new ArrayList<>();
TestSupport.setPrivateConnection(agent, sessionSchemaConnection("SYSDBA", sessionQueries, boundOwners));
List<ColumnInfo> columns = agent.getColumns("", "USERS");
Assertions.assertTrue(
sessionQueries.stream().anyMatch(sql -> sql.contains("SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')")),
sessionQueries.toString()
);
Assertions.assertTrue(boundOwners.contains("SYSDBA"), boundOwners.toString());
Assertions.assertFalse(boundOwners.contains(""), boundOwners.toString());
Assertions.assertEquals(1, columns.size());
Assertions.assertEquals("id comment", columns.get(0).getComment());
}
@Test
void resolvesBlankTableSchemaFromTheSessionBeforeListingTables() {
DamengAgent agent = new DamengAgent();
List<String> sessionQueries = new ArrayList<>();
List<String> boundOwners = new ArrayList<>();
TestSupport.setPrivateConnection(agent, sessionSchemaConnection("SYSDBA", sessionQueries, boundOwners));
List<TableInfo> tables = agent.listTables("");
Assertions.assertTrue(boundOwners.contains("SYSDBA"), boundOwners.toString());
Assertions.assertFalse(boundOwners.contains(""), boundOwners.toString());
Assertions.assertEquals(List.of("USERS"), tables.stream().map(TableInfo::getName).toList());
}
@Test
void resolvesBlankSchemaToTheConnectedUserWhenTheSessionCannotReportIt() {
DamengAgent agent = new DamengAgent();
setConnectedUsername(agent, "DBXUSER");
List<String> sessionQueries = new ArrayList<>();
List<String> boundOwners = new ArrayList<>();
TestSupport.setPrivateConnection(agent, sessionSchemaConnection(null, sessionQueries, boundOwners));
List<ColumnInfo> columns = agent.getColumns("", "USERS");
Assertions.assertTrue(boundOwners.contains("DBXUSER"), boundOwners.toString());
Assertions.assertFalse(boundOwners.contains(""), boundOwners.toString());
Assertions.assertEquals(1, columns.size());
Assertions.assertEquals("id comment", columns.get(0).getComment());
}
@Test
void keepsAnExplicitSchemaWithoutConsultingTheSession() {
DamengAgent agent = new DamengAgent();
List<String> sessionQueries = new ArrayList<>();
List<String> boundOwners = new ArrayList<>();
TestSupport.setPrivateConnection(agent, sessionSchemaConnection("SYSDBA", sessionQueries, boundOwners));
agent.getColumns("APP", "USERS");
Assertions.assertTrue(boundOwners.contains("APP"), boundOwners.toString());
Assertions.assertTrue(sessionQueries.isEmpty(), sessionQueries.toString());
}
@Test
void effectiveMetadataSchemaPrefersTheSessionSchemaThenTheConnectedUser() {
Assertions.assertEquals("CURRENT_SCH", DamengAgent.effectiveMetadataSchema("CURRENT_SCH", "CONNECTED"));
Assertions.assertEquals("CONNECTED", DamengAgent.effectiveMetadataSchema("", "CONNECTED"));
Assertions.assertEquals("CONNECTED", DamengAgent.effectiveMetadataSchema(" ", "CONNECTED"));
Assertions.assertEquals("CONNECTED", DamengAgent.effectiveMetadataSchema(null, "CONNECTED"));
Assertions.assertEquals("", DamengAgent.effectiveMetadataSchema(null, null));
}
@Test
void disablesParallelExecutionForIndexMetadataQuery() {
DamengAgent agent = new DamengAgent();
@@ -1651,7 +1727,12 @@ class DamengAgentMetadataTest {
Arrays.asList("DEFAULT_VALUE", "VARCHAR2", "Y", null, null, 80, 20, null, "default length"),
Arrays.asList("WORD_VALUE", "VARCHAR2", "Y", null, null, 128, 32, "BYTE", "word byte length"),
Arrays.asList("NATIONAL_VALUE", "NVARCHAR2", "Y", null, null, 80, 20, "C", "national length"),
Arrays.asList("NATIONAL_FIXED", "NCHAR", "Y", null, null, 40, 10, "B", "national fixed length")
Arrays.asList("NATIONAL_FIXED", "NCHAR", "Y", null, null, 40, 10, "B", "national fixed length"),
// DM8 reports CHAR_USED as 'B' for byte- and character-length columns alike and
// records the declared unit in SCALE instead.
Arrays.asList("SCALE_CHAR_VAR", "VARCHAR2", "Y", null, 7, 40, 10, "B", "dameng char unit"),
Arrays.asList("SCALE_CHAR_FIXED", "CHAR", "Y", null, 8, 10, 10, "B", "dameng fixed char unit"),
Arrays.asList("SCALE_BYTE_VAR", "VARCHAR2", "Y", null, 0, 10, 10, "B", "dameng byte unit")
)));
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
@@ -1663,18 +1744,49 @@ class DamengAgentMetadataTest {
Assertions.assertEquals("VARCHAR2(128 BYTE)", columns.get(4).getData_type());
Assertions.assertEquals("NVARCHAR2(20)", columns.get(5).getData_type());
Assertions.assertEquals("NCHAR(10)", columns.get(6).getData_type());
Assertions.assertEquals("VARCHAR2(10 CHAR)", columns.get(7).getData_type());
Assertions.assertEquals("CHAR(10 CHAR)", columns.get(8).getData_type());
Assertions.assertEquals("VARCHAR2(10 BYTE)", columns.get(9).getData_type());
}
@Test
void readsDeclaredLengthWhenAllTabColumnsHasNoCharacterLength() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, metadataConnectionForColumns(
List.of(
// DM8 leaves CHAR_LENGTH at 0 for NVARCHAR2 and reports the byte length in
// DATA_LENGTH, so the declared character length has to come from SYSCOLUMNS.
Arrays.asList("NATIONAL_VALUE", "NVARCHAR2", "Y", null, 7, 400, 0, null, "national length"),
Arrays.asList("BIG_CHAR_VALUE", "VARCHAR2", "Y", null, 7, 800, 0, "B", "char unit from scale")
),
List.of(Arrays.asList("NATIONAL_VALUE", 100), Arrays.asList("BIG_CHAR_VALUE", 200))
));
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
Assertions.assertEquals("NVARCHAR2(100)", columns.get(0).getData_type());
Assertions.assertEquals("VARCHAR2(200 CHAR)", columns.get(1).getData_type());
}
@Test
void appendsTableAndColumnCommentsToTableDdl() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, metadataConnection());
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, metadataConnection(
"id comment",
null,
false,
List.of(),
sqls
));
String ddl = agent.getTableDdl("APP", "USERS");
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
Assertions.assertTrue(ddl.contains("COMMENT ON TABLE \"APP\".\"USERS\" IS '用户示例表';"), ddl);
Assertions.assertTrue(ddl.contains("COMMENT ON COLUMN \"APP\".\"USERS\".\"ID\" IS 'id comment';"), ddl);
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_COL_COMMENTS")), String.join("\n", sqls));
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_CONS_COLUMNS")), String.join("\n", sqls));
}
@Test
@@ -1786,13 +1898,74 @@ class DamengAgentMetadataTest {
ddl.contains("CREATE UNIQUE INDEX \"APP\".\"UX_USERS_EMAIL\" ON \"APP\".\"USERS\" (\"EMAIL\");"),
ddl
);
Assertions.assertTrue(
ddl.contains("CREATE SPATIAL INDEX \"APP\".\"IDX_USERS_GEO\" ON \"APP\".\"USERS\" (\"GEO\");"),
ddl
);
Assertions.assertFalse(ddl.contains("PK_USERS"), ddl);
String indexSql = sqls.stream().filter(sql -> sql.contains("ALL_INDEXES")).findFirst().orElseThrow();
Assertions.assertTrue(indexSql.contains("CONSTRAINT_TYPE IN ('P', 'U')"), indexSql);
String indexSql = sqls.stream().filter(sql -> sql.contains("SYS.SYSINDEXES")).findFirst().orElseThrow();
Assertions.assertTrue(indexSql.contains("constraint_metadata.TYPE$ IN ('P', 'U')"), indexSql);
Assertions.assertTrue(indexSql.contains("schema_object.NAME = ?"), indexSql);
Assertions.assertTrue(indexSql.contains("table_object.NAME = ?"), indexSql);
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_INDEXES")), String.join("\n", sqls));
long dbmsMetadataCalls = sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count();
Assertions.assertEquals(1, dbmsMetadataCalls);
}
@Test
void fallsBackToDictionaryViewsWhenSystemIndexCatalogIsUnavailable() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, metadataConnection(
"id comment",
null,
false,
List.of(indexRow("IDX_USERS_NAME", "NAME", "N", "BT", 0)),
sqls,
"CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);",
defaultColumnMetadataRows("id comment"),
null,
new SQLException("no SYS index catalog privilege")
));
String ddl = agent.getTableDdl("APP", "USERS");
Assertions.assertTrue(
ddl.contains("CREATE INDEX \"APP\".\"IDX_USERS_NAME\" ON \"APP\".\"USERS\" (\"NAME\");"),
ddl
);
int systemQuery = indexOfSql(sqls, "SYS.SYSINDEXES");
int fallbackQuery = indexOfSql(sqls, "ALL_INDEXES");
Assertions.assertTrue(systemQuery >= 0, String.join("\n", sqls));
Assertions.assertTrue(fallbackQuery > systemQuery, String.join("\n", sqls));
}
@Test
void doesNotRetryIndexCatalogAfterConnectionFailure() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, metadataConnection(
"id comment",
null,
false,
List.of(),
sqls,
"CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);",
defaultColumnMetadataRows("id comment"),
null,
new SQLNonTransientConnectionException("connection lost")
));
RuntimeException error = Assertions.assertThrows(
RuntimeException.class,
() -> agent.getTableDdl("APP", "USERS")
);
Assertions.assertEquals("connection lost", error.getCause().getMessage());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSINDEXES")), String.join("\n", sqls));
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_INDEXES")), String.join("\n", sqls));
}
@Test
void skipsDamengInternalIndexesWhenAppendingTableDdl() {
DamengAgent agent = new DamengAgent();
@@ -1802,8 +1975,8 @@ class DamengAgentMetadataTest {
null,
false,
List.of(
indexRow("IDX_USERS_NAME", "NAME", "NONUNIQUE", "NORMAL"),
indexRow("SYS_INTERNAL_DDL", "ID", "NONUNIQUE", "INNER CLUSTER INDEX")
indexRow("IDX_USERS_NAME", "NAME", "N", "BT", 0),
indexRow("SYS_INTERNAL_DDL", "ID", "N", "BT", 1)
),
sqls
));
@@ -2003,8 +2176,9 @@ class DamengAgentMetadataTest {
null,
false,
List.of(
indexRow("IDX_USERS_NAME", "NAME", "NONUNIQUE", "NORMAL"),
indexRow("UX_USERS_EMAIL", "EMAIL", "UNIQUE", "NORMAL")
indexRow("IDX_USERS_NAME", "NAME", "N", "BT", 0),
indexRow("UX_USERS_EMAIL", "EMAIL", "Y", "BT", 0),
indexRow("IDX_USERS_GEO", "GEO", "N", "ST", 0)
),
sqls
);
@@ -2053,6 +2227,13 @@ class DamengAgentMetadataTest {
}
private static Connection metadataConnectionForColumns(List<List<Object>> columnRows) {
return metadataConnectionForColumns(columnRows, null);
}
private static Connection metadataConnectionForColumns(
List<List<Object>> columnRows,
List<List<Object>> declaredColumnLengths
) {
return metadataConnection(
"id comment",
null,
@@ -2060,7 +2241,10 @@ class DamengAgentMetadataTest {
List.of(),
null,
"CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);",
columnRows
columnRows,
null,
null,
declaredColumnLengths
);
}
@@ -2094,6 +2278,56 @@ class DamengAgentMetadataTest {
String dbmsMetadataDdl,
List<List<Object>> columnRows,
String dbmsMetadataError
) {
return metadataConnection(
allColumnComment,
fallbackColumnComment,
includeMaterializedView,
independentIndexes,
sqls,
dbmsMetadataDdl,
columnRows,
dbmsMetadataError,
null
);
}
private static Connection metadataConnection(
String allColumnComment,
String fallbackColumnComment,
boolean includeMaterializedView,
List<List<Object>> independentIndexes,
List<String> sqls,
String dbmsMetadataDdl,
List<List<Object>> columnRows,
String dbmsMetadataError,
SQLException systemIndexError
) {
return metadataConnection(
allColumnComment,
fallbackColumnComment,
includeMaterializedView,
independentIndexes,
sqls,
dbmsMetadataDdl,
columnRows,
dbmsMetadataError,
systemIndexError,
null
);
}
private static Connection metadataConnection(
String allColumnComment,
String fallbackColumnComment,
boolean includeMaterializedView,
List<List<Object>> independentIndexes,
List<String> sqls,
String dbmsMetadataDdl,
List<List<Object>> columnRows,
String dbmsMetadataError,
SQLException systemIndexError,
List<List<Object>> declaredColumnLengths
) {
boolean[] dbmsMetadataResultOpen = {false};
return proxy(Connection.class, (method, args) -> {
@@ -2115,9 +2349,18 @@ class DamengAgentMetadataTest {
if (sql.startsWith("SELECT NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH'")) {
return metadataStatement(List.of(List.of("APP"), List.of("EMPTY_SCHEMA"), List.of("SYSDBA")));
}
if (sql.contains("SYS.SYSINDEXES")) {
if (systemIndexError != null) {
return failingMetadataStatement(systemIndexError);
}
return metadataStatement(independentIndexes);
}
if (sql.contains("ALL_CONS_COLUMNS")) {
return metadataStatement(List.of(List.of("ID")));
}
if (sql.contains("SYS.SYSCOLUMNS") && sql.contains("LENGTH$")) {
return metadataStatement(declaredColumnLengths == null ? List.of() : declaredColumnLengths);
}
if (sql.contains("SYS.SYSCOLUMNS")) {
return metadataStatement(List.of(List.of("ID")));
}
@@ -2158,7 +2401,7 @@ class DamengAgentMetadataTest {
return metadataStatement(List.of());
}
if (sql.contains("ALL_INDEXES")) {
return metadataStatement(independentIndexes);
return metadataStatement(dictionaryIndexRows(independentIndexes));
}
if (sql.contains("ALL_TAB_COMMENTS")) {
List<List<Object>> rows = new ArrayList<>();
@@ -2186,7 +2429,9 @@ class DamengAgentMetadataTest {
return metadataStatement(List.of());
}
if (sql.contains("ALL_COL_COMMENTS") && !sql.contains("ALL_TAB_COLUMNS")) {
return metadataStatement(List.of());
return metadataStatement(
allColumnComment == null ? List.of() : List.of(List.of("ID", allColumnComment))
);
}
if (sql.contains("ALL_TAB_COLUMNS")) {
return metadataStatement(columnRows);
@@ -2234,8 +2479,26 @@ class DamengAgentMetadataTest {
});
}
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType) {
return List.of(name, columns, uniqueness, indexType);
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType, int flags) {
return List.of(name, columns, uniqueness, indexType, flags);
}
private static List<List<Object>> dictionaryIndexRows(List<List<Object>> systemRows) {
return systemRows.stream().map(row -> List.of(
row.get(0),
row.get(1),
"Y".equals(row.get(2)) ? "UNIQUE" : "NONUNIQUE",
"ST".equals(row.get(3)) ? "SPATIAL" : "NORMAL"
)).toList();
}
private static int indexOfSql(List<String> sqls, String fragment) {
for (int i = 0; i < sqls.size(); i++) {
if (sqls.get(i).contains(fragment)) {
return i;
}
}
return -1;
}
private static PreparedStatement dbmsMetadataStatement(String ddl, boolean[] resultOpen) {
@@ -2699,6 +2962,75 @@ class DamengAgentMetadataTest {
});
}
// Serves the session-schema probe, records every first bound parameter (the OWNER of the
// metadata queries under test) and answers the column/table queries these tests assert on.
private static Connection sessionSchemaConnection(
String currentSchema,
List<String> sessionQueries,
List<String> boundOwners
) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("createStatement".equals(name)) {
return proxy(Statement.class, (statementMethod, statementArgs) -> {
String statementName = statementMethod.getName();
if ("executeQuery".equals(statementName)) {
String sql = (String) statementArgs[0];
sessionQueries.add(sql);
if (sql.contains("SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')")) {
if (currentSchema == null) {
throw new SQLException("SYS_CONTEXT is not supported");
}
return metadataResultSet(List.of(List.of(currentSchema)));
}
return metadataResultSet(List.of());
}
if ("close".equals(statementName)) {
return null;
}
return defaultValue(statementMethod.getReturnType());
});
}
if ("prepareStatement".equals(name)) {
String sql = (String) args[0];
if (sql.contains("ALL_TAB_COLUMNS")) {
return ownerRecordingStatement(defaultColumnMetadataRows("id comment"), boundOwners);
}
if (sql.contains("ALL_OBJECTS") && sql.contains("ALL_TAB_COMMENTS")) {
return ownerRecordingStatement(List.of(Arrays.asList("USERS", "TABLE", "用户示例表")), boundOwners);
}
return metadataStatement(List.of());
}
if ("close".equals(name)) {
return null;
}
if ("isClosed".equals(name)) {
return false;
}
return defaultValue(method.getReturnType());
});
}
private static PreparedStatement ownerRecordingStatement(List<List<Object>> rows, List<String> owners) {
return proxy(PreparedStatement.class, (method, args) -> {
String name = method.getName();
if ("executeQuery".equals(name)) {
return metadataResultSet(rows);
}
if (("setString".equals(name) || "setObject".equals(name)) && Integer.valueOf(1).equals(args[0])) {
owners.add(args[1] == null ? null : String.valueOf(args[1]));
return null;
}
if ("setString".equals(name) || "setObject".equals(name)) {
return null;
}
if ("close".equals(name)) {
return null;
}
return defaultValue(method.getReturnType());
});
}
private static PreparedStatement metadataStatement(List<List<Object>> rows) {
return metadataStatement(rows, null);
}
@@ -2761,6 +3093,10 @@ class DamengAgentMetadataTest {
default -> null;
};
}
if ("getInt".equals(name) && args[0] instanceof Integer columnIndex) {
Object value = rows.get(index[0]).get(columnIndex - 1);
return value instanceof Number number ? number.intValue() : Integer.parseInt(String.valueOf(value));
}
if ("getCharacterStream".equals(name) && args[0] instanceof Integer columnIndex) {
Object value = rows.get(index[0]).get(columnIndex - 1);
if (value instanceof LongText longText) {
+227 -7
View File
@@ -1316,6 +1316,168 @@ dependencies = [
"zip 4.6.1",
]
[[package]]
name = "dbx-driver-agent"
version = "0.1.0"
dependencies = [
"chrono",
"crc",
"dbx-platform",
"dbx-sql-core",
"dbx-types",
"flate2",
"futures",
"log",
"percent-encoding",
"reqwest",
"serde",
"serde_json",
"sha2 0.10.9",
"sysinfo",
"tar",
"tempfile",
"tokio",
"tokio-util",
"uuid",
"zip 4.6.1",
"zstd",
]
[[package]]
name = "dbx-driver-elasticsearch"
version = "0.1.0"
dependencies = [
"dbx-driver-support",
"dbx-types",
"percent-encoding",
"regex",
"reqwest",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "dbx-driver-mongodb"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dbx-driver-support",
"dbx-types",
"futures",
"json5",
"log",
"mongodb",
"percent-encoding",
"reqwest",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"uuid",
]
[[package]]
name = "dbx-driver-mysql"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-driver-support",
"dbx-sql-core",
"dbx-sql-dialect",
"dbx-types",
"futures",
"log",
"mysql_async",
"percent-encoding",
"rust_decimal",
"serde_json",
"sqlparser",
"tokio",
"tokio-util",
]
[[package]]
name = "dbx-driver-postgres"
version = "0.1.0"
dependencies = [
"bytes",
"chrono",
"dbx-driver-support",
"dbx-sql-core",
"dbx-types",
"deadpool-postgres",
"futures",
"log",
"openssl",
"percent-encoding",
"postgres-openssl",
"rustls 0.23.42",
"rustls-pemfile 2.2.0",
"serde_json",
"sqlparser",
"tokio",
"tokio-postgres",
"tokio-postgres-rustls",
"tokio-util",
"uuid",
"webpki-roots 0.26.11",
]
[[package]]
name = "dbx-driver-redis"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"dbx-driver-support",
"dbx-types",
"redis",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "dbx-driver-sqlserver"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-driver-support",
"dbx-sql-core",
"dbx-sql-data",
"dbx-types",
"futures",
"log",
"serde",
"serde_json",
"sqlparser",
"tiberius",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "dbx-driver-support"
version = "0.1.0"
dependencies = [
"bytes",
"dbx-platform",
"dbx-types",
"portpicker",
"reqwest",
"russh",
"serde",
"serde_json",
"sqlparser",
"tokio",
"tokio-util",
"uuid",
]
[[package]]
name = "dbx-drivers"
version = "0.1.0"
@@ -1325,8 +1487,18 @@ dependencies = [
"chrono",
"crc",
"csv",
"dbx-driver-agent",
"dbx-driver-elasticsearch",
"dbx-driver-mongodb",
"dbx-driver-mysql",
"dbx-driver-postgres",
"dbx-driver-redis",
"dbx-driver-sqlserver",
"dbx-driver-support",
"dbx-platform",
"dbx-sql",
"dbx-sql-core",
"dbx-sql-data",
"dbx-sql-dialect",
"dbx-sqlite-worker",
"dbx-types",
"deadpool-postgres",
@@ -1334,14 +1506,12 @@ dependencies = [
"futures",
"json5",
"log",
"mongodb",
"mysql_async",
"openssl",
"pageant",
"percent-encoding",
"portpicker",
"postgres-openssl",
"redis",
"regex",
"reqwest",
"rusqlite",
@@ -1374,6 +1544,7 @@ name = "dbx-duckdb-driver"
version = "0.1.0"
dependencies = [
"chrono",
"chrono-tz",
"dbx-core",
"duckdb",
"log",
@@ -1434,23 +1605,72 @@ dependencies = [
[[package]]
name = "dbx-sql"
version = "0.1.0"
dependencies = [
"dbx-sql-core",
"dbx-sql-data",
"dbx-sql-dialect",
"dbx-sql-schema",
"dbx-types",
]
[[package]]
name = "dbx-sql-core"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-types",
"encoding_rs",
"log",
"regex",
"serde",
"serde_json",
"sqlparser",
"unicode-ident",
"winnow",
]
[[package]]
name = "dbx-sql-data"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-sql-core",
"dbx-sql-dialect",
"dbx-types",
"serde",
"serde_json",
"sqlparser",
"unicode-width",
]
[[package]]
name = "dbx-sql-dialect"
version = "0.1.0"
dependencies = [
"chrono",
"dbx-types",
"log",
"notify",
"percent-encoding",
"serde",
"serde_yaml",
"tokio",
]
[[package]]
name = "dbx-sql-schema"
version = "0.1.0"
dependencies = [
"dbx-sql-core",
"dbx-sql-dialect",
"dbx-types",
"log",
"rayon",
"regex",
"serde",
"serde_json",
"serde_yaml",
"sqlparser",
"tokio",
"unicode-ident",
"unicode-width",
"winnow",
]
[[package]]
+1
View File
@@ -24,6 +24,7 @@ test = false
[dependencies]
chrono = "0.4"
chrono-tz = "0.10"
duckdb = { version = "1.3.2", features = ["bundled", "parquet"] }
log = "0.4"
serde = { version = "1", features = ["derive"] }
+150 -4
View File
@@ -1,6 +1,8 @@
#![allow(clippy::items_after_test_module)]
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono::{DateTime, Duration as ChronoDuration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Offset, Utc};
use chrono_tz::Tz;
use duckdb::core::LogicalTypeId;
use duckdb::types::{TimeUnit, Value, ValueRef};
use crate::wire as db;
@@ -179,6 +181,53 @@ mod tests {
]]
);
}
#[test]
fn duckdb_execute_preserves_timezone_marker_only_for_timestamptz() {
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
con.execute_batch("SET TimeZone = 'Asia/Shanghai'").expect("set session timezone");
con.execute_batch("CREATE VIEW events AS SELECT TIMESTAMPTZ '2025-02-10 20:37:56+00' AS occurred_at")
.expect("create view");
let result = duckdb_execute(
&con,
"SELECT occurred_at, occurred_at::VARCHAR AS session_value, \
TIMESTAMP '2025-02-10 20:37:56' AS naive_value, \
NULL::TIMESTAMPTZ AS missing_value, \
TIMESTAMPTZ '2025-02-10 20:37:56.123456+00' AS precise_value FROM events",
)
.expect("query view with temporal values");
assert_eq!(result.rows[0][0], serde_json::json!("2025-02-11 04:37:56+08"));
assert_eq!(result.rows[0][1], serde_json::json!("2025-02-11 04:37:56+08"));
assert_eq!(result.rows[0][2], serde_json::json!("2025-02-10 20:37:56"));
assert_eq!(result.rows[0][3], serde_json::Value::Null);
assert_eq!(result.rows[0][4], serde_json::json!("2025-02-11 04:37:56.123456+08"));
}
#[test]
fn duckdb_execute_uses_dst_offset_for_each_instant() {
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
con.execute_batch("SET TimeZone = 'America/New_York'").expect("set session timezone");
let result = duckdb_execute(
&con,
"SELECT TIMESTAMPTZ '2025-01-15 12:00:00+00' AS winter, TIMESTAMPTZ '2025-07-15 12:00:00+00' AS summer",
)
.expect("execute timestamptz query");
assert_eq!(result.rows[0][0], serde_json::json!("2025-01-15 07:00:00-05"));
assert_eq!(result.rows[0][1], serde_json::json!("2025-07-15 08:00:00-04"));
}
#[test]
fn duckdb_session_timezone_accepts_fixed_offsets() {
assert!(DuckDbSessionTimezone::parse("+08").is_some());
assert!(DuckDbSessionTimezone::parse("-05:30").is_some());
assert!(DuckDbSessionTimezone::parse("+24").is_none());
assert_eq!(format_utc_offset(0), "+00");
assert_eq!(format_utc_offset(8 * 3600), "+08");
assert_eq!(format_utc_offset(-(5 * 3600 + 30 * 60)), "-05:30");
}
}
#[cfg(test)]
@@ -186,7 +235,12 @@ pub fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result<db::QueryRe
duckdb_execute_with_max_rows(con, sql, None)
}
fn duckdb_value_to_json(row: &duckdb::Row<'_>, idx: usize) -> serde_json::Value {
fn duckdb_value_to_json(
row: &duckdb::Row<'_>,
idx: usize,
timestamptz: bool,
session_timezone: Option<DuckDbSessionTimezone>,
) -> serde_json::Value {
let Ok(value_ref) = row.get_ref(idx) else {
return serde_json::Value::Null;
};
@@ -216,7 +270,12 @@ fn duckdb_value_to_json(row: &duckdb::Row<'_>, idx: usize) -> serde_json::Value
duckdb_time64_to_string(unit, value).map(serde_json::Value::String).unwrap_or(serde_json::Value::Null)
}
ValueRef::Timestamp(unit, value) => {
duckdb_timestamp_to_string(unit, value).map(serde_json::Value::String).unwrap_or(serde_json::Value::Null)
let formatted = if timestamptz {
duckdb_timestamptz_to_string(unit, value, session_timezone)
} else {
duckdb_timestamp_to_string(unit, value)
};
formatted.map(serde_json::Value::String).unwrap_or(serde_json::Value::Null)
}
ValueRef::Text(bytes) => std::str::from_utf8(bytes)
.map(|s| serde_json::Value::String(s.to_string()))
@@ -353,6 +412,87 @@ fn duckdb_time64_to_string(unit: TimeUnit, value: i64) -> Option<String> {
Some(format_temporal_without_empty_fraction(time.to_string()))
}
#[derive(Clone, Copy)]
enum DuckDbSessionTimezone {
Zone(Tz),
Fixed(FixedOffset),
}
impl DuckDbSessionTimezone {
fn parse(name: &str) -> Option<Self> {
let name = name.trim();
if let Ok(zone) = name.parse::<Tz>() {
return Some(Self::Zone(zone));
}
parse_fixed_utc_offset(name).map(Self::Fixed)
}
fn to_local(self, instant: DateTime<Utc>) -> (NaiveDateTime, i32) {
match self {
Self::Zone(zone) => {
let local = instant.with_timezone(&zone);
(local.naive_local(), local.offset().fix().local_minus_utc())
}
Self::Fixed(offset) => {
let local = instant.with_timezone(&offset);
(local.naive_local(), local.offset().fix().local_minus_utc())
}
}
}
}
fn parse_fixed_utc_offset(value: &str) -> Option<FixedOffset> {
let (sign, rest) = match value.as_bytes().first()? {
b'+' => (1, &value[1..]),
b'-' => (-1, &value[1..]),
_ => return None,
};
let (hours, minutes) = match rest.split_once(':') {
Some((hours, minutes)) => (hours.parse::<i32>().ok()?, minutes.parse::<i32>().ok()?),
None if rest.len() == 4 => (rest[..2].parse::<i32>().ok()?, rest[2..].parse::<i32>().ok()?),
None if rest.len() <= 2 => (rest.parse::<i32>().ok()?, 0),
None => return None,
};
if !(0..=23).contains(&hours) || !(0..=59).contains(&minutes) {
return None;
}
FixedOffset::east_opt(sign * (hours * 3600 + minutes * 60))
}
fn duckdb_session_timezone(con: &duckdb::Connection) -> Option<DuckDbSessionTimezone> {
let name: String = con.query_row("SELECT current_setting('TimeZone')", [], |row| row.get(0)).ok()?;
DuckDbSessionTimezone::parse(&name)
}
fn duckdb_timestamptz_to_string(
unit: TimeUnit,
value: i64,
session_timezone: Option<DuckDbSessionTimezone>,
) -> Option<String> {
let nanos = duckdb_time_unit_to_nanos(unit, value)?;
let seconds = nanos.div_euclid(1_000_000_000);
let nanos_remainder = nanos.rem_euclid(1_000_000_000) as u32;
let instant: DateTime<Utc> = DateTime::from_timestamp(seconds, nanos_remainder)?;
Some(match session_timezone {
Some(session_timezone) => {
let (local, offset_seconds) = session_timezone.to_local(instant);
format!("{}{}", format_naive_datetime(local), format_utc_offset(offset_seconds))
}
None => format!("{}+00", format_naive_datetime(instant.naive_utc())),
})
}
fn format_utc_offset(offset_seconds: i32) -> String {
let sign = if offset_seconds < 0 { '-' } else { '+' };
let total_minutes = offset_seconds.unsigned_abs() / 60;
let (hours, minutes) = (total_minutes / 60, total_minutes % 60);
if minutes == 0 {
format!("{sign}{hours:02}")
} else {
format!("{sign}{hours:02}:{minutes:02}")
}
}
fn duckdb_timestamp_to_string(unit: TimeUnit, value: i64) -> Option<String> {
let nanos = duckdb_time_unit_to_nanos(unit, value)?;
let seconds = nanos.div_euclid(1_000_000_000);
@@ -397,6 +537,8 @@ pub fn duckdb_execute_with_max_rows(
let sql = sql.as_ref();
if crate::sql::starts_with_duckdb_result_sql_keyword(sql) {
// DuckDB cannot answer another query while a result set is being streamed.
let session_timezone = duckdb_session_timezone(con);
let mut stmt = con.prepare(sql).map_err(|e| e.to_string())?;
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
let stmt_ref = rows.as_ref().ok_or("DuckDB statement unavailable")?;
@@ -404,10 +546,14 @@ pub fn duckdb_execute_with_max_rows(
let columns: Vec<String> = (0..col_count)
.map(|i| stmt_ref.column_name(i).map(|s| s.to_string()).unwrap_or_else(|_| "?".to_string()))
.collect();
let timestamptz_columns: Vec<bool> =
(0..col_count).map(|i| stmt_ref.column_logical_type(i).id() == LogicalTypeId::TimestampTZ).collect();
let mut result_rows = Vec::new();
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
let vals: Vec<serde_json::Value> = (0..col_count).map(|i| duckdb_value_to_json(row, i)).collect();
let vals: Vec<serde_json::Value> = (0..col_count)
.map(|i| duckdb_value_to_json(row, i, timestamptz_columns[i], session_timezone))
.collect();
result_rows.push(vals);
if result_rows.len() > row_limit {
break;
+3 -1
View File
@@ -35,7 +35,9 @@ The following URL parameters are supported:
- `time_zone`
- `connect_retry_max`
- `connect_timeout_ms`
- `enable_compression=true`
- `enable_compression=true` (also accepts `rpc_compression` and the IoTDB JDBC
alias `rpc_compress`; when conflicting aliases are present, they take
precedence in that order)
- `node_urls=host1:6667,host2:6667` for cluster sessions
- `ssl=true` and `insecure_skip_verify=true`
+1 -1
View File
@@ -187,7 +187,7 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
}
config.ConnectTimeoutMS = parsed
}
config.EnableCompression = queryBool(query, "enable_compression", "rpc_compression")
config.EnableCompression = queryBool(query, "enable_compression", "rpc_compression", "rpc_compress")
config.NodeURLs = parseNodeURLs(firstQueryValue(query, "node_urls", "nodes"))
if len(config.NodeURLs) == 0 {
config.NodeURLs = []string{net.JoinHostPort(config.Host, strconv.Itoa(config.Port))}
+50
View File
@@ -70,6 +70,56 @@ func TestParseConnectionConfig(t *testing.T) {
}
}
func TestParseConnectionConfigCompressionAliases(t *testing.T) {
tests := []struct {
name string
connectionString string
urlParams string
want bool
}{
{name: "default disabled"},
{name: "documented enabled", urlParams: "enable_compression=true", want: true},
{name: "documented disabled", urlParams: "enable_compression=false"},
{name: "existing alias enabled", urlParams: "rpc_compression=true", want: true},
{name: "existing alias disabled", urlParams: "rpc_compression=false"},
{name: "JDBC alias enabled in URL parameters", urlParams: "rpc_compress=true", want: true},
{name: "JDBC alias false", urlParams: "rpc_compress=false"},
{
name: "JDBC alias enabled in connection string",
connectionString: "jdbc:iotdb://localhost:6667/?rpc_compress=true",
want: true,
},
{
name: "URL parameters override same connection string key",
connectionString: "jdbc:iotdb://localhost:6667/?rpc_compress=true",
urlParams: "rpc_compress=false",
},
{
name: "documented option takes precedence",
urlParams: "enable_compression=false&rpc_compression=true&rpc_compress=true",
},
{
name: "existing alias takes precedence over JDBC alias",
urlParams: "rpc_compression=false&rpc_compress=true",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
ConnectionString: test.connectionString,
URLParams: test.urlParams,
})
if err != nil {
t.Fatal(err)
}
if config.EnableCompression != test.want {
t.Fatalf("EnableCompression = %t, want %t", config.EnableCompression, test.want)
}
})
}
}
func TestBundledTimeZoneDatabase(t *testing.T) {
dataSet, err := client.NewIoTDBRpcDataSet(
"", nil, nil, nil, true, false, 0, 0, nil, 0, nil, 0, nil,
@@ -78,6 +78,15 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
Object value = rs.getBigDecimal(index);
return rs.wasNull() ? null : value;
});
case Types.BIT:
case Types.BOOLEAN:
// IRIS has no boolean type: %Boolean is stored as 1/0 and only
// the JDBC driver turns it into a Java boolean, which would
// surface as true/false in the grid. Report the stored 1/0.
return unchecked(() -> {
int value = rs.getInt(index);
return rs.wasNull() ? null : value;
});
case Types.OTHER:
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
default:
@@ -147,6 +147,47 @@ class IrisAgentTest {
assertEquals(Collections.singletonList("getObject"), calls);
}
@Test
void readsBitColumnsAsStoredNumbers() {
for (int sqlType : new int[] {Types.BIT, Types.BOOLEAN}) {
List<String> calls = new ArrayList<>();
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getInt".equals(method.getName())) {
calls.add("getInt");
return 1;
}
if (method.getName().startsWith("get")) {
calls.add(method.getName());
throw new AssertionError("IRIS %Boolean must not be read as a Java boolean");
}
if ("wasNull".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
Object value = new IrisAgent().resultValue(resultSet, 1, sqlType);
assertEquals(1, value);
assertEquals(Collections.singletonList("getInt"), calls);
}
}
@Test
void preservesNullForBitColumns() {
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getInt".equals(method.getName())) {
return 0;
}
if ("wasNull".equals(method.getName())) {
return true;
}
return defaultValue(method.getReturnType());
});
assertNull(new IrisAgent().resultValue(resultSet, 1, Types.BIT));
}
@Test
void readsColumnsAndUsesInlinePrimaryKey() {
List<String> calls = new ArrayList<>();
@@ -0,0 +1,51 @@
package com.dbx.agent.kafka;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.net.spi.InetAddressResolver;
import java.net.spi.InetAddressResolverProvider;
import java.util.Objects;
import java.util.stream.Stream;
public final class DbxInetAddressResolverProvider extends InetAddressResolverProvider {
private static volatile boolean avoidReverseDns;
@Override
public InetAddressResolver get(Configuration configuration) {
return wrapping(configuration.builtinResolver());
}
@Override
public String name() {
return "dbx-kafka";
}
static boolean setAvoidReverseDns(boolean avoid) {
boolean previous = avoidReverseDns;
avoidReverseDns = avoid;
return previous;
}
static InetAddressResolver wrapping(InetAddressResolver delegate) {
Objects.requireNonNull(delegate, "delegate");
return new InetAddressResolver() {
@Override
public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy)
throws UnknownHostException {
return delegate.lookupByName(host, lookupPolicy);
}
@Override
public String lookupByAddress(byte[] address) throws UnknownHostException {
if (!avoidReverseDns) {
return delegate.lookupByAddress(address);
}
Objects.requireNonNull(address, "address");
if (address.length != 4 && address.length != 16) {
throw new IllegalArgumentException("Invalid address length");
}
return InetAddress.getByAddress(address).getHostAddress();
}
};
}
}
@@ -46,6 +46,12 @@ public final class KafkaAgent {
private static final int DEFAULT_SESSION_TIMEOUT_MS = 30_000;
private static final int DEFAULT_ZOOKEEPER_CONNECTION_TIMEOUT_MS = 10_000;
private static final String ZOOKEEPER_PROPERTY_PREFIX = "zookeeper.";
private static final Set<String> SASL_MECHANISMS_WITHOUT_SERVER_NAME = Set.of(
"PLAIN",
"SCRAM-SHA-256",
"SCRAM-SHA-512",
"OAUTHBEARER"
);
private static final Set<String> KERBEROS_SYSTEM_PROPERTY_KEYS = Set.of(
"java.security.krb5.conf",
"sun.security.krb5.debug",
@@ -134,8 +140,17 @@ public final class KafkaAgent {
case "handshake" -> handshakeResult();
case "connect" -> connect(params);
case "test_connection" -> testConnection(params);
case "disconnect" -> { closeClients(); yield Collections.singletonMap("ok", true); }
case "shutdown" -> { closeClients(); shutdownRequested = true; yield Collections.singletonMap("ok", true); }
case "disconnect" -> {
closeClients();
DbxInetAddressResolverProvider.setAvoidReverseDns(false);
yield Collections.singletonMap("ok", true);
}
case "shutdown" -> {
closeClients();
DbxInetAddressResolverProvider.setAvoidReverseDns(false);
shutdownRequested = true;
yield Collections.singletonMap("ok", true);
}
// Topic management
case "mq_list_topics" -> listTopics(params);
case "mq_create_topic" -> createTopic(params);
@@ -175,6 +190,9 @@ public final class KafkaAgent {
private static Object connect(JsonObject params) throws Exception {
JsonObject conn = resolveBrokerConnection(connectionObject(params));
boolean previousReverseDnsMode = DbxInetAddressResolverProvider.setAvoidReverseDns(
shouldAvoidSaslReverseDns(conn)
);
Map<String, String> previousKerberosSystemProperties = applyKerberosSystemProperties(conn);
AdminClient nextAdmin = null;
KafkaProducer<String, byte[]> nextProducer = null;
@@ -198,12 +216,16 @@ public final class KafkaAgent {
nextProducer.close(Duration.ofSeconds(5));
}
restoreKerberosSystemProperties(previousKerberosSystemProperties);
DbxInetAddressResolverProvider.setAvoidReverseDns(previousReverseDnsMode);
throw e;
}
}
private static Object testConnection(JsonObject params) throws Exception {
JsonObject conn = resolveBrokerConnection(connectionObject(params));
boolean previousReverseDnsMode = DbxInetAddressResolverProvider.setAvoidReverseDns(
shouldAvoidSaslReverseDns(conn)
);
Map<String, String> previousKerberosSystemProperties = applyKerberosSystemProperties(conn);
AdminClient probe = null;
try {
@@ -244,6 +266,7 @@ public final class KafkaAgent {
probe.close(Duration.ofSeconds(5));
}
restoreKerberosSystemProperties(previousKerberosSystemProperties);
DbxInetAddressResolverProvider.setAvoidReverseDns(previousReverseDnsMode);
}
}
@@ -470,6 +493,25 @@ public final class KafkaAgent {
return protocol.isBlank() ? "PLAINTEXT" : protocol;
}
static boolean shouldAvoidSaslReverseDns(JsonObject conn) {
JsonObject properties = connectionProperties(conn);
String configuredProtocol = stringProperty(properties, "security.protocol");
String protocol = configuredProtocol == null || configuredProtocol.isBlank()
? securityProtocol(conn)
: configuredProtocol;
if (!protocol.trim().toUpperCase(Locale.ROOT).startsWith("SASL_")) {
return false;
}
String mechanism = stringOrEmpty(conn, "sasl_mechanism");
if (mechanism.isBlank()) mechanism = stringOrEmpty(conn, "saslMechanism");
String configuredMechanism = stringProperty(properties, "sasl.mechanism");
if (configuredMechanism != null && !configuredMechanism.isBlank()) {
mechanism = configuredMechanism;
}
return SASL_MECHANISMS_WITHOUT_SERVER_NAME.contains(mechanism.trim().toUpperCase(Locale.ROOT));
}
static void applySecurityProperties(JsonObject conn, Properties props) {
String securityProtocol = stringOrEmpty(conn, "security_protocol");
if (securityProtocol.isBlank()) {
@@ -0,0 +1 @@
com.dbx.agent.kafka.DbxInetAddressResolverProvider
@@ -0,0 +1,139 @@
package com.dbx.agent.kafka;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.net.spi.InetAddressResolver;
import java.net.spi.InetAddressResolverProvider;
import java.util.ServiceLoader;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DbxInetAddressResolverProviderTest {
@Test
void providerIsPublishedAsTheKafkaAgentResolverService() {
assertTrue(ServiceLoader.load(InetAddressResolverProvider.class).stream()
.anyMatch(provider -> provider.type() == DbxInetAddressResolverProvider.class));
}
@Test
void installedProviderShortCircuitsTheInetAddressCallUsedByKafka() throws Exception {
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(true);
try {
InetAddress numericAddress = InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 });
assertEquals("127.0.0.1", numericAddress.getHostName());
} finally {
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
}
}
@Test
void nonKerberosSaslReturnsNumericAddressWithoutCallingReverseDns() throws Exception {
AtomicInteger reverseLookups = new AtomicInteger();
InetAddressResolver resolver = DbxInetAddressResolverProvider.wrapping(
resolverReturning("broker.example.test", reverseLookups)
);
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(true);
try {
assertEquals("192.0.2.25", resolver.lookupByAddress(new byte[] {
(byte) 192, 0, 2, 25
}));
assertEquals(0, reverseLookups.get());
} finally {
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
}
}
@Test
void kerberosModeKeepsPlatformReverseDnsBehavior() throws Exception {
AtomicInteger reverseLookups = new AtomicInteger();
InetAddressResolver resolver = DbxInetAddressResolverProvider.wrapping(
resolverReturning("broker.example.test", reverseLookups)
);
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(false);
try {
assertEquals("broker.example.test", resolver.lookupByAddress(new byte[] {
(byte) 192, 0, 2, 25
}));
assertEquals(1, reverseLookups.get());
} finally {
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
}
}
@Test
void platformReverseDnsFailuresRemainVisibleWhenBypassIsDisabled() {
InetAddressResolver delegate = new InetAddressResolver() {
@Override
public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy) {
throw new AssertionError("forward lookup was not expected");
}
@Override
public String lookupByAddress(byte[] address) throws UnknownHostException {
throw new UnknownHostException("PTR lookup failed");
}
};
InetAddressResolver resolver = DbxInetAddressResolverProvider.wrapping(delegate);
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(false);
try {
UnknownHostException error = assertThrows(UnknownHostException.class,
() -> resolver.lookupByAddress(new byte[] { (byte) 192, 0, 2, 25 }));
assertEquals("PTR lookup failed", error.getMessage());
} finally {
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
}
}
@Test
void hostnameLookupAlwaysUsesThePlatformResolver() throws Exception {
AtomicInteger forwardLookups = new AtomicInteger();
InetAddressResolver delegate = new InetAddressResolver() {
@Override
public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy)
throws UnknownHostException {
forwardLookups.incrementAndGet();
return Stream.of(InetAddress.getByAddress(host, new byte[] { 127, 0, 0, 1 }));
}
@Override
public String lookupByAddress(byte[] address) {
throw new AssertionError("reverse lookup was not expected");
}
};
InetAddressResolver resolver = DbxInetAddressResolverProvider.wrapping(delegate);
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(true);
try {
InetAddress resolved = resolver
.lookupByName("broker.example.test", InetAddressResolver.LookupPolicy.of(
InetAddressResolver.LookupPolicy.IPV4
))
.findFirst()
.orElseThrow();
assertEquals("broker.example.test", resolved.getHostName());
assertEquals(1, forwardLookups.get());
} finally {
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
}
}
private static InetAddressResolver resolverReturning(String reverseResult, AtomicInteger reverseLookups) {
return new InetAddressResolver() {
@Override
public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy) {
throw new AssertionError("forward lookup was not expected");
}
@Override
public String lookupByAddress(byte[] address) {
reverseLookups.incrementAndGet();
return reverseResult;
}
};
}
}
@@ -581,6 +581,35 @@ class KafkaAgentTest {
assertNull(kafkaProperties.getProperty("zookeeper.ssl.trustStore.password"));
}
@Test
void reverseDnsIsSkippedOnlyForSupportedNonKerberosSaslMechanisms() {
assertTrue(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
"SASL_PLAINTEXT", "PLAIN")));
assertTrue(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
"SASL_SSL", "SCRAM-SHA-512")));
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
"SASL_PLAINTEXT", "GSSAPI")));
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
"PLAINTEXT", "PLAIN")));
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
"SSL", "PLAIN")));
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
"SASL_PLAINTEXT", "CUSTOM")));
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
"SASL_PLAINTEXT", "")));
}
@Test
void explicitKafkaPropertiesControlReverseDnsMode() {
JsonObject connection = connectionWithSecurity("SASL_PLAINTEXT", "PLAIN");
JsonObject properties = new JsonObject();
properties.addProperty("security.protocol", "SASL_SSL");
properties.addProperty("sasl.mechanism", "GSSAPI");
connection.add("properties", properties);
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connection));
}
@Test
void producerDefaultsToLegacyCompatibleNonIdempotentDelivery() {
JsonObject connection = new JsonObject();
@@ -592,6 +621,13 @@ class KafkaAgentTest {
assertEquals("false", properties.getProperty(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG));
}
private static JsonObject connectionWithSecurity(String protocol, String mechanism) {
JsonObject connection = new JsonObject();
connection.addProperty("security_protocol", protocol);
connection.addProperty("sasl_mechanism", mechanism);
return connection;
}
@Test
void producerAllowsExplicitIdempotenceForModernBrokers() {
JsonObject extraProperties = new JsonObject();
@@ -2,6 +2,12 @@ package com.dbx.agent.oceanbaseoracle;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.AgentProtocol;
import com.dbx.agent.CompletionAssistantCandidate;
import com.dbx.agent.CompletionAssistantCandidateKind;
import com.dbx.agent.CompletionAssistantMatchMode;
import com.dbx.agent.CompletionAssistantObjectKind;
import com.dbx.agent.CompletionAssistantRequest;
import com.dbx.agent.CompletionAssistantResponse;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseInfo;
@@ -21,6 +27,7 @@ import com.dbx.agent.PartitionInfo;
import com.dbx.agent.QueryPageOptions;
import com.dbx.agent.QueryPageResult;
import com.dbx.agent.QueryResult;
import com.dbx.agent.QueryTiming;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
@@ -28,14 +35,15 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -58,9 +66,6 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
"GGSYS", "FLOWS_FILES", "APEX_PUBLIC_USER", "GSMROOTUSER", "SYSRAC"
);
private boolean queryTimeoutChanged;
private String auditEligibleCursorId;
private static final String SQL_AUDIT_BY_TRACE = "SELECT EXECUTE_TIME, RETURN_ROWS FROM SYS.GV$OB_SQL_AUDIT "
+ "WHERE TRACE_ID = ? AND IS_INNER_SQL = 0 AND IS_EXECUTOR_RPC = 0 AND ROWNUM <= 2";
public static final JdbcAgentProfile OCEANBASE_ORACLE_PROFILE = new JdbcAgentProfile(
"com.oceanbase.jdbc.Driver",
@@ -80,97 +85,33 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
super(OCEANBASE_ORACLE_PROFILE);
}
@Override
public boolean supportsQueryTiming() { return true; }
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
auditEligibleCursorId = null;
QueryResult result = super.executeQuery(sql, schema, options);
if (!result.getTruncated() && !result.getColumns().isEmpty()) {
result.setServer_execute_time_us(availableServerExecuteTimeUs(
result.getRows().size(), JdbcExecutor.statementMaxRows(options.getMaxRows())
));
try (QueryTiming timing = QueryTiming.begin()) {
QueryResult result = super.executeQuery(sql, schema, options);
result.setQuery_timings_ms(timing.finish());
return result;
}
return result;
}
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
auditEligibleCursorId = null;
QueryPageResult result = super.executeQueryPage(sql, schema, options);
if (result.getHas_more()) {
auditEligibleCursorId = result.getSession_id();
try (QueryTiming timing = QueryTiming.begin()) {
QueryPageResult result = super.executeQueryPage(sql, schema, options);
result.setQuery_timings_ms(timing.finish());
return result;
}
return withCompletedCursorServerTiming(result);
}
@Override
public QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
boolean auditEligible = sessionId != null && sessionId.equals(auditEligibleCursorId);
QueryPageResult result = super.fetchQueryPage(sessionId, pageSize);
if (!result.getHas_more()) auditEligibleCursorId = null;
return auditEligible ? withCompletedCursorServerTiming(result) : result;
}
@Override
protected void beforeAgentMethod(String method, String querySessionId) {
if (!AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method)
|| auditEligibleCursorId == null
|| !auditEligibleCursorId.equals(querySessionId)) {
auditEligibleCursorId = null;
}
}
private QueryPageResult withCompletedCursorServerTiming(QueryPageResult result) {
// JdbcExecutor closes the ResultSet and Statement before returning a terminal
// page. Never issue audit SQL while Connector/J still owns an open cursor.
// executePage limits rows while reading, without Statement.setMaxRows.
// Preserve its JDBC default of 0 here; QueryPageOptions.maxRows is a
// client-side cap, not the statement limit used by Connector/J.
if (!result.getHas_more() && !result.getTruncated() && !result.getColumns().isEmpty()) {
result.setServer_execute_time_us(availableServerExecuteTimeUs(result.getCursor_rows_read(), 0));
}
return result;
}
private Long availableServerExecuteTimeUs(long expectedRows, int statementMaxRows) {
try {
return serverExecuteTimeUs(requireConnected(), expectedRows, statementMaxRows);
} catch (RuntimeException ignored) {
return null;
}
}
static Long serverExecuteTimeUs(Connection connection, long expectedRows, int statementMaxRows) {
try {
String traceId;
try (Statement statement = connection.createStatement()) {
// Connector/J resets the session's SQL_SELECT_LIMIT in execute prolog
// when this differs from the previous statement. That SET would
// replace the query's LAST_TRACE_ID before we can read it.
statement.setMaxRows(statementMaxRows);
statement.setQueryTimeout(1);
try (ResultSet traces = statement.executeQuery("SELECT LAST_TRACE_ID() FROM DUAL")) {
traceId = traces.next() ? traces.getString(1) : null;
}
}
if (traceId == null || traceId.isBlank()) return null;
try (PreparedStatement statement = connection.prepareStatement(SQL_AUDIT_BY_TRACE)) {
statement.setMaxRows(statementMaxRows);
statement.setQueryTimeout(1);
statement.setString(1, traceId);
try (ResultSet audit = statement.executeQuery()) {
if (!audit.next()) return null;
long executionUs = audit.getLong(1);
if (audit.wasNull() || executionUs < 0) return null;
long returnedRows = audit.getLong(2);
if (audit.wasNull() || returnedRows != expectedRows || audit.next()) return null;
return executionUs;
}
}
} catch (SQLException | RuntimeException ignored) {
// SQL Audit can be disabled, unavailable to this account, or evicted.
// The user's query result remains valid; no wall-clock substitute is used.
return null;
try (QueryTiming timing = QueryTiming.begin()) {
QueryPageResult result = super.fetchQueryPage(sessionId, pageSize);
result.setQuery_timings_ms(timing.finish());
return result;
}
}
@@ -388,6 +329,330 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
});
}
@Override
public CompletionAssistantResponse completionAssistantSearch(CompletionAssistantRequest request) {
if (hasTableLikeCompletionKind(request.getObject_kinds())) {
return unchecked(() -> completionAssistantTables(request));
}
return super.completionAssistantSearch(request);
}
private CompletionAssistantResponse completionAssistantTables(CompletionAssistantRequest request) throws SQLException {
int limit = boundedCompletionLimit(request.getMax_results());
int scanLimit = Math.min(1000, Math.max(limit * 3, limit + 1));
String preferredSchema = preferredCompletionSchema(request);
CompletionTablesQuery query = buildCompletionTablesQuery(request, preferredSchema, scanLimit + 1);
List<CompletionTableRow> rows = new ArrayList<>();
try (PreparedStatement stmt = requireConnection().prepareStatement(query.sql)) {
bindCompletionArgs(stmt, query.args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
rows.add(new CompletionTableRow(
rs.getString(1),
rs.getString(2),
rs.getString(3),
rs.getString(4),
rs.getString(5)
));
}
}
}
Set<CompletionSynonymTarget> validTargets = validCompletionSynonymTargets(
rows,
completionTableObjectTypes(request.getObject_kinds())
);
List<CompletionAssistantCandidate> candidates = new ArrayList<>();
for (CompletionTableRow row : rows) {
if (row.name == null || row.name.isBlank()) {
continue;
}
if ("SYNONYM".equalsIgnoreCase(row.objectType)
&& (row.targetOwner == null || row.targetName == null
|| !validTargets.contains(new CompletionSynonymTarget(row.targetOwner, row.targetName)))) {
continue;
}
CompletionAssistantCandidateKind kind = "VIEW".equalsIgnoreCase(row.objectType)
? CompletionAssistantCandidateKind.VIEW
: CompletionAssistantCandidateKind.TABLE;
candidates.add(new CompletionAssistantCandidate(
row.name,
kind,
blankToNull(request.getDatabase()),
row.owner,
null,
null,
null,
row.objectType
));
}
boolean incomplete = candidates.size() > limit;
if (incomplete) {
candidates = new ArrayList<>(candidates.subList(0, limit));
}
return new CompletionAssistantResponse(candidates, incomplete, false);
}
static CompletionTablesQuery buildCompletionTablesQuery(
CompletionAssistantRequest request,
String preferredSchema,
int limit
) {
List<String> objectTypes = completionTableObjectTypes(request.getObject_kinds());
boolean caseSensitive = request.getCase_sensitive();
String pattern = completionLikePattern(request.getMask(), request.getMatch_mode());
// OceanBase Oracle matches metadata the same way as listTables: fold the
// bind value in Java and compare UPPER(column) LIKE ?. UPPER(?) on binds
// is unreliable for fuzzy/lowercase masks in this driver.
if (!caseSensitive) {
pattern = pattern.toUpperCase(Locale.ROOT);
}
List<Object> args = new ArrayList<>();
String namePredicate = completionNamePredicate("o.OBJECT_NAME", caseSensitive);
String synonymNamePredicate = completionNamePredicate("s.SYNONYM_NAME", caseSensitive);
args.add(pattern);
String ownerPredicate = "";
String synonymOwnerPredicate = "";
String owner = "";
if (!request.getGlobal_search()) {
owner = firstNonBlank(request.getParent_schema(), request.getSchema(), preferredSchema);
if (owner == null) {
owner = "";
}
owner = owner.toUpperCase(Locale.ROOT);
args.add(owner);
ownerPredicate = " AND UPPER(o.OWNER) = ?";
}
args.add(pattern);
if (!owner.isEmpty()) {
args.add(owner);
synonymOwnerPredicate = " AND UPPER(s.OWNER) = ?";
}
String preferred = preferredSchema == null ? "" : preferredSchema.trim();
String preferredFolded = caseSensitive ? preferred : preferred.toUpperCase(Locale.ROOT);
String exactMask = request.getMask() == null ? "" : request.getMask().trim();
String exactFolded = caseSensitive ? exactMask : exactMask.toUpperCase(Locale.ROOT);
args.add(preferredFolded);
args.add(exactFolded);
args.add(limit);
String typeList = String.join(", ", objectTypes);
String baseSql = """
SELECT o.OWNER,
o.OBJECT_NAME,
o.OBJECT_TYPE,
CAST(NULL AS VARCHAR2(128)) AS TARGET_OWNER,
CAST(NULL AS VARCHAR2(128)) AS TARGET_NAME
FROM ALL_OBJECTS o
WHERE o.OBJECT_TYPE IN (%s)
AND %s%s
UNION ALL
SELECT s.OWNER,
s.SYNONYM_NAME AS OBJECT_NAME,
'SYNONYM' AS OBJECT_TYPE,
s.TABLE_OWNER AS TARGET_OWNER,
s.TABLE_NAME AS TARGET_NAME
FROM ALL_SYNONYMS s
WHERE s.DB_LINK IS NULL
AND %s%s
""".formatted(typeList, namePredicate, ownerPredicate, synonymNamePredicate, synonymOwnerPredicate)
.stripIndent()
.trim();
String preferredOwnerPredicate = caseSensitive ? "OWNER = ?" : "UPPER(OWNER) = ?";
String exactNamePredicate = caseSensitive
? "OBJECT_NAME = ?"
: "UPPER(OBJECT_NAME) = ?";
String orderedSql = """
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, TARGET_OWNER, TARGET_NAME
FROM (
%s
)
ORDER BY CASE
WHEN %s THEN 0
WHEN OWNER = 'PUBLIC' THEN 1
WHEN OWNER IN ('SYS','SYSTEM','SYSMAN','DBSNMP','OUTLN','XDB','MDSYS','CTXSYS','WMSYS') THEN 3
ELSE 2
END,
CASE WHEN %s THEN 0 ELSE 1 END,
CASE OBJECT_TYPE WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'SYNONYM' THEN 3 ELSE 4 END,
OBJECT_NAME,
OWNER
""".formatted(baseSql, preferredOwnerPredicate, exactNamePredicate).stripIndent().trim();
String sql = """
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, TARGET_OWNER, TARGET_NAME
FROM (
%s
)
WHERE ROWNUM <= ?
""".formatted(orderedSql).stripIndent().trim();
return new CompletionTablesQuery(sql, args);
}
private static List<String> completionTableObjectTypes(List<CompletionAssistantObjectKind> kinds) {
List<String> objectTypes = new ArrayList<>();
boolean any = kinds == null || kinds.isEmpty();
if (any || kinds.contains(CompletionAssistantObjectKind.TABLE)) {
objectTypes.add("'TABLE'");
}
if (any || kinds.contains(CompletionAssistantObjectKind.VIEW)) {
objectTypes.add("'VIEW'");
}
if (objectTypes.isEmpty()) {
objectTypes.add("'TABLE'");
objectTypes.add("'VIEW'");
}
return objectTypes;
}
private static boolean hasTableLikeCompletionKind(List<CompletionAssistantObjectKind> kinds) {
if (kinds == null || kinds.isEmpty()) {
return true;
}
for (CompletionAssistantObjectKind kind : kinds) {
if (kind == CompletionAssistantObjectKind.TABLE || kind == CompletionAssistantObjectKind.VIEW) {
return true;
}
}
return false;
}
private String preferredCompletionSchema(CompletionAssistantRequest request) throws SQLException {
String preferred = firstNonBlank(request.getSchema());
if (preferred != null && !preferred.isBlank()) {
return preferred;
}
String current = currentSchema();
return current == null ? "" : current;
}
private static String completionLikePattern(String mask, CompletionAssistantMatchMode matchMode) {
String escaped = escapeLikePattern(mask == null ? "" : mask.trim());
if (matchMode == CompletionAssistantMatchMode.CONTAINS) {
return "%" + escaped + "%";
}
return escaped + "%";
}
private static String escapeLikePattern(String mask) {
return mask.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
private static String completionNamePredicate(String column, boolean caseSensitive) {
if (caseSensitive) {
return column + " LIKE ? ESCAPE '\\'";
}
return "UPPER(" + column + ") LIKE ? ESCAPE '\\'";
}
private static int boundedCompletionLimit(Integer requested) {
if (requested == null || requested <= 0) {
return 100;
}
return Math.min(requested, 1000);
}
private static String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value.trim();
}
}
return null;
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
private static void bindCompletionArgs(PreparedStatement stmt, List<Object> args) throws SQLException {
for (int i = 0; i < args.size(); i++) {
Object arg = args.get(i);
if (arg instanceof Integer integer) {
stmt.setInt(i + 1, integer);
} else {
stmt.setString(i + 1, arg == null ? null : String.valueOf(arg));
}
}
}
private Set<CompletionSynonymTarget> validCompletionSynonymTargets(
List<CompletionTableRow> rows,
List<String> objectTypes
) throws SQLException {
Set<CompletionSynonymTarget> targets = new LinkedHashSet<>();
for (CompletionTableRow row : rows) {
if ("SYNONYM".equalsIgnoreCase(row.objectType) && row.targetOwner != null && row.targetName != null) {
targets.add(new CompletionSynonymTarget(row.targetOwner, row.targetName));
}
}
Set<CompletionSynonymTarget> valid = new HashSet<>();
if (targets.isEmpty()) {
return valid;
}
String typeList = String.join(", ", objectTypes);
List<CompletionSynonymTarget> ordered = new ArrayList<>(targets);
for (int start = 0; start < ordered.size(); start += 100) {
List<CompletionSynonymTarget> batch = ordered.subList(start, Math.min(start + 100, ordered.size()));
List<Object> args = new ArrayList<>();
List<String> predicates = new ArrayList<>();
for (CompletionSynonymTarget target : batch) {
args.add(target.owner());
args.add(target.name());
predicates.add("(o.OWNER = ? AND o.OBJECT_NAME = ?)");
}
String sql = "SELECT DISTINCT o.OWNER, o.OBJECT_NAME"
+ " FROM ALL_OBJECTS o"
+ " WHERE o.OBJECT_TYPE IN (" + typeList + ")"
+ " AND (" + String.join(" OR ", predicates) + ")";
try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
bindCompletionArgs(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
valid.add(new CompletionSynonymTarget(rs.getString(1), rs.getString(2)));
}
}
}
}
return valid;
}
record CompletionSynonymTarget(String owner, String name) {
}
static final class CompletionTableRow {
final String owner;
final String name;
final String objectType;
final String targetOwner;
final String targetName;
CompletionTableRow(String owner, String name, String objectType, String targetOwner, String targetName) {
this.owner = owner;
this.name = name;
this.objectType = objectType;
this.targetOwner = targetOwner;
this.targetName = targetName;
}
}
static final class CompletionTablesQuery {
final String sql;
final List<Object> args;
CompletionTablesQuery(String sql, List<Object> args) {
this.sql = sql;
this.args = List.copyOf(args);
}
}
private static MetadataSql oceanBaseMetadataSql(
String baseSql,
String selectList,
@@ -457,6 +722,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
String owner = normalizeSchema(schema);
String objectName = normalizeObjectName(name);
String normalizedType = normalizeObjectSourceType(objectType);
if (prefersDictionarySource(normalizedType)) {
return getDictionaryFirstObjectSource(owner, objectName, normalizedType);
}
String source;
SQLException metadataError = null;
try {
@@ -486,6 +754,30 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
});
}
private ObjectSource getDictionaryFirstObjectSource(String owner, String name, String objectType) throws SQLException {
String source;
SQLException dictionaryError = null;
try {
source = queryDictionarySource(owner, name, objectType);
} catch (SQLException e) {
dictionaryError = e;
source = null;
}
if (source == null || source.trim().isEmpty()) {
try {
source = queryDbmsMetadataSource(owner, name, objectType);
} catch (SQLException metadataError) {
if (dictionaryError != null) {
dictionaryError.addSuppressed(metadataError);
throw dictionaryError;
}
throw metadataError;
}
}
return new ObjectSource(name, objectType, owner, source == null ? "" : source);
}
private String queryDbmsMetadataSource(String owner, String name, String objectType) throws SQLException {
if ("SYNONYM".equals(objectType)) {
// GET_DDL does not cover synonyms on every supported OB version.
@@ -529,6 +821,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
stmt.setString(1, owner);
stmt.setString(2, name);
stmt.setString(3, sourceType);
stmt.setFetchSize(256);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String line = rs.getString(1);
@@ -559,6 +852,13 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
};
}
private static boolean prefersDictionarySource(String objectType) {
return switch (objectType) {
case "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "TRIGGER", "TYPE", "TYPE_BODY" -> true;
default -> false;
};
}
private static String editableOracleSource(String source) {
String trimmed = source.trim();
if (trimmed.isEmpty() || trimmed.regionMatches(true, 0, "CREATE ", 0, "CREATE ".length())) {
@@ -1,7 +1,10 @@
package com.dbx.agent.oceanbaseoracle;
import com.dbx.agent.AgentProtocol;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.CompletionAssistantMatchMode;
import com.dbx.agent.CompletionAssistantObjectKind;
import com.dbx.agent.CompletionAssistantRequest;
import com.dbx.agent.CompletionAssistantResponse;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.MetadataListConstraints;
@@ -137,28 +140,9 @@ class OceanBaseOracleAgentTest {
Assertions.assertThrows(IllegalArgumentException.class, () -> OceanBaseOracleAgent.queryTimeoutSql(-1));
}
@Test
void readsServerExecutionOnlyFromOneMatchingAuditRow() {
List<String> statements = new ArrayList<>();
List<String> parameters = new ArrayList<>();
List<Integer> maxRows = new ArrayList<>();
Assertions.assertEquals(370L, OceanBaseOracleAgent.serverExecuteTimeUs(
auditTimingConnection(1, 500, false, statements, parameters, maxRows), 500, 1001
));
Assertions.assertEquals(List.of(1001, 1001), maxRows,
"trace and audit statements must preserve the target statement's session row limit");
Assertions.assertEquals(List.of("trace-1"), parameters);
Assertions.assertTrue(statements.get(1).contains("IS_INNER_SQL = 0 AND IS_EXECUTOR_RPC = 0"));
Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(0, 500, false, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0));
Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(2, 500, false, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0));
Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(1, 1, false, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0));
Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(1, 500, true, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0));
}
@ParameterizedTest
@CsvSource({"10, 1000, false", "1, 1000, false", "1, 1, true", "2, 2, false"})
void preservesTheCursorStatementLimitWhenSamplingItsTerminalPage(int pageSize, int maxRows, boolean truncated) {
void returnsCursorRowsWithoutAdditionalAuditQueries(int pageSize, int maxRows, boolean truncated) {
List<Integer> auditLimits = new ArrayList<>();
List<String> auditSql = new ArrayList<>();
Connection auditConnection = auditTimingConnection(1, 2, false, auditSql, new ArrayList<>(), auditLimits);
@@ -215,12 +199,13 @@ class OceanBaseOracleAgentTest {
Assertions.assertEquals(truncated, result.getTruncated());
Assertions.assertEquals(truncated ? List.of(List.of(1)) : List.of(List.of(1), List.of(2)), rows);
Assertions.assertEquals(0, queryLimit[0], "the paging cap must not change the JDBC statement limit");
Assertions.assertEquals(truncated ? List.of() : List.of(0, 0), auditLimits);
Assertions.assertEquals(truncated ? null : Long.valueOf(370), result.getServer_execute_time_us());
Assertions.assertTrue(auditSql.isEmpty(), "completed queries must not read trace or audit records");
Assertions.assertTrue(auditLimits.isEmpty());
Assertions.assertNull(result.getServer_execute_time_us());
}
@Test
void doesNotAuditAnOpenCursorAfterAnotherSessionMethodCanReplaceItsTrace() {
void finishesCursorWithoutCreatingDiagnosticStatements() {
int[] row = {-1};
ResultSetMetaData meta = proxy(ResultSetMetaData.class, (method, args) -> {
if ("getColumnCount".equals(method.getName())) return 1;
@@ -255,11 +240,11 @@ class OceanBaseOracleAgentTest {
Assertions.assertTrue(first.getHas_more());
Assertions.assertEquals(2, statementsCreated[0]);
agent.beforeAgentMethod(AgentProtocol.METHOD_LIST_TABLES, null);
QueryPageResult last = agent.fetchQueryPage(first.getSession_id(), 1);
Assertions.assertFalse(last.getHas_more());
Assertions.assertNull(last.getServer_execute_time_us());
Assertions.assertEquals(2, statementsCreated[0], "no LAST_TRACE_ID query may run for an invalidated cursor");
Assertions.assertEquals(2, statementsCreated[0], "no diagnostic query may run when the cursor finishes");
}
@Test
@@ -460,6 +445,64 @@ class OceanBaseOracleAgentTest {
Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?"), sql.get(0));
}
@Test
void globalCompletionTableSearchOmitsOwnerFilter() {
CompletionAssistantRequest request = completionRequest("DWD", null, "STAG", true);
OceanBaseOracleAgent.CompletionTablesQuery query = OceanBaseOracleAgent.buildCompletionTablesQuery(request, "DWD", 21);
Assertions.assertTrue(query.sql.contains("FROM ALL_OBJECTS"), query.sql);
Assertions.assertTrue(query.sql.contains("FROM ALL_SYNONYMS"), query.sql);
Assertions.assertTrue(query.sql.contains("UPPER(o.OBJECT_NAME) LIKE ?"), query.sql);
Assertions.assertFalse(query.sql.contains("UPPER(o.OWNER) = ?"), query.sql);
Assertions.assertFalse(query.sql.contains("UPPER(s.OWNER) = ?"), query.sql);
Assertions.assertTrue(query.sql.contains("ROWNUM <= ?"), query.sql);
Assertions.assertEquals(List.of("STAG%", "STAG%", "DWD", "STAG", 21), query.args);
}
@Test
void completionTableSearchFoldsLowercaseMasksForFuzzyMatch() {
CompletionAssistantRequest request = completionRequest("dwd", null, "ord", true);
setField(request, "match_mode", CompletionAssistantMatchMode.CONTAINS);
OceanBaseOracleAgent.CompletionTablesQuery query = OceanBaseOracleAgent.buildCompletionTablesQuery(request, "dwd", 21);
Assertions.assertTrue(query.sql.contains("UPPER(o.OBJECT_NAME) LIKE ?"), query.sql);
Assertions.assertTrue(query.sql.contains("UPPER(OWNER) = ?"), query.sql);
Assertions.assertTrue(query.sql.contains("UPPER(OBJECT_NAME) = ?"), query.sql);
Assertions.assertFalse(query.sql.contains("LIKE UPPER(?)"), query.sql);
Assertions.assertEquals(List.of("%ORD%", "%ORD%", "DWD", "ORD", 21), query.args);
}
@Test
void scopedCompletionTableSearchFiltersOwnerCaseInsensitively() {
CompletionAssistantRequest request = completionRequest("dwd", "staging", "ord", false);
OceanBaseOracleAgent.CompletionTablesQuery query = OceanBaseOracleAgent.buildCompletionTablesQuery(request, "dwd", 21);
Assertions.assertTrue(query.sql.contains("UPPER(o.OWNER) = ?"), query.sql);
Assertions.assertTrue(query.sql.contains("UPPER(s.OWNER) = ?"), query.sql);
Assertions.assertEquals(List.of("ORD%", "STAGING", "ORD%", "STAGING", "DWD", "ORD", 21), query.args);
}
@Test
void completionAssistantSearchReturnsGlobalTableCandidates() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE", "TARGET_OWNER", "TARGET_NAME"},
new Object[][]{
{"DWD", "ORDERS", "TABLE", null, null},
{"STAGING", "ORDERS", "TABLE", null, null}
}
)));
CompletionAssistantResponse response = agent.completionAssistantSearch(completionRequest("DWD", null, "ORD", true));
Assertions.assertEquals(2, response.getCandidates().size());
Assertions.assertEquals("ORDERS", response.getCandidates().get(0).getName());
Assertions.assertEquals("DWD", response.getCandidates().get(0).getSchema());
Assertions.assertEquals("STAGING", response.getCandidates().get(1).getSchema());
Assertions.assertFalse(sql.get(0).contains("UPPER(o.OWNER) = ?"), sql.get(0));
}
@Test
void readsViewDdlWithDbmsMetadataForSchemaCompare() {
List<String> sql = new ArrayList<>();
@@ -578,7 +621,7 @@ class OceanBaseOracleAgentTest {
}
@Test
void fallsBackToAllSourceForOracleRoutineAndPackageTypes() {
void readsOracleRoutineAndPackageTypesFromAllSourceFirst() {
for (String[] object : new String[][]{
{"PROCEDURE", "PROCEDURE"},
{"FUNCTION", "FUNCTION"},
@@ -588,7 +631,7 @@ class OceanBaseOracleAgentTest {
List<String> sql = new ArrayList<>();
List<String> params = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, objectSourceFallbackConnection(
TestSupport.setPrivateConnection(agent, objectSourceConnection(
sql,
params,
resultSet(
@@ -602,14 +645,37 @@ class OceanBaseOracleAgentTest {
Assertions.assertEquals(object[0], source.getObject_type());
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE " + object[1]), source.getSource());
Assertions.assertEquals(
List.of(object[0], "MixedRoutine", "MixedOwner", "MixedOwner", "MixedRoutine", object[1]),
List.of("MixedOwner", "MixedRoutine", object[1]),
params
);
Assertions.assertTrue(sql.get(1).contains("ALL_SOURCE"), sql.get(1));
Assertions.assertTrue(sql.get(1).contains("ORDER BY LINE"), sql.get(1));
Assertions.assertEquals(1, sql.size());
Assertions.assertTrue(sql.get(0).contains("ALL_SOURCE"), sql.get(0));
Assertions.assertTrue(sql.get(0).contains("ORDER BY LINE"), sql.get(0));
}
}
@Test
void fallsBackToDbmsMetadataWhenAllSourceIsUnavailable() {
List<String> sql = new ArrayList<>();
List<String> params = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, objectSourceFallbackConnection(
sql,
params,
resultSet(
new String[]{"DDL"},
new Object[][]{{"CREATE OR REPLACE PROCEDURE APP.P1 AS BEGIN NULL; END;"}}
)
));
ObjectSource source = agent.getObjectSource("APP", "P1", "PROCEDURE");
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE PROCEDURE"), source.getSource());
Assertions.assertTrue(sql.get(0).contains("ALL_SOURCE"), sql.get(0));
Assertions.assertTrue(sql.get(1).contains("DBMS_METADATA.GET_DDL"), sql.get(1));
Assertions.assertEquals(List.of("APP", "P1", "PROCEDURE", "PROCEDURE", "P1", "APP"), params);
}
@Test
void synonymSourcePreservesOwnersQuotedNamesAndRemoteLinkDomains() {
for (String owner : List.of("Mixed.Owner", "PUBLIC")) {
@@ -1467,6 +1533,33 @@ class OceanBaseOracleAgentTest {
return null;
}
private static CompletionAssistantRequest completionRequest(
String schema,
String parentSchema,
String mask,
boolean globalSearch
) {
CompletionAssistantRequest request = new CompletionAssistantRequest();
setField(request, "database", "OBORCL");
setField(request, "schema", schema);
setField(request, "parent_schema", parentSchema);
setField(request, "mask", mask);
setField(request, "global_search", globalSearch);
setField(request, "max_results", 20);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.TABLE, CompletionAssistantObjectKind.VIEW));
return request;
}
private static void setField(Object target, String name, Object value) {
try {
var field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
}
private static <T> T proxy(Class<T> type, MethodHandler handler) {
InvocationHandler invocationHandler = new InvocationHandler() {
@Override
+114 -3
View File
@@ -3866,7 +3866,7 @@ func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSiz
if err != nil {
s.closeRows(rows)
var panicErr oracleDriverPanicError
if attempt == 0 && errors.As(err, &panicErr) {
if attempt == 0 && !oracleSQLLocksRows(sqlText) && errors.As(err, &panicErr) {
if placeholder, ok := oraclePlaceholderRetrySQL(sqlText, s.loadOracleColumnMeta); ok {
sqlText = placeholder
continue
@@ -4211,10 +4211,32 @@ func columnTypeNames(rows *sql.Rows) []string {
}
func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeoutSecs int, deferLOBs bool) (*sql.Rows, error) {
// A statement that keeps row locks while it runs is rewritten before the
// first attempt: when such a statement panics after the rows were locked,
// the locks stay behind and the retry below blocks on them until the query
// times out (issues #9344 / #9180).
if oracleSQLLocksRows(sqlText) {
rewritten, err := rewriteOracleSelectSQL(sqlText, s.loadOracleColumnMeta, deferLOBs)
if err == nil && rewritten != sqlText {
return s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
}
return s.queryRowsWithTimeout(sqlText, nil, timeoutSecs)
}
if deferLOBs {
rewritten, err := rewriteOracleSelectSQL(sqlText, s.loadOracleColumnMeta, true)
if err == nil && rewritten != sqlText {
return s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
rows, rewrittenErr := s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
if rewrittenErr == nil {
return rows, nil
}
// A deferred projection can still read a value the driver cannot
// decode (user-defined object/collection columns). Only a panic is
// worth retrying through the value-rewrite fallback below; other
// failures keep their own error.
var panicErr oracleDriverPanicError
if !errors.As(rewrittenErr, &panicErr) {
return nil, rewrittenErr
}
}
}
rows, err := s.queryRowsWithTimeout(sqlText, nil, timeoutSecs)
@@ -4502,6 +4524,7 @@ func parseSingleOracleTableRef(fromSQL string) (oracleTableRef, bool) {
ref.Table = second.Name
pos = skipSQLWhitespace(fromSQL, afterSecond)
}
pos = parseOracleDatabaseLinkSuffix(fromSQL, pos)
if pos < len(fromSQL) {
if strings.HasPrefix(strings.TrimLeft(fromSQL[pos:], " \t\r\n"), ",") {
return oracleTableRef{}, false
@@ -4536,6 +4559,31 @@ func parseSingleOracleTableRef(fromSQL string) (oracleTableRef, bool) {
return ref, true
}
// parseOracleDatabaseLinkSuffix consumes the `@link` (or `@!link`) suffix of a
// remote table reference so that the alias written after the link is still
// recognized (issues #9171 / #9344). Without it `schema.table@link t` parsed as
// an unaliased reference, so a `t.*` / `t.column` projection no longer matched
// the table and go-ora panicked on the user-defined type columns the value
// rewrite is meant to substitute.
//
// A suffix that cannot be read as a link name is left in place, which keeps the
// previous shape for connect-string links and other unparsable spellings.
func parseOracleDatabaseLinkSuffix(fromSQL string, pos int) int {
if pos >= len(fromSQL) || fromSQL[pos] != '@' {
return pos
}
linkStart := pos + 1
if linkStart < len(fromSQL) && fromSQL[linkStart] == '!' {
// `table@!link` keeps the same link name; the marker only changes where
// the link itself is resolved.
linkStart++
}
if _, afterLink, ok := readOracleIdentifierToken(fromSQL, linkStart); ok {
return skipSQLWhitespace(fromSQL, afterLink)
}
return pos
}
func splitOracleSelectListModifier(selectList string) (string, string) {
trimmedLeft := strings.TrimLeft(selectList, " \t\r\n")
prefixLen := len(selectList) - len(trimmedLeft)
@@ -4589,6 +4637,8 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
rewritten = append(rewritten, oracleGeometryExpression(column, columnRef, outputAlias))
} else if isOracleXMLType(column.DataType) && !deferLOBs {
rewritten = append(rewritten, oracleXMLSerializeExpression(columnRef, outputAlias))
} else if isOracleOpaqueObjectType(column) {
rewritten = append(rewritten, oracleOpaqueObjectExpression(column, columnRef, outputAlias))
} else if deferLOBs {
if expressions, ok := oracleDeferredLOBExpressions(columnRef, outputAlias, sourceIndex, column); ok {
rewritten = append(rewritten, expressions...)
@@ -4623,6 +4673,12 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
sourceIndex++
continue
}
if isOracleOpaqueObjectType(meta) {
rewritten = append(rewritten, oracleOpaqueObjectExpression(meta, columnRef, outputAlias))
changed = true
sourceIndex++
continue
}
if deferLOBs {
if expressions, isLOB := oracleDeferredLOBExpressions(columnRef, outputAlias, sourceIndex, meta); isLOB {
rewritten = append(rewritten, expressions...)
@@ -4676,6 +4732,41 @@ func oracleGeometryPlaceholder(column oracleColumnMeta) string {
return "<ST_GEOMETRY>"
}
// oracleOpaqueObjectExpression projects a placeholder instead of the value of a
// user-defined type (object, collection, ANYDATA, ...). go-ora cannot decode
// those values over the wire and panics while scanning them, which fails the
// whole statement and rolls the transaction back (issues #9344 / #9180). The
// placeholder keeps the statement and the remaining columns readable.
func oracleOpaqueObjectExpression(column oracleColumnMeta, columnRef, alias string) string {
return fmt.Sprintf("CASE WHEN %s IS NULL THEN NULL ELSE '%s' END AS %s", columnRef, oracleOpaqueObjectPlaceholder(column), alias)
}
func oracleOpaqueObjectPlaceholder(column oracleColumnMeta) string {
name := strings.ToUpper(strings.TrimSpace(column.DataType))
if name == "" {
name = "OBJECT"
}
return "<" + name + ">"
}
// oracleSQLLocksRows reports whether a statement holds row locks while it runs
// (FOR UPDATE, FOR UPDATE OF ..., NOWAIT / WAIT n / SKIP LOCKED). Such a
// statement must not be retried after a driver panic: the failed attempt keeps
// its locks, so the retry would block on them instead of returning.
func oracleSQLLocksRows(sqlText string) bool {
for searchFrom := 0; searchFrom < len(sqlText); {
pos := findTopLevelSQLKeyword(sqlText, searchFrom, "for")
if pos < 0 {
return false
}
if sqlKeywordAt(sqlText, skipSQLWhitespace(sqlText, pos+len("for")), "update") {
return true
}
searchFrom = pos + len("for")
}
return false
}
func oracleDeferredLOBKind(dataType string) (kind, placeholder string, ok bool) {
switch strings.ToUpper(strings.TrimSpace(dataType)) {
case "CLOB":
@@ -4773,7 +4864,15 @@ func oracleQualifierMatchesTable(qualifier string, tableRef oracleTableRef) bool
func oracleColumnsNeedValueRewrite(columns []oracleColumnMeta, deferLOBs bool) bool {
for _, column := range columns {
if isOracleGeometryType(column) || isOracleXMLType(column.DataType) || (deferLOBs && isOracleDeferredLOBType(column.DataType)) {
if isOracleGeometryType(column) || isOracleXMLType(column.DataType) {
return true
}
if deferLOBs && isOracleDeferredLOBType(column.DataType) {
return true
}
// Opaque user-defined types are substituted in both the plain and the
// deferred preview projection, so a preview never panics on them.
if isOracleOpaqueObjectType(column) {
return true
}
}
@@ -4822,6 +4921,18 @@ func isOracleGeometryType(column oracleColumnMeta) bool {
return isOracleSTGeometry(column) || isOracleSDOGeometry(column)
}
// isOracleOpaqueObjectType reports whether a column's declared type is a named
// user-defined type that the driver cannot decode. Built-in scalar types report
// no DATA_TYPE_OWNER; named types (object, collection, ANYDATA, ...) do. Types
// DBX is able to render — SDE/SDO geometry, XMLType and deferred LOBs — are
// matched earlier, so they are never treated as opaque here.
func isOracleOpaqueObjectType(column oracleColumnMeta) bool {
if isOracleGeometryType(column) || isOracleXMLType(column.DataType) || isOracleDeferredLOBType(column.DataType) {
return false
}
return strings.TrimSpace(column.DataTypeOwner) != ""
}
func leadingSQLSelectListStart(sqlText string) int {
trimmed := trimLeadingSQLComments(sqlText)
prefixLen := len(sqlText) - len(trimmed)
+250
View File
@@ -4095,3 +4095,253 @@ func (r *oracleManualTxRows) Next(dest []driver.Value) error {
r.next++
return nil
}
func TestOracleOpaqueObjectTypeDetection(t *testing.T) {
cases := []struct {
name string
column oracleColumnMeta
want bool
}{
{"object type", oracleColumnMeta{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"}, true},
{"collection type", oracleColumnMeta{Name: "V", DataType: "DBX9180_VARRAY", DataTypeOwner: "DBX_TEST"}, true},
{"anydata", oracleColumnMeta{Name: "V", DataType: "ANYDATA", DataTypeOwner: "SYS"}, true},
{"number", oracleColumnMeta{Name: "ID", DataType: "NUMBER"}, false},
{"varchar2", oracleColumnMeta{Name: "NOTE", DataType: "VARCHAR2"}, false},
{"clob", oracleColumnMeta{Name: "PAYLOAD", DataType: "CLOB"}, false},
{"xmltype", oracleColumnMeta{Name: "DOC", DataType: "XMLTYPE", DataTypeOwner: "SYS"}, false},
{"sdo geometry", oracleColumnMeta{Name: "SHAPE", DataType: "SDO_GEOMETRY", DataTypeOwner: "MDSYS"}, false},
{"sde geometry", oracleColumnMeta{Name: "SHAPE", DataType: "ST_GEOMETRY", DataTypeOwner: "SDE"}, false},
}
for _, testCase := range cases {
if got := isOracleOpaqueObjectType(testCase.column); got != testCase.want {
t.Fatalf("%s: isOracleOpaqueObjectType() = %v, want %v", testCase.name, got, testCase.want)
}
}
}
func TestRewriteOracleOpaqueObjectSelectStar(t *testing.T) {
sqlText, err := rewriteOracleSelectSQL(
`SELECT * FROM TEST_UDT`,
func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{
{Name: "ID", DataType: "NUMBER"},
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
{Name: "NOTE", DataType: "VARCHAR2"},
}, nil
},
false,
)
if err != nil {
t.Fatal(err)
}
want := `SELECT "ID", CASE WHEN "G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS "G", "NOTE" FROM TEST_UDT`
if sqlText != want {
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
}
}
func TestRewriteOracleOpaqueObjectExplicitColumn(t *testing.T) {
sqlText, err := rewriteOracleSelectSQL(
`SELECT t.G AS shape, t.ID FROM TEST_UDT t`,
func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{
{Name: "ID", DataType: "NUMBER"},
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
}, nil
},
false,
)
if err != nil {
t.Fatal(err)
}
want := `SELECT CASE WHEN t."G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS shape, t.ID FROM TEST_UDT t`
if sqlText != want {
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
}
}
func TestRewriteOracleKeepsBuiltinColumnsUntouched(t *testing.T) {
input := `SELECT ID, NOTE FROM TEST_PLAIN`
sqlText, err := rewriteOracleSelectSQL(
input,
func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{
{Name: "ID", DataType: "NUMBER"},
{Name: "NOTE", DataType: "VARCHAR2"},
{Name: "PAYLOAD", DataType: "CLOB"},
}, nil
},
false,
)
if err != nil {
t.Fatal(err)
}
if sqlText != input {
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, input)
}
}
func TestOracleSQLLocksRowsDetection(t *testing.T) {
cases := []struct {
name string
sql string
want bool
}{
{"for update", "SELECT * FROM T FOR UPDATE", true},
{"for update skip locked", "SELECT * FROM T FOR UPDATE SKIP LOCKED", true},
{"for update of column", "SELECT ID FROM T FOR UPDATE OF ID NOWAIT", true},
{"lowercase", "select id from t for update", true},
{"plain select", "SELECT * FROM T", false},
{"string literal", "SELECT 'FOR UPDATE' FROM T", false},
{"comment", "SELECT * FROM T -- FOR UPDATE", false},
{"subquery for update", "SELECT * FROM (SELECT ID FROM T FOR UPDATE) X", false},
{"column named update", "SELECT FOR_UPDATE FROM T", false},
}
for _, testCase := range cases {
if got := oracleSQLLocksRows(testCase.sql); got != testCase.want {
t.Fatalf("%s: oracleSQLLocksRows(%q) = %v, want %v", testCase.name, testCase.sql, got, testCase.want)
}
}
}
func TestRewriteOracleOpaqueObjectInDeferredProjection(t *testing.T) {
sqlText, err := rewriteOracleSelectSQL(
`SELECT * FROM TEST_UDT_LOB`,
func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{
{Name: "ID", DataType: "NUMBER"},
{Name: "DOC", DataType: "CLOB"},
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
}, nil
},
true,
)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(sqlText, `CASE WHEN "G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS "G"`) {
t.Fatalf("deferred rewrite did not substitute the opaque column: %s", sqlText)
}
if !strings.Contains(sqlText, `"DOC"`) {
t.Fatalf("deferred rewrite dropped the LOB column: %s", sqlText)
}
}
func TestParseSingleOracleTableRefReadsDatabaseLinkAndAlias(t *testing.T) {
cases := []struct {
name string
from string
want oracleTableRef
ok bool
}{
{
name: "unqualified link with alias",
from: `T_UDT@DBX_LOOP t`,
want: oracleTableRef{Table: "T_UDT", Alias: "T", AliasText: "t"},
ok: true,
},
{
name: "schema qualified link with alias",
from: `DBX_TEST.T_UDT@DBX_LOOP t`,
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT", Alias: "T", AliasText: "t"},
ok: true,
},
{
name: "remote link marker",
from: `DBX_TEST.T_UDT@!DBX_LOOP x`,
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT", Alias: "X", AliasText: "x"},
ok: true,
},
{
name: "quoted link followed by a clause",
from: `DBX_TEST.T_UDT@"Loop Link" WHERE ID > 0`,
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT"},
ok: true,
},
{
name: "link without alias",
from: `DBX_TEST.T_UDT@DBX_LOOP`,
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT"},
ok: true,
},
{
name: "local table is unchanged",
from: `DBX_TEST.T_UDT t`,
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT", Alias: "T", AliasText: "t"},
ok: true,
},
{
name: "link followed by another table is not a single reference",
from: `DBX_TEST.T_UDT@DBX_LOOP, DBX_TEST.OTHER`,
ok: false,
},
{
name: "link followed by a join is not a single reference",
from: `DBX_TEST.T_UDT@DBX_LOOP JOIN DBX_TEST.OTHER ON 1 = 1`,
ok: false,
},
{
name: "connect string link keeps the previous shape",
from: `DBX_TEST.T_UDT@'XE'`,
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT"},
ok: true,
},
}
for _, testCase := range cases {
got, ok := parseSingleOracleTableRef(testCase.from)
if ok != testCase.ok {
t.Fatalf("%s: parseSingleOracleTableRef(%q) ok = %v, want %v", testCase.name, testCase.from, ok, testCase.ok)
}
if !testCase.ok {
continue
}
if got != testCase.want {
t.Fatalf("%s: parseSingleOracleTableRef(%q) = %+v, want %+v", testCase.name, testCase.from, got, testCase.want)
}
}
}
func TestRewriteOracleOpaqueObjectProjectionKeepsLinkedAlias(t *testing.T) {
var gotSchema, gotTable string
sqlText, err := rewriteOracleSelectSQL(
`SELECT t.*, ROWIDTOCHAR(t.ROWID) AS "__DBX_PK_0" FROM DBX_TEST.T_UDT@DBX_LOOP t`,
func(schema, table string) ([]oracleColumnMeta, error) {
gotSchema, gotTable = schema, table
return []oracleColumnMeta{
{Name: "ID", DataType: "NUMBER"},
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
}, nil
},
false,
)
if err != nil {
t.Fatal(err)
}
if gotSchema != "DBX_TEST" || gotTable != "T_UDT" {
t.Fatalf("column metadata loaded for (%q, %q), want (DBX_TEST, T_UDT)", gotSchema, gotTable)
}
want := `SELECT t."ID", CASE WHEN t."G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS "G", ROWIDTOCHAR(t.ROWID) AS "__DBX_PK_0" FROM DBX_TEST.T_UDT@DBX_LOOP t`
if sqlText != want {
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
}
}
func TestRewriteOracleOpaqueObjectExplicitColumnKeepsLinkedAlias(t *testing.T) {
sqlText, err := rewriteOracleSelectSQL(
`SELECT t.ID, t.G FROM T_UDT@DBX_LOOP t`,
func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{
{Name: "ID", DataType: "NUMBER"},
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
}, nil
},
false,
)
if err != nil {
t.Fatal(err)
}
want := `SELECT t.ID, CASE WHEN t."G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS "G" FROM T_UDT@DBX_LOOP t`
if sqlText != want {
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
}
}
@@ -519,10 +519,17 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
+ "ORDER BY c.colid";
}
// sysobjects.xtype codes for the object kinds the object browser and the
// tree both ask object source for: P = stored procedure, FN = scalar
// function, V = view, TR = trigger. Views were missing, so "view DDL" on a
// SQL Server legacy connection failed with "Unsupported object type: VIEW"
// instead of returning the definition stored in syscomments (#10162).
private static String sqlServer2000ObjectXtype(String objectType) {
return switch (objectType) {
case "PROCEDURE" -> "P";
case "FUNCTION" -> "FN";
case "VIEW" -> "V";
case "TRIGGER" -> "TR";
default -> null;
};
}
@@ -3,6 +3,7 @@ package com.dbx.agent.sqlserverlegacy;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.test.TestSupport;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -126,6 +127,48 @@ class SqlServerLegacyAgentTest {
);
}
/**
* The object browser asks for object source with the object's kind, so a
* view must map to sysobjects.xtype 'V' (and a trigger to 'TR') instead of
* being rejected as an unsupported object type (#10162).
*/
@Test
void sqlServer2000ObjectSourceResolvesViewsAndTriggers() {
List<String> boundXtypes = new java.util.ArrayList<>();
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
TestSupport.setPrivateConnection(agent, objectSourceConnection(boundXtypes));
setSqlServer2000Mode(agent, true);
ObjectSource view = agent.getObjectSource("dbo", "V_ORDERS", "VIEW");
ObjectSource trigger = agent.getObjectSource("dbo", "TR_ORDERS", "TRIGGER");
ObjectSource procedure = agent.getObjectSource("dbo", "P_ORDERS", "PROCEDURE");
Assertions.assertEquals(List.of("V", "TR", "P"), boundXtypes);
Assertions.assertEquals("CREATE VIEW dbo.V_ORDERS AS SELECT 1", view.getSource());
Assertions.assertEquals("VIEW", view.getObject_type());
Assertions.assertEquals("dbo", view.getSchema());
// Legacy catalogs stay read-only in the editor.
Assertions.assertFalse(view.isEditable());
Assertions.assertEquals("CREATE VIEW dbo.V_ORDERS AS SELECT 1", trigger.getSource());
Assertions.assertEquals("CREATE VIEW dbo.V_ORDERS AS SELECT 1", procedure.getSource());
}
@Test
void sqlServer2000ObjectSourceStillRejectsKindsWithoutAnXtype() {
List<String> boundXtypes = new java.util.ArrayList<>();
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
TestSupport.setPrivateConnection(agent, objectSourceConnection(boundXtypes));
setSqlServer2000Mode(agent, true);
IllegalArgumentException error = Assertions.assertThrows(
IllegalArgumentException.class,
() -> agent.getObjectSource("dbo", "PKG_ORDERS", "PACKAGE")
);
Assertions.assertEquals("Unsupported object type: PACKAGE", error.getMessage());
Assertions.assertTrue(boundXtypes.isEmpty());
}
@Test
void sqlServer2000ColumnCommentsEnrichJdbcColumnRemarks() {
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
@@ -495,6 +538,42 @@ class SqlServerLegacyAgentTest {
});
}
/**
* Connection whose object-source query returns the ordered syscomments
* chunks and records the xtype the agent bound, so tests can assert the
* sysobjects.xtype mapping without a live legacy server.
*/
private static Connection objectSourceConnection(List<String> boundXtypes) {
return proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
String name = statementMethod.getName();
if ("setString".equals(name) && statementArgs != null && Integer.valueOf(3).equals(statementArgs[0])) {
boundXtypes.add((String) statementArgs[1]);
return null;
}
if ("executeQuery".equals(name)) {
return metadataResultSet(
Arrays.asList(
Arrays.asList("CREATE VIEW dbo.V_ORDERS AS "),
Arrays.asList("SELECT 1")
),
Map.of("SOURCE_TEXT", 0)
);
}
if ("close".equals(name)) {
return null;
}
return defaultValue(statementMethod.getReturnType());
});
}
if ("close".equals(method.getName()) || "isClosed".equals(method.getName())) {
return "isClosed".equals(method.getName()) ? Boolean.FALSE : null;
}
return defaultValue(method.getReturnType());
});
}
private static PreparedStatement commentStatement(List<List<Object>> rows) {
return proxy(PreparedStatement.class, (method, args) -> {
if ("executeQuery".equals(method.getName())) {
+9
View File
@@ -60,6 +60,15 @@ for platform in "${PLATFORMS[@]}"; do
cp "$native_file" "$WORK/drivers/"
done
# The SQLite SSH worker executes on the remote SSH host, so its Linux binaries
# must ship in every bundle: the desktop platform never matches them, which
# otherwise leaves the worker missing from offline packages (#8987).
for worker_file in "$RELEASE_DIR"/dbx-agent-sqlite-worker-*-linux-x64 \
"$RELEASE_DIR"/dbx-agent-sqlite-worker-*-linux-aarch64; do
[ -f "$worker_file" ] || continue
cp "$worker_file" "$WORK/drivers/"
done
ZIP_NAME="dbx-agents-offline-${platform}.zip"
ZIP_ENTRIES=(agent-registry.json jre/ drivers/)
[ -d "$WORK/jdbc" ] && ZIP_ENTRIES+=(jdbc/)
+38 -38
View File
@@ -1,48 +1,48 @@
{
"access": "0.1.63",
"bigquery": "0.1.67",
"cache": "0.1.11",
"access": "0.1.65",
"bigquery": "0.1.69",
"cache": "0.1.13",
"cassandra": "0.1.50",
"dameng": "0.1.75",
"databend": "0.1.54",
"databricks": "0.1.61",
"db2": "0.1.67",
"duckdb": "0.1.21",
"exasol": "0.1.61",
"firebird": "0.1.62",
"gbase8a": "0.1.61",
"gbase8s": "0.1.56",
"goldendb": "0.1.66",
"h2": "0.1.67",
"h2-legacy": "0.1.36",
"dameng": "0.1.77",
"databend": "0.1.56",
"databricks": "0.1.63",
"db2": "0.1.69",
"duckdb": "0.1.23",
"exasol": "0.1.63",
"firebird": "0.1.64",
"gbase8a": "0.1.63",
"gbase8s": "0.1.58",
"goldendb": "0.1.68",
"h2": "0.1.69",
"h2-legacy": "0.1.38",
"hive": "0.1.55",
"argo": "0.1.0",
"ignite": "0.1.19",
"ignite3": "0.1.19",
"spark": "0.1.38",
"informix": "0.1.67",
"ignite": "0.1.21",
"ignite3": "0.1.21",
"spark": "0.1.40",
"informix": "0.1.69",
"kingbase": "0.1.59",
"kylin": "0.1.66",
"kylin": "0.1.68",
"neo4j": "0.1.48",
"oceanbase-oracle": "0.1.63",
"oracle": "0.1.63",
"snowflake": "0.1.66",
"sqlite-worker": "0.1.3",
"spanner": "0.1.19",
"saphana": "0.1.62",
"sundb": "0.1.66",
"teradata": "0.1.61",
"tdengine": "0.1.49",
"trino": "0.1.66",
"oceanbase-oracle": "0.1.65",
"oracle": "0.1.64",
"snowflake": "0.1.68",
"sqlite-worker": "0.1.4",
"spanner": "0.1.21",
"saphana": "0.1.64",
"sundb": "0.1.68",
"teradata": "0.1.63",
"tdengine": "0.1.50",
"trino": "0.1.68",
"vastbase": "0.1.51",
"vertica": "0.1.61",
"mongodb": "0.1.69",
"highgo": "0.1.64",
"uxdb": "0.1.33",
"yashandb": "0.1.62",
"oscar": "0.1.41",
"vertica": "0.1.63",
"mongodb": "0.1.71",
"highgo": "0.1.66",
"uxdb": "0.1.35",
"yashandb": "0.1.64",
"oscar": "0.1.43",
"xugu": "0.1.47",
"iris": "0.1.60",
"iris": "0.1.62",
"iotdb": "0.1.41",
"etcd": "0.1.43",
"etcd2": "0.1.1",
@@ -50,5 +50,5 @@
"kafka": "0.1.14",
"rocketmq": "0.1.13",
"rabbitmq": "0.1.11",
"sqlserver-legacy": "0.1.39"
"sqlserver-legacy": "0.1.41"
}
+32 -7
View File
@@ -13,6 +13,10 @@
background-color: rgb(255 255 255);
}
body {
margin: 0;
}
html.dark,
html.dark body,
html.dark #root {
@@ -22,30 +26,47 @@
#startup-loading {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100vh;
align-items: center;
justify-content: center;
color: #6b7280;
font: 13px system-ui, sans-serif;
font:
14px system-ui,
sans-serif;
}
#startup-loading::before {
#startup-loading .startup-brand {
color: #111827;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
}
html.dark #startup-loading .startup-brand {
color: #f3f4f6;
}
#startup-loading .startup-spinner {
width: 22px;
height: 22px;
margin-bottom: 12px;
box-sizing: border-box;
border: 2px solid rgba(107, 114, 128, 0.22);
border-top-color: currentColor;
border-radius: 999px;
content: "";
animation: dbx-startup-spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
#startup-loading::before { animation: none; }
#startup-loading .startup-spinner {
animation: none;
}
}
@keyframes dbx-startup-spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
</style>
<script data-dbx-startup-theme>
@@ -98,7 +119,11 @@
</head>
<body>
<div id="root">
<div id="startup-loading" role="status" aria-live="polite">Loading…</div>
<div id="startup-loading" role="status" aria-live="polite" aria-busy="true">
<span class="startup-brand" aria-hidden="true">DBX</span>
<span class="startup-spinner" aria-hidden="true"></span>
<span>Loading…</span>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" role="img" aria-label="Salesforce">
<path fill="#00A1E0" d="M82 60c4.5-11 15.5-19 28-19 10 0 19 5 24 13 3-1 6-2 9-2 13 0 23 10 23 23 0 2 0 3-.5 5 9 4 15 13 15 23 0 15-12 27-27 27H58c-18 0-33-15-33-33 0-16 12-30 27-33 3-12 15-21 28-21 1 0 2 0 2 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

+96 -41
View File
@@ -2,12 +2,17 @@
import { blockingDesktopAiRunsForUpdate } from "@/lib/ai/desktopAiRunRegistry";
import { setupUpdatePreparation, prepareUpdateWithDraftRecovery, isUpdatePreparationActive } from "@/lib/app/updatePreparation";
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent, provide } from "vue";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
import { markStartupPhase } from "@/lib/startup/startupTiming";
import { clearStartupPreloadRetry } from "@/lib/startup/startupPreloadRecovery";
import { useI18n } from "vue-i18n";
import { FileText } from "@lucide/vue";
import { TooltipProvider } from "@/components/ui/tooltip";
import AppToolbar from "@/components/layout/AppToolbar.vue";
import AppTabBar from "@/components/layout/AppTabBar.vue";
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "@/components/layout/groupTabBarPortal";
import PluginShortcutBar from "@/components/plugins/PluginShortcutBar.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import SqlEditorWorkspace from "@/components/layout/SqlEditorWorkspace.vue";
import { EDITOR_TOOLBAR_ACTIONS } from "@/components/layout/editorToolbarActions";
@@ -144,7 +149,7 @@ import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { apiUrl, webPath } from "@/lib/common/webPath";
import { webPath } from "@/lib/common/webPath";
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
@@ -179,6 +184,7 @@ import ExternalSqlFileChangeDialog from "@/components/editor/ExternalSqlFileChan
import { resolveWindowContext } from "@/lib/app/windowContext";
import { openDetachedTabWindow } from "@/lib/app/detachedTabWindow";
import { OPEN_PLUGIN_AI_CONVERSATION, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue"));
const PluginWorkbenchTab = defineAsyncComponent(() => import("@/components/plugins/PluginWorkbenchTab.vue"));
@@ -214,6 +220,7 @@ type AiAssistantHandle = {
type AuxiliarySearchSurface = "ai" | "history" | "sqlLibrary" | null;
const { t, locale: appLocale } = useI18n();
const startupProps = defineProps<{ startupAuthentication?: StartupAuthentication }>();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
@@ -361,9 +368,9 @@ const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, appl
const drawDesktopWindowFrame = shouldDrawDesktopWindowFrame(isMacOS(), isDesktop, isWindows());
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
let updateCheckTimer: ReturnType<typeof setInterval> | undefined;
const needsAuth = ref(!isDesktop);
const authenticated = ref(isDesktop);
const setupRequired = ref(false);
const needsAuth = ref(!isDesktop && (startupProps.startupAuthentication?.required ?? true));
const authenticated = ref(isDesktop || (startupProps.startupAuthentication?.authenticated ?? false));
const setupRequired = ref(!isDesktop && (startupProps.startupAuthentication?.setup_required ?? false));
const showConnectionDialog = ref(false);
const connectionDialogPrefill = ref<ConnectionDeepLinkDraft | null>(null);
@@ -396,6 +403,7 @@ const agentDriverUpdateCount = ref(0);
const showHistory = ref(false);
const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true");
const isAiPanelMaximized = ref(false);
const isHistoryPanelMaximized = ref(false);
const isZenMode = ref(false);
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
const showSqlFilePanel = ref(safeLocalStorageGet("dbx-sql-file-panel-open") === "true");
@@ -484,6 +492,20 @@ const blockingAiRunCount = computed(() => (isDesktop ? blockingDesktopAiRunsForQ
let aiRunsQuitConfirmed = false;
const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
const pluginAiRecommendationsByTab = ref<Record<string, PluginAiRecommendationHostUpdate>>({});
const activePluginAiRecommendations = computed(() => {
const tab = activeTab.value;
if (!tab || tab.mode !== "plugin-workbench" || !tab.pluginWorkbench) return undefined;
return pluginAiRecommendationsByTab.value[tab.id];
});
function updatePluginAiRecommendations(tabId: string, update: PluginAiRecommendationHostUpdate): void {
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId);
if (!tab?.pluginWorkbench || tab.pluginWorkbench.pluginId !== update.pluginId || tab.pluginWorkbench.contributionId !== update.contributionId) return;
const expectedWorkbenchId = typeof tab.pluginWorkbench.context?.workbenchId === "string" ? tab.pluginWorkbench.context.workbenchId : undefined;
if (expectedWorkbenchId && update.workbenchId && expectedWorkbenchId !== update.workbenchId) return;
pluginAiRecommendationsByTab.value = { ...pluginAiRecommendationsByTab.value, [tabId]: update };
}
// Plugin workbench tabs stay mounted once opened (hidden via v-show): an
// iframe moved out of the DOM reloads from scratch, so KeepAlive/ContentArea
// remounts flash the whole webview and drop its live session state.
@@ -937,9 +959,16 @@ function requestActiveEditorExecute(source?: "pointer" | "keyboard", tabId?: str
void tryExecute(undefined, targetTabId ? { tabId: targetTabId } : undefined);
}
function requestActiveEditorExecuteInNewResultTab() {
if (contentAreaRef.value?.requestQueryEditorExecuteInNewResultTab?.()) return;
void tryExecuteInNewResultTab();
function requestActiveEditorExecuteInNewResultTab(source?: "pointer" | "keyboard", tabId?: string) {
const snapshot = pendingToolbarExecutionSnapshot.value;
pendingToolbarExecutionSnapshot.value = undefined;
const targetTabId = tabId ?? (source === "pointer" ? snapshot?.tabId : undefined);
if (source === "pointer" && snapshot && snapshot.tabId === targetTabId) {
void tryExecuteInNewResultTab(snapshot, { tabId: targetTabId });
return;
}
if (contentAreaRef.value?.requestQueryEditorExecuteInNewResultTab?.(targetTabId)) return;
void tryExecuteInNewResultTab(undefined, targetTabId ? { tabId: targetTabId } : undefined);
}
const toolbarAgentDriverUpdateCount = computed(() => Math.max(agentDriverUpdateCount.value, componentUpdates.driverUpdateCount.value));
@@ -983,6 +1012,7 @@ provide(EDITOR_TOOLBAR_ACTIONS, {
databaseRequiredSignalFor: (tabId: string) => (databaseRequiredTabId.value === tabId ? databaseRequiredSignal.value : 0),
captureExecutionSnapshot: captureActiveEditorExecutionSnapshot,
toolbarExecute: requestActiveEditorExecute,
toolbarExecuteInNewResultTab: requestActiveEditorExecuteInNewResultTab,
cancelExecution: (tabId: string) => cancelActiveExecution(tabId),
explain: (tabId: string) => tryExplain(undefined, { tabId }),
formatSql: formatActiveSql,
@@ -1263,11 +1293,12 @@ function openPluginConnectionDialog(pluginId: string, providerId: string) {
showConnectionDialog.value = true;
}
async function checkAllUpdates() {
if (manualCheckingAllUpdates.value) return;
if (manualCheckingAllUpdates.value || componentUpdates.updating.value) return;
manualCheckingAllUpdates.value = true;
const startedAt = Date.now();
try {
const [, componentRefresh] = await Promise.allSettled([checkUpdates({ silent: true }), componentUpdates.refresh()]);
// A user-triggered check must replace any in-flight background snapshot that may predate the update.
const [, componentRefresh] = await Promise.allSettled([checkUpdates({ silent: true }), componentUpdates.refresh({ force: true })]);
if (componentRefresh.status === "fulfilled" && componentRefresh.value) syncToolbarComponentUpdateState();
const remaining = 500 - (Date.now() - startedAt);
if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining));
@@ -1283,7 +1314,7 @@ function openDriverStoreFromUpdate(target?: DriverStoreTab) {
function handleToolbarUpdateClick() {
showUpdateDialog.value = true;
if (!checkingAllUpdates.value) void checkAllUpdates();
void checkAllUpdates();
}
function syncToolbarComponentUpdateState() {
@@ -1674,6 +1705,7 @@ function applyRightSidebarPanelState(next: RightSidebarPanelState) {
}
function setRightSidebarPanelOpen(panelId: RightSidebarPanelId, open: boolean) {
if ((panelId === "history" && !open) || (panelId !== "history" && open)) isHistoryPanelMaximized.value = false;
if (panelId === "ai" && !open) {
isAiPanelMaximized.value = false;
} else if (open && panelId !== "ai" && isAiPanelMaximized.value) {
@@ -3863,12 +3895,24 @@ function onLoginSuccess() {
async function initApp() {
const t0 = performance.now();
console.log("[STARTUP] initApp begin");
void Promise.all([initSavedSqlEditorPositions(), savedSqlStore.initFromStorage()])
.then(() => {
console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`);
void queryStore.hydrateSavedSqlTabs();
})
.catch((e: any) => {
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
});
const restoreOpenTabs = async () => {
await settingsStore.initEditorSettings();
markStartupPhase("settings-ready");
console.log(`[STARTUP] settingsStore.initEditorSettings: ${(performance.now() - t0).toFixed(0)}ms`);
await connectionStore.initFromDisk();
markStartupPhase("connections-ready");
console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`);
await queryStore.initOpenTabs({ validConnectionIds: connectionStore.connections.map((connection) => connection.id) });
markStartupPhase("tabs-restored");
console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`);
};
@@ -3906,15 +3950,6 @@ async function initApp() {
void promptTemplateStore.init();
void Promise.all([initSavedSqlEditorPositions(), savedSqlStore.initFromStorage()])
.then(() => {
console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`);
void queryStore.hydrateSavedSqlTabs();
})
.catch((e: any) => {
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
});
restoreActiveConnectionContext();
} catch (e: any) {
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
@@ -3973,6 +4008,8 @@ function runUpdateNotificationChecks() {
}
onMounted(async () => {
clearStartupPreloadRetry();
markStartupPhase("app-mounted");
console.log("[STARTUP] onMounted begin");
const mountStart = performance.now();
if (isDetachedWindowContext) {
@@ -4022,14 +4059,15 @@ onMounted(async () => {
true,
);
if (!isDesktop) {
try {
const res = await fetch(apiUrl("/api/auth/check"));
const data = await res.json();
needsAuth.value = data.required;
authenticated.value = data.authenticated;
setupRequired.value = data.setup_required;
} catch {
/* server unreachable */
if (!startupProps.startupAuthentication) {
try {
const data = await checkStartupAuthentication();
needsAuth.value = data.required;
authenticated.value = data.authenticated;
setupRequired.value = data.setup_required;
} catch {
/* server unreachable */
}
}
if (needsAuth.value && !authenticated.value) {
history.replaceState(null, "", webPath("/login"));
@@ -4058,14 +4096,15 @@ onMounted(async () => {
appVersion.value = v;
})
.catch(() => {});
setupTauriListeners();
void setupTauriListeners().then(() => {
void openPendingSqlFiles();
void openPendingDbFiles();
void openPendingConnectionLinks();
void openPendingAiConfigLinks();
void openPendingPluginInstallLinks();
});
setupCloseActionPromptListener();
void setupDetachedWindowEvents();
void openPendingSqlFiles();
void openPendingDbFiles();
void openPendingConnectionLinks();
void openPendingAiConfigLinks();
void openPendingPluginInstallLinks();
console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`);
});
@@ -4154,6 +4193,7 @@ onUnmounted(() => {
/>
<div :class="isDetachedWindowContext ? 'flex-1 flex min-h-0' : isClassicLayout ? 'app-layout-classic flex-1 flex min-h-0' : 'app-panel-gutter flex-1 flex min-h-0 gap-1 p-1'">
<PluginShortcutBar v-if="!isDetachedWindowContext && !isZenMode && settingsStore.editorSettings.pluginShortcuts.enabled && settingsStore.editorSettings.pluginShortcuts.position.startsWith('left-')" :position="settingsStore.editorSettings.pluginShortcuts.position" />
<AppSidebar
v-if="!isDetachedWindowContext"
v-show="sidebarOpen && !isZenMode"
@@ -4169,7 +4209,11 @@ onUnmounted(() => {
@mousedown="rememberSidebarSearchSurface"
/>
<div v-show="!isAiPanelMaximized || isZenMode" :class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
<div
data-editor-content
v-show="(!isAiPanelMaximized && !isHistoryPanelMaximized) || isZenMode"
:class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'"
>
<div class="h-full flex min-h-0 min-w-0 flex-col">
<AppTabBar
v-if="!isDetachedWindowContext"
@@ -4432,6 +4476,7 @@ onUnmounted(() => {
:contribution-id="workbenchTab.pluginWorkbench!.contributionId"
:context="workbenchTab.pluginWorkbench!.context"
@close-tab="queryStore.closeTab(workbenchTab.id)"
@recommendations="updatePluginAiRecommendations(workbenchTab.id, $event)"
/>
</div>
</div>
@@ -4441,7 +4486,7 @@ onUnmounted(() => {
<div
v-if="!isDetachedWindowContext && showAiPanel"
v-show="!isZenMode"
v-show="!isHistoryPanelMaximized && !isZenMode"
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[240px] max-w-full']"
:style="isAiPanelMaximized ? {} : { width: aiPanelWidth + 'px' }"
>
@@ -4453,6 +4498,7 @@ onUnmounted(() => {
:tab="activeTab"
:connection="activeConnection"
:maximized="isAiPanelMaximized"
:plugin-recommendations="activePluginAiRecommendations"
@append-sql="onAiAppendSql"
@execute-sql="onAiExecuteSql"
@temp-run-sql="onAiTempRunSql"
@@ -4470,20 +4516,28 @@ onUnmounted(() => {
<div
v-if="!isDetachedWindowContext && showHistory"
v-show="!isAiPanelMaximized && !isZenMode"
:class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'"
:style="{ width: historyWidth + 'px' }"
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isHistoryPanelMaximized ? 'min-w-0 flex-1' : 'shrink-0 max-w-full']"
:style="isHistoryPanelMaximized ? {} : { width: historyWidth + 'px' }"
>
<div class="panel-resize-handle panel-resize-handle--left" @pointerdown="startHistoryResize" />
<div v-if="!isHistoryPanelMaximized" class="panel-resize-handle panel-resize-handle--left" @pointerdown="startHistoryResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]" @mousedown="rememberAuxiliarySearchSurface('history')">
<div data-history-panel class="h-full min-h-0">
<QueryHistory :current-connection-id="activeTab?.connectionId" :current-database="activeTab?.database" @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
<QueryHistory
:maximized="isHistoryPanelMaximized"
@toggle-maximize="isHistoryPanelMaximized = !isHistoryPanelMaximized"
:current-connection-id="activeTab?.connectionId"
:current-database="activeTab?.database"
@restore="restoreHistorySql"
@analyze-ai="analyzeHistoryWithAi"
@close="closeRightSidebarPanel('history')"
/>
</div>
</div>
</div>
<div
v-if="!isDetachedWindowContext && showSqlLibraryPanel"
v-show="!isAiPanelMaximized && !isZenMode"
v-show="!isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
:class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'"
:style="{ width: sqlLibraryWidth + 'px' }"
>
@@ -4497,7 +4551,7 @@ onUnmounted(() => {
<div
v-if="!isDetachedWindowContext && showSqlFilePanel"
v-show="!isAiPanelMaximized && !isZenMode"
v-show="!isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
:class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'"
:style="{ width: sqlFilePanelWidth + 'px' }"
>
@@ -4506,6 +4560,7 @@ onUnmounted(() => {
<SqlFilePanel @close="closeRightSidebarPanel('sqlFile')" />
</div>
</div>
<PluginShortcutBar v-if="!isDetachedWindowContext && !isZenMode && settingsStore.editorSettings.pluginShortcuts.enabled && settingsStore.editorSettings.pluginShortcuts.position.startsWith('right-')" :position="settingsStore.editorSettings.pluginShortcuts.position" />
</div>
<AppDialogs
+59 -81
View File
@@ -1,15 +1,31 @@
<script setup lang="ts">
import { defineAsyncComponent, onMounted, ref, watch } from "vue";
import { defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";
import { useI18n } from "vue-i18n";
import LoginPage from "@/components/auth/LoginPage.vue";
import SecurityMigrationWizard from "@/components/migration/SecurityMigrationWizard.vue";
import StartupLoading from "@/components/layout/StartupLoading.vue";
import { useMigrationStore } from "@/stores/migrationStore";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { apiUrl, webPath } from "@/lib/common/webPath";
import { webPath } from "@/lib/common/webPath";
import { loadSavedLocale } from "@/i18n";
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
import { markStartupPhase } from "@/lib/startup/startupTiming";
import { retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
// App setup installs listeners and instantiates business stores, so even its import
// is deferred until authentication and the security migration have completed.
const App = defineAsyncComponent(() => import("./App.vue"));
const startupAsyncOptions = {
loadingComponent: StartupLoading,
errorComponent: StartupLoading,
delay: 0,
suspensible: false,
onError(error: Error, _retry: () => void, fail: () => void) {
retryStartupAfterPreloadFailure(error);
fail();
},
};
const App = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("./App.vue") });
const LoginPage = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/auth/LoginPage.vue") });
const SecurityMigrationWizard = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/migration/SecurityMigrationWizard.vue") });
const props = defineProps<{ localeReady?: Promise<void> }>();
const { t } = useI18n();
const migration = useMigrationStore();
const { blocking } = migration;
@@ -17,33 +33,42 @@ const checkingAuth = ref(true);
const loginRequired = ref(false);
const setupRequired = ref(false);
const authFailed = ref(false);
const enteringApp = ref(false);
let launchTransitionTimer: number | undefined;
const startupAuthentication = shallowRef<StartupAuthentication>();
const checkingLocale = ref(Boolean(props.localeReady));
const localeFailed = ref(false);
let initialLocaleReady = props.localeReady;
let authRequest: AbortController | undefined;
watch(
() => migration.state.entered,
(entered) => {
if (entered) {
enteringApp.value = true;
}
},
);
watch(migration.blocking, (blocked) => {
if (!blocked) markStartupPhase("migration-ready");
});
function appReady() {
window.clearTimeout(launchTransitionTimer);
launchTransitionTimer = window.setTimeout(() => {
enteringApp.value = false;
}, 650);
async function initializeLocale() {
if (!initialLocaleReady && !localeFailed.value) return;
checkingLocale.value = true;
localeFailed.value = false;
try {
await (initialLocaleReady ?? loadSavedLocale());
markStartupPhase("locale-ready");
window.dispatchEvent(new Event("dbx:startup-ready"));
} catch {
localeFailed.value = true;
} finally {
initialLocaleReady = undefined;
checkingLocale.value = false;
}
}
async function initialize() {
authRequest?.abort();
const request = new AbortController();
authRequest = request;
checkingAuth.value = true;
authFailed.value = false;
try {
if (!isTauriRuntime()) {
const response = await fetch(apiUrl("/api/auth/check"), { credentials: "same-origin" });
if (!response.ok) throw new Error("AUTH_CHECK_FAILED");
const result = await response.json();
if (typeof result.required !== "boolean" || typeof result.authenticated !== "boolean") throw new Error("AUTH_CHECK_FAILED");
const result = await checkStartupAuthentication(request.signal);
if (request.signal.aborted) return;
startupAuthentication.value = result;
setupRequired.value = result.setup_required === true;
loginRequired.value = setupRequired.value || (result.required && !result.authenticated);
if (loginRequired.value) {
@@ -51,74 +76,27 @@ async function initialize() {
return;
}
}
markStartupPhase("auth-ready");
await migration.initialize();
} catch {
authFailed.value = true;
if (!request.signal.aborted) authFailed.value = true;
} finally {
checkingAuth.value = false;
if (!request.signal.aborted) checkingAuth.value = false;
}
}
async function authenticated() {
history.replaceState(null, "", webPath("/"));
await initialize();
}
onMounted(initialize);
onMounted(() => {
void initializeLocale();
void initialize();
});
onUnmounted(() => authRequest?.abort());
</script>
<template>
<div v-if="checkingAuth || authFailed" class="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-background text-foreground" role="status">
<p>{{ t(authFailed ? "migration.authFailed" : "migration.checking") }}</p>
<button v-if="authFailed" class="rounded border px-4 py-2" @click="initialize">{{ t("migration.retry") }}</button>
</div>
<StartupLoading v-if="checkingLocale || localeFailed || checkingAuth || authFailed" :label="authFailed ? t('migration.authFailed') : !checkingLocale && !localeFailed ? t('migration.checking') : undefined" :error="localeFailed || authFailed" :retry="localeFailed ? initializeLocale : initialize" />
<LoginPage v-else-if="loginRequired" :setup-mode="setupRequired" @authenticated="authenticated" />
<SecurityMigrationWizard v-else-if="blocking" :store="migration" />
<div v-else class="relative min-h-screen">
<Suspense @resolve="appReady">
<App />
</Suspense>
<Transition name="startup-fade">
<div v-if="enteringApp" class="fixed inset-0 z-[1100] flex items-center justify-center bg-background text-foreground" role="status" aria-live="polite">
<div class="flex flex-col items-center gap-5 text-center">
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-2xl text-primary" aria-hidden="true">✓</span>
<div>
<p class="text-lg font-semibold">{{ t("migration.successTitle") }}</p>
<p class="mt-2 text-sm text-muted-foreground">{{ t("migration.launching") }}</p>
</div>
<span class="h-1.5 w-24 overflow-hidden rounded-full bg-primary/15" aria-hidden="true"><span class="startup-progress block h-full w-1/2 rounded-full bg-primary" /></span>
</div>
</div>
</Transition>
</div>
<App v-else :startup-authentication="startupAuthentication" />
</template>
<style scoped>
.startup-fade-enter-active,
.startup-fade-leave-active {
transition: opacity 280ms ease;
}
.startup-fade-enter-from,
.startup-fade-leave-to {
opacity: 0;
}
.startup-progress {
animation: startup-progress 1.2s ease-in-out infinite;
}
@keyframes startup-progress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(200%);
}
}
@media (prefers-reduced-motion: reduce) {
.startup-progress {
animation: none;
transform: translateX(50%);
}
}
</style>
+212
View File
@@ -0,0 +1,212 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
desktop: vi.fn(),
appImported: vi.fn(),
reloadLocale: vi.fn(),
migrationStatus: vi.fn(),
migrationStart: vi.fn(),
migrationRetry: vi.fn(),
migrationCleanupBackups: vi.fn(),
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: mocks.desktop }));
vi.mock("@/lib/backend/api", () => mocks);
vi.mock("@/i18n", () => ({ loadSavedLocale: mocks.reloadLocale }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
const readyStatus = { state: "not_required", needsMigration: false };
let businessReady: ReturnType<typeof deferred<void>>;
let mountedApp: App | undefined;
let root: HTMLDivElement;
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
sessionStorage.clear();
mocks.desktop.mockReturnValue(true);
mocks.migrationStatus.mockResolvedValue(readyStatus);
mocks.reloadLocale.mockResolvedValue(undefined);
businessReady = deferred<void>();
root = document.createElement("div");
document.body.append(root);
vi.doMock("../App.vue", async () => {
mocks.appImported();
await businessReady.promise;
return {
__esModule: true,
default: defineComponent({
props: ["startupAuthentication"],
setup: (props) => () => h("main", { "data-business-app": "" }, JSON.stringify(props.startupAuthentication ?? null)),
}),
};
});
vi.doMock("@/components/auth/LoginPage.vue", () => ({
__esModule: true,
default: defineComponent({
props: ["setupMode"],
emits: ["authenticated"],
setup:
(props, { emit }) =>
() =>
h("button", { "data-login": "", onClick: () => emit("authenticated") }, props.setupMode ? "setup" : "login"),
}),
}));
vi.doMock("@/components/migration/SecurityMigrationWizard.vue", () => ({
__esModule: true,
default: defineComponent({
props: ["store"],
setup: (props) => () =>
h(
"button",
{
"data-migration": "",
onClick: async () => {
await props.store.start();
props.store.enter();
},
},
"migrate",
),
}),
}));
});
afterEach(() => {
mountedApp?.unmount();
mountedApp = undefined;
root.remove();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function mountGate(localeReady?: Promise<void>) {
const { default: Gate } = await import("../StartupGate.vue");
mountedApp = createApp(Gate, { localeReady });
mountedApp.config.errorHandler = vi.fn();
mountedApp.mount(root);
}
describe("startup boundary", () => {
it("checks migration while the locale loads and keeps feedback until the app module resolves", async () => {
const locale = deferred<void>();
await mountGate(locale.promise);
await vi.waitFor(() => expect(mocks.migrationStatus).toHaveBeenCalledTimes(1));
expect(mocks.appImported).not.toHaveBeenCalled();
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
locale.resolve();
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
expect(root.querySelector("main")).toBeNull();
businessReady.resolve();
await vi.waitFor(() => expect(root.querySelector("[data-business-app]")).not.toBeNull());
expect(root.querySelector("[data-startup-loading]")).toBeNull();
});
it("passes the checked Web authentication state without an extra request", async () => {
mocks.desktop.mockReturnValue(false);
const authentication = { required: true, authenticated: true, setup_required: false };
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => authentication });
vi.stubGlobal("fetch", fetchMock);
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(JSON.parse(root.querySelector("main")!.textContent!)).toEqual(authentication);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each([false, true])("does not inspect protected data until login/setup completes (setup=%s)", async (setup) => {
mocks.desktop.mockReturnValue(false);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: false, setup_required: setup }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: true }) });
vi.stubGlobal("fetch", fetchMock);
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector("[data-login]")).not.toBeNull());
expect(root.querySelector("[data-login]")!.textContent).toBe(setup ? "setup" : "login");
expect(mocks.migrationStatus).not.toHaveBeenCalled();
expect(mocks.appImported).not.toHaveBeenCalled();
(root.querySelector("[data-login]") as HTMLButtonElement).click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not load the business app until a required migration is completed and entered", async () => {
mocks.migrationStatus.mockResolvedValueOnce({ state: "pending", needsMigration: true }).mockResolvedValue(readyStatus);
mocks.migrationStart.mockResolvedValue({ state: "succeeded" });
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector("[data-migration]")).not.toBeNull());
expect(mocks.appImported).not.toHaveBeenCalled();
(root.querySelector("[data-migration]") as HTMLButtonElement).click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(mocks.migrationStart).toHaveBeenCalledTimes(1);
});
it("fails closed on malformed auth and allows retry", async () => {
mocks.desktop.mockReturnValue(false);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => null })
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: false, authenticated: false }) });
vi.stubGlobal("fetch", fetchMock);
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
expect(mocks.migrationStatus).not.toHaveBeenCalled();
expect(mocks.appImported).not.toHaveBeenCalled();
root.querySelector("button")!.click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
});
it("retries a failed locale load without rerunning successful security checks", async () => {
const locale = deferred<void>();
businessReady.resolve();
await mountGate(locale.promise);
locale.reject(new Error("locale download failed"));
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
expect(mocks.appImported).not.toHaveBeenCalled();
root.querySelector("button")!.click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(mocks.reloadLocale).toHaveBeenCalledTimes(1);
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
});
it("shows a safe retry state when the app module fails to load", async () => {
await mountGate();
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
businessReady.reject(new Error("private module URL"));
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
expect(root.textContent).toContain("startup.loadFailed");
expect(root.textContent).not.toContain("private module URL");
expect(root.querySelector("button")).not.toBeNull();
});
it("cancels pending authentication when the gate unmounts", async () => {
mocks.desktop.mockReturnValue(false);
const fetchMock = vi.fn().mockReturnValue(new Promise(() => {}));
vi.stubGlobal("fetch", fetchMock);
await mountGate();
await nextTick();
const signal = fetchMock.mock.calls[0]?.[1].signal as AbortSignal;
mountedApp!.unmount();
mountedApp = undefined;
expect(signal.aborted).toBe(true);
expect(mocks.migrationStatus).not.toHaveBeenCalled();
});
});
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import * as api from "@/lib/backend/api";
import { authorizationPrivileges, type AuthorizationPreset, type DatabaseAuthorizationSelection } from "@/lib/database/databaseAuthorizationPlan";
import { authorizationPresetPrivileges, authorizationPrivileges, type AuthorizationDatabaseOption, type AuthorizationPreset, type AuthorizationTableSelection, type DatabaseAuthorizationSelection } from "@/lib/database/databaseAuthorizationPlan";
import type { DatabaseUserAdminProvider } from "@/lib/database/databaseUserAdmin";
/**
@@ -21,7 +21,7 @@ const props = defineProps<{
/** 当前连接的权限提供者,用于判断是否支持表级授权以及表级可用权限 */
provider: DatabaseUserAdminProvider | null;
/** 当前连接下的数据库名列表 */
databases: string[];
databases: AuthorizationDatabaseOption[];
/** 数据库列表是否正在加载 */
databasesLoading?: boolean;
/** 连接 ID,用于按库懒加载表列表 */
@@ -37,9 +37,10 @@ const emit = defineEmits<{
const { t } = useI18n();
// 全局作用域标识:MySQL 的 GRANT/REVOKE 支持 ON *.*,权限编辑面板需要保留这一能力
const GLOBAL_SCOPE = "*";
const GLOBAL_OPTION: AuthorizationDatabaseOption = { database: GLOBAL_SCOPE };
const search = ref("");
// 表列表按库懒加载并缓存,避免下拉展开时重复请求
const tables = ref<Record<string, string[]>>({});
const tables = ref<Record<string, AuthorizationTableSelection[]>>({});
const tablesLoading = ref<Record<string, boolean>>({});
const tableErrors = ref<Record<string, string>>({});
const tableSearch = ref<Record<string, string>>({});
@@ -47,12 +48,12 @@ const tableSearch = ref<Record<string, string>>({});
const supportsTableGrants = computed(() => !!props.provider?.supportsTableGrantsOnCreate);
const filteredDatabases = computed(() => {
const query = search.value.trim().toLowerCase();
return query ? props.databases.filter((database) => database.toLowerCase().includes(query)) : props.databases;
return query ? props.databases.filter((database) => databaseLabel(database).toLowerCase().includes(query)) : props.databases;
});
// 紧凑模式(权限编辑面板)在列表顶部提供固定的「全局」入口,且不受库搜索影响;
// 新增用户弹窗保持只勾选真实数据库,不改变原有产品行为。
const visibleDatabases = computed(() => (props.compact ? [GLOBAL_SCOPE, ...filteredDatabases.value] : filteredDatabases.value));
const selectedDatabaseSet = computed(() => new Set(props.modelValue.map((selection) => selection.database)));
const visibleDatabases = computed(() => (props.compact && props.provider?.authorizationModel === "mysql" ? [GLOBAL_OPTION, ...filteredDatabases.value] : filteredDatabases.value));
const selectedDatabaseSet = computed(() => new Set(props.modelValue.map(authorizationSelectionKey)));
// 同一 tick 内连续触发多次选择变更时(例如连续点击多张表),父组件回流的 props 尚未更新,
// 这里用最近一次发出的值作为基准,避免后一次变更覆盖前一次导致选择丢失。
@@ -61,6 +62,19 @@ watch(
() => props.modelValue,
(value) => {
pendingSelections = value;
for (const selection of value) {
if (selection.tables !== undefined) void loadTables({ database: selection.database, ...(selection.catalog ? { catalog: selection.catalog } : {}) });
}
},
{ immediate: true },
);
watch(
() => props.databases,
() => {
for (const selection of props.modelValue) {
if (selection.tables !== undefined) void loadTables({ database: selection.database, ...(selection.catalog ? { catalog: selection.catalog } : {}) });
}
},
);
@@ -73,33 +87,34 @@ function commitSelections(next: DatabaseAuthorizationSelection[]) {
emit("update:modelValue", next);
}
function selectionFor(database: string): DatabaseAuthorizationSelection | undefined {
return props.modelValue.find((selection) => selection.database === database);
function selectionFor(database: AuthorizationDatabaseOption): DatabaseAuthorizationSelection | undefined {
const key = authorizationOptionKey(database);
return props.modelValue.find((selection) => authorizationSelectionKey(selection) === key);
}
/**
* 统一以“不可变更新”方式回写选中项:始终替换为新的数组与对象,
* 避免直接修改 props 中的对象引用(v-model 单向数据流的约束)。
*/
function patchSelection(database: string, patch: (selection: DatabaseAuthorizationSelection) => DatabaseAuthorizationSelection | undefined) {
function patchSelection(database: AuthorizationDatabaseOption, patch: (selection: DatabaseAuthorizationSelection) => DatabaseAuthorizationSelection | undefined) {
const next: DatabaseAuthorizationSelection[] = [];
for (const selection of currentSelections()) {
if (selection.database !== database) {
if (authorizationSelectionKey(selection) !== authorizationOptionKey(database)) {
next.push(selection);
continue;
}
const updated = patch({ ...selection });
if (updated) next.push({ ...updated, tables: updated.tables ? [...updated.tables] : undefined, privileges: updated.privileges ? [...updated.privileges] : undefined });
if (updated) next.push({ ...updated, tables: updated.tables?.map((table) => ({ ...table })), privileges: updated.privileges ? [...updated.privileges] : undefined });
}
commitSelections(next);
}
function toggleDatabase(database: string) {
function toggleDatabase(database: AuthorizationDatabaseOption) {
if (selectionFor(database)) {
commitSelections(currentSelections().filter((selection) => selection.database !== database));
commitSelections(currentSelections().filter((selection) => authorizationSelectionKey(selection) !== authorizationOptionKey(database)));
return;
}
commitSelections([...currentSelections(), { database, preset: "readOnly", privileges: ["SELECT"] }]);
commitSelections([...currentSelections(), { database: database.database, ...(database.catalog ? { catalog: database.catalog } : {}), preset: "readOnly", privileges: ["SELECT"] }]);
}
// 表范围下 MySQL 不支持 CREATE ROUTINE / EVENT 等库级权限,需要按目标作用域过滤可选权限
@@ -109,43 +124,63 @@ function privilegesForSelection(selection?: DatabaseAuthorizationSelection): str
return authorizationPrivileges(userProvider, selection?.tables === undefined ? "database" : "table");
}
function usesSelectedTables(database: string): boolean {
function usesSelectedTables(database: AuthorizationDatabaseOption): boolean {
return selectionFor(database)?.tables !== undefined;
}
function isTableSelected(database: string, table: string): boolean {
return selectionFor(database)?.tables?.includes(table) ?? false;
function isTableSelected(database: AuthorizationDatabaseOption, table: AuthorizationTableSelection): boolean {
return selectionFor(database)?.tables?.some((selected) => authorizationTableKey(selected) === authorizationTableKey(table)) ?? false;
}
function allTablesSelected(database: string): boolean {
const available = tables.value[database] ?? [];
return available.length > 0 && selectionFor(database)?.tables?.length === available.length;
function allTablesSelected(database: AuthorizationDatabaseOption): boolean {
const key = authorizationOptionKey(database);
const available = tables.value[key] ?? [];
const selected = new Set(selectionFor(database)?.tables?.map(authorizationTableKey) ?? []);
return available.length > 0 && available.every((table) => selected.has(authorizationTableKey(table)));
}
function filteredTables(database: string): string[] {
const query = (tableSearch.value[database] ?? "").trim().toLowerCase();
const available = tables.value[database] ?? [];
return query ? available.filter((table) => table.toLowerCase().includes(query)) : available;
function filteredTables(database: AuthorizationDatabaseOption): AuthorizationTableSelection[] {
const key = authorizationOptionKey(database);
const query = (tableSearch.value[key] ?? "").trim().toLowerCase();
const available = tables.value[key] ?? [];
return query ? available.filter((table) => tableLabel(table).toLowerCase().includes(query)) : available;
}
async function loadTables(database: string) {
if (tables.value[database] || tablesLoading.value[database]) return;
tablesLoading.value = { ...tablesLoading.value, [database]: true };
tableErrors.value = { ...tableErrors.value, [database]: "" };
async function loadTables(database: AuthorizationDatabaseOption) {
const key = authorizationOptionKey(database);
if (tables.value[key] || tablesLoading.value[key]) return;
tablesLoading.value = { ...tablesLoading.value, [key]: true };
tableErrors.value = { ...tableErrors.value, [key]: "" };
try {
const result = await api.listTables(props.connectionId, database, "");
let result: AuthorizationTableSelection[];
if (props.provider?.authorizationModel === "postgres") {
const schemas = (await api.listSchemas(props.connectionId, database.database)).filter(isPostgresGrantSchema);
const schemaTables = await Promise.all(
schemas.map(async (schema) => {
const items = await api.listTables(props.connectionId, database.database, schema, undefined, undefined, undefined, ["TABLE"]);
return items.map((table) => ({ name: table.name, schema }));
}),
);
result = schemaTables.flat();
} else if (props.provider?.authorizationModel === "starrocks") {
const items = await api.listTables(props.connectionId, database.database, "", undefined, undefined, undefined, ["TABLE"], database.catalog);
result = items.map((table) => ({ name: table.name }));
} else {
const items = await api.listTables(props.connectionId, database.database, "");
result = items.map((table) => ({ name: table.name }));
}
tables.value = {
...tables.value,
[database]: Array.from(new Set(result.map((table) => table.name.trim()).filter(Boolean))).sort((left, right) => left.localeCompare(right)),
[key]: uniqueTableOptions(result).sort((left, right) => tableLabel(left).localeCompare(tableLabel(right))),
};
} catch (error: any) {
tableErrors.value = { ...tableErrors.value, [database]: error?.message || String(error) };
tableErrors.value = { ...tableErrors.value, [key]: error?.message || String(error) };
} finally {
tablesLoading.value = { ...tablesLoading.value, [database]: false };
tablesLoading.value = { ...tablesLoading.value, [key]: false };
}
}
async function updateTableScope(database: string, selectTables: boolean) {
async function updateTableScope(database: AuthorizationDatabaseOption, selectTables: boolean) {
if (!selectTables) {
patchSelection(database, (selection) => {
delete selection.tables;
@@ -155,6 +190,7 @@ async function updateTableScope(database: string, selectTables: boolean) {
}
patchSelection(database, (selection) => {
selection.tables = selection.tables ?? [];
if (authorizationPresetPrivileges(props.provider!, selection.preset, selection.privileges, "table").length === 0) selection.preset = "readOnly";
if (selection.preset === "custom") {
const allowed = new Set(privilegesForSelection(selection));
selection.privileges = (selection.privileges ?? []).filter((privilege) => allowed.has(privilege));
@@ -164,27 +200,25 @@ async function updateTableScope(database: string, selectTables: boolean) {
await loadTables(database);
}
function toggleTable(database: string, table: string) {
function toggleTable(database: AuthorizationDatabaseOption, table: AuthorizationTableSelection) {
patchSelection(database, (selection) => {
if (!selection.tables) return selection;
const next = new Set(selection.tables);
if (next.has(table)) next.delete(table);
else next.add(table);
selection.tables = Array.from(next);
const key = authorizationTableKey(table);
selection.tables = selection.tables.some((selected) => authorizationTableKey(selected) === key) ? selection.tables.filter((selected) => authorizationTableKey(selected) !== key) : [...selection.tables, table];
return selection;
});
}
function toggleAllTables(database: string) {
function toggleAllTables(database: AuthorizationDatabaseOption) {
patchSelection(database, (selection) => {
if (!selection.tables) return selection;
const available = tables.value[database] ?? [];
selection.tables = selection.tables.length === available.length ? [] : [...available];
const available = tables.value[authorizationOptionKey(database)] ?? [];
selection.tables = allTablesSelected(database) ? [] : [...available];
return selection;
});
}
function updatePreset(database: string, preset: unknown) {
function updatePreset(database: AuthorizationDatabaseOption, preset: unknown) {
if (typeof preset !== "string") return;
patchSelection(database, (selection) => {
selection.preset = preset as AuthorizationPreset;
@@ -192,7 +226,7 @@ function updatePreset(database: string, preset: unknown) {
});
}
function togglePrivilege(database: string, privilege: string) {
function togglePrivilege(database: AuthorizationDatabaseOption, privilege: string) {
patchSelection(database, (selection) => {
const next = new Set(selection.privileges ?? []);
if (next.has(privilege)) next.delete(privilege);
@@ -202,9 +236,45 @@ function togglePrivilege(database: string, privilege: string) {
});
}
function isPrivilegeSelected(database: string, privilege: string): boolean {
function isPrivilegeSelected(database: AuthorizationDatabaseOption, privilege: string): boolean {
return selectionFor(database)?.privileges?.includes(privilege) ?? false;
}
function authorizationOptionKey(option: AuthorizationDatabaseOption): string {
return JSON.stringify([option.catalog ?? "", option.database]);
}
function authorizationSelectionKey(selection: Pick<DatabaseAuthorizationSelection, "catalog" | "database">): string {
return authorizationOptionKey(selection);
}
function databaseLabel(option: AuthorizationDatabaseOption): string {
return option.catalog ? `${option.catalog} / ${option.database}` : option.database;
}
function authorizationTableKey(table: AuthorizationTableSelection): string {
return JSON.stringify([table.schema ?? "", table.name]);
}
function tableLabel(table: AuthorizationTableSelection): string {
return table.schema ? `${table.schema}.${table.name}` : table.name;
}
function uniqueTableOptions(options: AuthorizationTableSelection[]): AuthorizationTableSelection[] {
const seen = new Set<string>();
return options.filter((option) => {
if (!option.name.trim()) return false;
const key = authorizationTableKey(option);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function isPostgresGrantSchema(schema: string): boolean {
const normalized = schema.toLowerCase();
return !!schema.trim() && normalized !== "information_schema" && normalized !== "sys_catalog" && !normalized.startsWith("pg_");
}
</script>
<template>
@@ -227,13 +297,13 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
{{ t("userAdmin.loadingDatabases") }}
</div>
<div v-else-if="visibleDatabases.length === 0" class="p-3 text-center text-xs text-muted-foreground">{{ t("userAdmin.emptyDatabases") }}</div>
<div v-for="database in visibleDatabases" :key="database" class="border-b p-2" :class="compact ? '' : 'last:border-b-0'">
<div v-for="database in visibleDatabases" :key="authorizationOptionKey(database)" class="border-b p-2" :class="compact ? '' : 'last:border-b-0'">
<div class="flex items-center gap-2">
<input :checked="selectedDatabaseSet.has(database)" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleDatabase(database)" />
<input :checked="selectedDatabaseSet.has(authorizationOptionKey(database))" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleDatabase(database)" />
<button type="button" class="min-w-0 flex-1 truncate text-left text-xs font-medium" @click="toggleDatabase(database)">
{{ database === GLOBAL_SCOPE ? t("userAdmin.globalScope") : database }}
{{ database.database === GLOBAL_SCOPE ? t("userAdmin.globalScope") : databaseLabel(database) }}
</button>
<Select v-if="selectedDatabaseSet.has(database)" :model-value="selectionFor(database)?.preset" @update:model-value="updatePreset(database, $event)">
<Select v-if="selectedDatabaseSet.has(authorizationOptionKey(database))" :model-value="selectionFor(database)?.preset" @update:model-value="updatePreset(database, $event)">
<SelectTrigger class="h-7 text-xs" :class="compact ? 'w-28' : 'w-32'"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="readWrite">{{ t("userAdmin.presetReadWrite") }}</SelectItem>
@@ -244,7 +314,7 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
</SelectContent>
</Select>
</div>
<div v-if="supportsTableGrants && database !== GLOBAL_SCOPE && selectedDatabaseSet.has(database)" class="mt-2 flex gap-2" :class="compact ? 'flex-col gap-1.5' : 'items-center pl-5'">
<div v-if="supportsTableGrants && database.database !== GLOBAL_SCOPE && selectedDatabaseSet.has(authorizationOptionKey(database))" class="mt-2 flex gap-2" :class="compact ? 'flex-col gap-1.5' : 'items-center pl-5'">
<div class="flex items-center gap-2">
<span v-if="!compact" class="shrink-0 text-[11px] text-muted-foreground">{{ t("userAdmin.tableScope") }}</span>
<div class="flex h-7 shrink-0 items-center rounded-md border bg-muted/30 p-0.5">
@@ -263,7 +333,7 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
<span class="min-w-0 flex-1 truncate">
{{ selectionFor(database)?.tables?.length ? t("userAdmin.selectedTableCount", { count: selectionFor(database)?.tables?.length }) : t("userAdmin.chooseTables") }}
</span>
<Loader2 v-if="tablesLoading[database]" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<Loader2 v-if="tablesLoading[authorizationOptionKey(database)]" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<ChevronDown v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
@@ -271,29 +341,29 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
<div class="flex items-center gap-2 border-b p-2">
<div class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border px-2">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input v-model="tableSearch[database]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t('userAdmin.searchTable')" />
<input v-model="tableSearch[authorizationOptionKey(database)]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t('userAdmin.searchTable')" />
</div>
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[database]?.length > 0)" @click="toggleAllTables(database)">
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[authorizationOptionKey(database)]?.length > 0)" @click="toggleAllTables(database)">
{{ t(allTablesSelected(database) ? "userAdmin.clearAllTables" : "userAdmin.selectAllTables") }}
</button>
</div>
<div class="max-h-56 overflow-auto p-1">
<div v-if="tablesLoading[database]" class="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
<div v-if="tablesLoading[authorizationOptionKey(database)]" class="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("userAdmin.loadingTables") }}
</div>
<div v-else-if="tableErrors[database]" class="px-3 py-4 text-center text-xs text-destructive">
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: tableErrors[database] }) }}</p>
<div v-else-if="tableErrors[authorizationOptionKey(database)]" class="px-3 py-4 text-center text-xs text-destructive">
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: tableErrors[authorizationOptionKey(database)] }) }}</p>
<button type="button" class="mt-2 text-primary" @click="loadTables(database)">{{ t("userAdmin.retry") }}</button>
</div>
<div v-else-if="filteredTables(database).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
{{ t("userAdmin.emptyTables") }}
</div>
<button v-for="table in filteredTables(database)" :key="table" type="button" class="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs hover:bg-accent" @click="toggleTable(database, table)">
<button v-for="table in filteredTables(database)" :key="authorizationTableKey(table)" type="button" class="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs hover:bg-accent" @click="toggleTable(database, table)">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border" :class="isTableSelected(database, table) ? 'border-primary bg-primary text-primary-foreground' : 'border-border'">
<Check v-if="isTableSelected(database, table)" class="h-2.5 w-2.5" />
</span>
<span class="min-w-0 flex-1 truncate">{{ table }}</span>
<span class="min-w-0 flex-1 truncate">{{ tableLabel(table) }}</span>
</button>
</div>
</PopoverContent>
@@ -319,7 +389,7 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
{{ t("userAdmin.customPrivilegeRequired") }}
</p>
</div>
<p v-if="provider?.dialect === 'postgres' && selectedDatabaseSet.has(database) && selectionFor(database)?.preset === 'ddl'" class="mt-2 text-[10px] text-muted-foreground" :class="compact ? '' : 'pl-5'">
<p v-if="provider?.dialect === 'postgres' && selectedDatabaseSet.has(authorizationOptionKey(database)) && selectionFor(database)?.preset === 'ddl'" class="mt-2 text-[10px] text-muted-foreground" :class="compact ? '' : 'pl-5'">
{{ t("userAdmin.postgresDdlHint") }}
</p>
</div>
@@ -16,15 +16,18 @@ import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { grantsFromQueryResult, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
import { grantsFromQueryResult, normalizeStarrocksCatalog, resolveDatabaseUserAdminProviderForConnection, type DatabaseTablePrivilegeGrant, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
import {
authorizationPlanSql,
authorizationPlanStatus,
authorizationPresetPrivileges,
authorizationPrivileges,
buildCreateUserAuthorizationPlan,
buildGrantAuthorizationPlan,
databaseAuthorizationsFromTableGrants,
executeAuthorizationPlan,
type AuthorizationAccountType,
type AuthorizationDatabaseOption,
type AuthorizationPlan,
type AuthorizationStepResult,
type DatabaseAuthorizationSelection,
@@ -88,12 +91,19 @@ const grantOption = ref(restoredUiState.grantOption ?? false);
const selectedPrivileges = ref<string[]>(restoredUiState.selectedPrivileges ?? ["SELECT"]);
const createCanLogin = ref(true);
const createAccountType = ref<AuthorizationAccountType>("standard");
const createDatabases = ref<string[]>([]);
const createDatabases = ref<AuthorizationDatabaseOption[]>([]);
const createDatabasesLoading = ref(false);
const createDatabasesLoadError = ref("");
const createDatabaseAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
// 权限编辑面板的授权范围选择(MySQL 表级授权场景),与新增用户弹窗复用同一个范围编辑器
// 权限编辑面板的授权范围选择,与新增用户弹窗复用同一个范围编辑器
const privilegeAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
const currentTableGrants = ref<DatabaseTablePrivilegeGrant[]>([]);
const currentTableGrantsLoaded = ref(false);
const currentTableGrantsLoading = ref(false);
const tableGrantLoadError = ref("");
let createPlanRequestId = 0;
let tableGrantRequestId = 0;
let databaseLoadPromise: Promise<void> | undefined;
let preserveRestoredPrivilegeSelection = restoredUiState.selectedPrivileges !== undefined || restoredUiState.grantOption !== undefined;
trackUiState(() => ({
@@ -139,9 +149,9 @@ const grantsSqlText = computed(() => grants.value.join("\n") || t("userAdmin.noG
const highlightedGrantsSql = computed(() => highlight(grantsSqlText.value));
const highlightedPendingSql = computed(() => highlight(pendingSql.value));
const createDatabaseAuthorizationsValid = computed(() => authorizationSelectionsValid(createDatabaseAuthorizations.value));
// MySQL 表级授权场景下,权限编辑面板复用与新增用户一致的授权范围编辑器
const usePrivilegeScopeEditor = computed(() => canEditPrivileges.value && supportsCreateTableGrants.value && privilegeScope.value !== "role");
const usePrivilegeScopeEditor = computed(() => canEditPrivileges.value && supportsCreateTableGrants.value && privilegeScope.value !== "role" && (provider.value?.authorizationModel !== "postgres" || privilegeScope.value === "table"));
const privilegeAuthorizationsValid = computed(() => authorizationSelectionsValid(privilegeAuthorizations.value));
const tableGrantDiffReady = computed(() => provider.value?.authorizationModel === "mysql" || currentTableGrantsLoaded.value);
const pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined));
const canPreviewHostChange = computed(() => {
const host = newHost.value.trim();
@@ -221,23 +231,94 @@ async function loadGrants() {
loadingGrants.value = true;
grantsLoaded.value = false;
grantError.value = "";
if (userProvider.authorizationModel !== "mysql") {
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
currentTableGrantsLoading.value = false;
tableGrantLoadError.value = "";
}
try {
const result = await api.executeQuery(props.connection.id, "", userProvider.showGrantsSql(user), undefined, undefined, {
maxRows: 1000,
});
grants.value = (userProvider.parseGrants ?? grantsFromQueryResult)(result);
grantsLoaded.value = true;
if (userProvider.tableGrantsFromShowGrants && userProvider.parseTableGrants) {
currentTableGrants.value = userProvider.parseTableGrants(result);
currentTableGrantsLoaded.value = true;
tableGrantLoadError.value = "";
syncAuthorizationSelectionsFromTableGrants();
}
// Existing-user editing reflects the exact SHOW GRANTS scope; create-user defaults stay independent.
syncPrivilegeSelectionFromGrants();
if (userProvider.authorizationModel === "postgres" && usePrivilegeScopeEditor.value) await loadCurrentTableGrants();
} catch (error: any) {
grantError.value = error?.message || String(error);
grants.value = [];
grantsLoaded.value = false;
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
} finally {
loadingGrants.value = false;
}
}
async function loadCurrentTableGrants() {
const user = selectedUser.value;
const userProvider = provider.value;
if (!user || !userProvider?.tableGrantsSql || !userProvider.parseTableGrants || currentTableGrantsLoading.value) return;
const requestId = ++tableGrantRequestId;
const selectedKey = userKey(user);
currentTableGrantsLoading.value = true;
currentTableGrantsLoaded.value = false;
tableGrantLoadError.value = "";
try {
if (createDatabases.value.length === 0) await loadDatabases();
if (createDatabasesLoadError.value) throw new Error(createDatabasesLoadError.value);
const grantsByDatabase = await Promise.all(
createDatabases.value.map(async ({ database }) => {
const result = await api.executeQuery(props.connection.id, database, userProvider.tableGrantsSql!(user), undefined, undefined, { maxRows: 10000 });
return userProvider.parseTableGrants!(result, { database });
}),
);
if (requestId !== tableGrantRequestId || selectedUserKey.value !== selectedKey) return;
currentTableGrants.value = grantsByDatabase.flat();
currentTableGrantsLoaded.value = true;
syncAuthorizationSelectionsFromTableGrants();
} catch (error: any) {
if (requestId !== tableGrantRequestId || selectedUserKey.value !== selectedKey) return;
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
tableGrantLoadError.value = error?.message || String(error);
} finally {
if (requestId === tableGrantRequestId) currentTableGrantsLoading.value = false;
}
}
function syncAuthorizationSelectionsFromTableGrants() {
const userProvider = provider.value;
if (!userProvider || createDatabases.value.length === 0 || privilegeAuthorizations.value.length > 0) return;
const loaded = databaseAuthorizationsFromTableGrants(userProvider, currentTableGrants.value);
const available = new Map(createDatabases.value.map((option) => [authorizationDatabaseOptionKey(option), option]));
const selections = loaded.selections.flatMap((selection) => {
const option = available.get(authorizationDatabaseOptionKey(selection));
if (!option) return [];
return [{ ...selection, catalog: option.catalog }];
});
privilegeAuthorizations.value = selections;
if (selections.length > 0) grantOption.value = loaded.grantOption;
}
function authorizationDatabaseOptionKey(option: AuthorizationDatabaseOption): string {
const catalog = provider.value?.authorizationModel === "starrocks" ? (normalizeStarrocksCatalog(option.catalog ?? "") ?? "default_catalog") : (option.catalog ?? "");
return JSON.stringify([catalog, option.database]);
}
async function preparePrivilegeScopeEditor() {
if (createDatabases.value.length === 0) await loadDatabases();
if (provider.value?.authorizationModel === "postgres" && !currentTableGrantsLoaded.value) await loadCurrentTableGrants();
}
function selectUser(user: DatabaseUserIdentity) {
selectedUserKey.value = userKey(user);
}
@@ -257,19 +338,39 @@ function togglePrivilege(privilege: string) {
}
// 加载当前连接下的数据库列表:新增用户弹窗与权限编辑面板的授权范围编辑器共用
async function loadDatabases() {
if (createDatabasesLoading.value) return;
createDatabases.value = [];
createDatabasesLoading.value = true;
try {
await ensureConnection();
const config = props.connection;
createDatabases.value = config.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(config.id, config) : (await api.listDatabases(config.id)).map((database) => database.name);
} catch (error: any) {
toast(t("userAdmin.loadDatabasesFailed", { message: error?.message || String(error) }), 5000);
} finally {
createDatabasesLoading.value = false;
}
function loadDatabases(): Promise<void> {
if (databaseLoadPromise) return databaseLoadPromise;
let request!: Promise<void>;
request = (async () => {
createDatabases.value = [];
createDatabasesLoading.value = true;
createDatabasesLoadError.value = "";
try {
await ensureConnection();
const config = props.connection;
if (provider.value?.authorizationModel === "starrocks") {
const catalogs = await api.listDorisCatalogs(config.id);
if (catalogs.length > 0) {
const catalogDatabases = await Promise.all(catalogs.map(async (catalog) => (await api.listDorisCatalogDatabases(config.id, catalog.name)).map((database) => ({ catalog: catalog.name, database: database.name }))));
createDatabases.value = catalogDatabases.flat();
} else {
createDatabases.value = (await api.listDatabases(config.id)).map((database) => ({ database: database.name }));
}
} else {
const databases = config.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(config.id, config) : (await api.listDatabases(config.id)).map((database) => database.name);
createDatabases.value = databases.map((database) => ({ database }));
}
if (currentTableGrantsLoaded.value) syncAuthorizationSelectionsFromTableGrants();
} catch (error: any) {
createDatabasesLoadError.value = error?.message || String(error);
toast(t("userAdmin.loadDatabasesFailed", { message: createDatabasesLoadError.value }), 5000);
} finally {
createDatabasesLoading.value = false;
if (databaseLoadPromise === request) databaseLoadPromise = undefined;
}
})();
databaseLoadPromise = request;
return request;
}
async function openCreateUserDialog() {
@@ -290,11 +391,23 @@ function authorizationSelectionsValid(selections: DatabaseAuthorizationSelection
if (!userProvider) return false;
return selections.every((selection) => {
if (selection.tables !== undefined && selection.tables.length === 0) return false;
if (selection.preset !== "custom") return true;
return authorizationPrivileges(userProvider, selection.tables === undefined ? "database" : "table").some((privilege) => selection.privileges?.includes(privilege));
const targetScope = selection.tables === undefined && userProvider.authorizationModel !== "starrocks" ? "database" : "table";
const available = authorizationPrivileges(userProvider, targetScope);
if (selection.preset !== "custom") return authorizationPresetPrivileges(userProvider, selection.preset, selection.privileges, targetScope).length > 0;
return available.some((privilege) => selection.privileges?.includes(privilege));
});
}
async function prepareAuthorizationSelections(selections: DatabaseAuthorizationSelection[]): Promise<DatabaseAuthorizationSelection[]> {
if (provider.value?.authorizationModel !== "postgres") return selections;
return Promise.all(
selections.map(async (selection) => ({
...selection,
schemas: selection.tables === undefined ? await api.listSchemas(props.connection.id, selection.database) : Array.from(new Set(selection.tables.flatMap((table) => (table.schema ? [table.schema] : [])))),
})),
);
}
function previewSql(sql: string, options: { danger?: boolean; afterApply?: () => Promise<void> } = {}) {
pendingSql.value = sql;
pendingPlan.value = undefined;
@@ -315,7 +428,7 @@ async function applyPendingSql() {
source: t("production.sourceAdmin"),
execute: async () => {
if (pendingPlan.value) {
return executeAuthorizationPlan(pendingPlan.value, (step) => api.executeMulti(props.connection.id, step.database, step.sql, undefined, undefined, { maxRows: 1000, continueOnError: true }));
return executeAuthorizationPlan(pendingPlan.value, (step) => api.executeMulti(props.connection.id, step.database, step.sql, undefined, undefined, { maxRows: 1000, catalog: step.targetCatalog, continueOnError: step.targetCatalog ? false : true }));
}
const queryResults = await api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000, continueOnError: true });
const failed = queryResults.find((item) => item.execution_error === true);
@@ -392,12 +505,15 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
if (step.operation === "createUser") return t("userAdmin.stepCreateUser");
if (step.operation === "grantAdmin") return t("userAdmin.stepGrantAdmin", { user: step.subject });
if (step.operation === "grantDatabase") {
return step.targetTable ? t("userAdmin.stepGrantTable", { user: step.subject, database: step.targetDatabase, table: step.targetTable }) : t("userAdmin.stepGrantDatabase", { user: step.subject, database: step.targetDatabase });
const database = authorizationStepDatabase(step);
return step.targetTable ? t("userAdmin.stepGrantTable", { user: step.subject, database, table: step.targetTable }) : t("userAdmin.stepGrantDatabase", { user: step.subject, database });
}
if (step.operation === "revokePrivileges") {
return step.targetTable ? t("userAdmin.stepRevokeTable", { user: step.subject, database: step.targetDatabase, table: step.targetTable }) : t("userAdmin.stepRevokeDatabase", { user: step.subject, database: step.targetDatabase });
const database = authorizationStepDatabase(step);
return step.targetTable ? t("userAdmin.stepRevokeTable", { user: step.subject, database, table: step.targetTable }) : t("userAdmin.stepRevokeDatabase", { user: step.subject, database });
}
if (step.operation === "grantCurrentObjects") {
if (step.targetTable) return t("userAdmin.stepGrantTable", { user: step.subject, database: authorizationStepDatabase(step), table: step.targetTable });
return t("userAdmin.stepGrantCurrentObjects", {
user: step.subject,
database: step.targetDatabase,
@@ -413,6 +529,10 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
return step.label;
}
function authorizationStepDatabase(step: AuthorizationStepResult["step"]): string {
return [step.targetCatalog, step.targetDatabase, step.targetSchema].filter(Boolean).join(".");
}
function authorizationObjectScopeLabel(scope: AuthorizationStepResult["step"]["objectScope"]): string {
if (scope === "schemas") return t("userAdmin.scopeSchemas");
if (scope === "tables") return t("userAdmin.scopeTables");
@@ -480,15 +600,23 @@ function previewPlan(plan: AuthorizationPlan, options: { danger?: boolean } = {}
sqlDialogOpen.value = true;
}
function previewGrant() {
async function previewGrant() {
const user = selectedUser.value;
const userProvider = provider.value;
const grantPrivilegesSql = userProvider?.grantPrivilegesSql;
if (!user || !userProvider || !grantPrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
if (usePrivilegeScopeEditor.value) {
// MySQL 表级授权:按所选库/表展开为多条 GRANT(仅追加,不回收该用户已有权限)
if (!privilegeAuthorizationsValid.value) return;
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases: privilegeAuthorizations.value, grantOption: grantOption.value }));
if (!privilegeAuthorizationsValid.value || !tableGrantDiffReady.value) return;
const databases = await prepareAuthorizationSelections(privilegeAuthorizations.value);
previewPlan(
buildGrantAuthorizationPlan({
provider: userProvider,
user,
databases,
grantOption: grantOption.value,
currentGrants: userProvider.authorizationModel === "mysql" ? undefined : currentTableGrants.value,
}),
);
return;
}
previewSql(
@@ -504,15 +632,15 @@ function previewGrant() {
);
}
function previewRevoke() {
async function previewRevoke() {
const user = selectedUser.value;
const userProvider = provider.value;
const revokePrivilegesSql = userProvider?.revokePrivilegesSql;
if (!user || !userProvider || !revokePrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
if (usePrivilegeScopeEditor.value) {
// 与授权保持同一套选择:按所选库/表展开为多条 REVOKE
if (!privilegeAuthorizationsValid.value) return;
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases: privilegeAuthorizations.value, revoke: true }), { danger: true });
if (!privilegeAuthorizationsValid.value || !tableGrantDiffReady.value) return;
const databases = await prepareAuthorizationSelections(privilegeAuthorizations.value);
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases, revoke: true, currentGrants: userProvider.authorizationModel === "mysql" ? undefined : currentTableGrants.value }), { danger: true });
return;
}
previewSql(
@@ -551,9 +679,14 @@ watch(
watch(
() => selectedUserKey.value,
() => {
tableGrantRequestId += 1;
grantsLoaded.value = false;
// 切换用户时清空上一位用户选择的授权范围,避免把权限误授给当前用户
privilegeAuthorizations.value = [];
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
currentTableGrantsLoading.value = false;
tableGrantLoadError.value = "";
void loadGrants();
},
);
@@ -561,17 +694,23 @@ watch(
watch(
() => props.connection.id,
() => {
tableGrantRequestId += 1;
users.value = [];
createDatabases.value = [];
createDatabasesLoadError.value = "";
createDatabaseAuthorizations.value = [];
privilegeAuthorizations.value = [];
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
currentTableGrantsLoading.value = false;
tableGrantLoadError.value = "";
selectedUserKey.value = "";
grants.value = [];
grantsLoaded.value = false;
privilegeScope.value = provider.value?.defaultScope ?? "mysql";
resetPrivilegeDefaults(privilegeScope.value);
void loadUsers();
if (usePrivilegeScopeEditor.value) void loadDatabases();
if (usePrivilegeScopeEditor.value) void preparePrivilegeScopeEditor();
},
);
@@ -599,10 +738,17 @@ watch(
watch([privilegeDatabase, privilegeTable], syncPrivilegeSelectionFromGrants);
watch(
() => usePrivilegeScopeEditor.value,
async (enabled) => {
if (!enabled) return;
await preparePrivilegeScopeEditor();
},
);
onMounted(() => {
void loadUsers();
// 权限编辑面板的授权范围选择需要数据库列表,进入页面即预加载(仅 MySQL 表级授权场景)
if (usePrivilegeScopeEditor.value) void loadDatabases();
if (usePrivilegeScopeEditor.value) void preparePrivilegeScopeEditor();
});
</script>
@@ -721,7 +867,7 @@ onMounted(() => {
<aside v-if="canEditPrivileges" class="flex min-h-0 flex-col bg-muted/10">
<div class="border-b p-3">
<div class="text-xs font-semibold">{{ t("userAdmin.privilegeEditor") }}</div>
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t(usePrivilegeScopeEditor ? "userAdmin.privilegeAppendHint" : "userAdmin.privilegeHint") }}</div>
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t(usePrivilegeScopeEditor && provider?.authorizationModel === "mysql" ? "userAdmin.privilegeAppendHint" : "userAdmin.privilegeHint") }}</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-3">
<template v-if="isPostgres">
@@ -743,7 +889,14 @@ onMounted(() => {
<label class="mb-2 block text-xs font-medium">{{ t("userAdmin.memberRole") }}</label>
<Input v-model="privilegeRole" class="mb-3 h-8 text-xs" :placeholder="t('userAdmin.memberRole')" />
</template>
<AuthorizationScopeEditor v-else-if="usePrivilegeScopeEditor" v-model="privilegeAuthorizations" compact :provider="provider" :databases="createDatabases" :databases-loading="createDatabasesLoading" :connection-id="connection.id" />
<div v-else-if="usePrivilegeScopeEditor" class="flex h-full min-h-0 flex-col gap-2">
<AuthorizationScopeEditor v-model="privilegeAuthorizations" compact :provider="provider" :databases="createDatabases" :databases-loading="createDatabasesLoading" :connection-id="connection.id" />
<div v-if="currentTableGrantsLoading" class="flex items-center gap-2 text-[11px] text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("userAdmin.loadingGrants") }}
</div>
<p v-else-if="tableGrantLoadError" class="text-[11px] text-destructive">{{ tableGrantLoadError }}</p>
</div>
<template v-else>
<label class="mb-2 block text-xs font-medium">
{{ isPostgres && privilegeScope !== "database" ? t("userAdmin.schema") : t("userAdmin.database") }}
@@ -781,10 +934,10 @@ onMounted(() => {
</span>
</label>
<div class="flex shrink-0 items-center justify-end gap-2 border-t p-3">
<Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewRevoke">
<Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && (!privilegeAuthorizationsValid || !tableGrantDiffReady)" @click="previewRevoke">
{{ t("userAdmin.revoke") }}
</Button>
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewGrant">
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && (!privilegeAuthorizationsValid || !tableGrantDiffReady)" @click="previewGrant">
{{ t("userAdmin.grant") }}
</Button>
</div>
@@ -158,7 +158,7 @@ const previewVisible = computed(() => open.value && !!props.source);
<Dialog v-model:open="open">
<!-- 对话框整体限高:高度上限跟随 --dbx-viewport-height(兼容旧版 WebView 中 vh 不准的情况);
内部采用纵向 flex 布局,header/footer 固定,只有中间预览区滚动,保证底部按钮始终可见 -->
<DialogContent class="flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]">
<DialogContent class="flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Camera class="h-5 w-5 text-primary" />
@@ -36,6 +36,8 @@ import type {
import { CONNECTION_PICKER_OPTIONS, CONNECTION_PROFILES, CONNECTION_PROFILE_ICONS, type ConnectionPickerOption, type ConnectionProfileCategory, type ConnectionProfileDefinition } from "@/types/generated/connectionProfiles";
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics";
import type { SalesforceAuthContext, SalesforceAuthMode, SalesforceEnvironment, SalesforceExternalConfig, SalesforceOAuthAuthorizeParams, SalesforceOAuthDevicePollResult } from "@/types/salesforce";
import { SALESFORCE_OAUTH_CALLBACK_URL } from "@/types/salesforce";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { MqttConnectionConfig } from "@/types/mqtt";
import type { NacosAdminConfig, NacosApiPlane, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
@@ -99,6 +101,7 @@ import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage,
import { consulAgentAddressesMatch } from "@/lib/consul/agentTarget";
import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from "@/lib/connection/connectionErrorHints";
import { buildCassandraExternalConfig, cassandraTlsConfigFromExternalConfig, type CassandraTlsConfig } from "@/lib/connection/cassandraTlsOptions";
import { savedMysqlTlsFormFields, supportsMysqlTlsOptions as mysqlTlsOptionsSupported, supportsMysqlTlsTab } from "@/lib/connection/mysqlTlsCapabilities";
import { preventDialogDocumentSelectAll } from "@/lib/connection/dialogTextSelection";
import { postgresLegacyTlsEnabled, postgresTlsModeForForm, setPostgresLegacyTlsEnabled } from "@/lib/connection/postgresTlsMode";
import { buildMqKafkaConnectionExtra, mqKafkaConnectionTarget, resolveMqKafkaConnectionSource, type MqKafkaConnectionSource } from "@/lib/connection/mqKafkaConnection";
@@ -1574,6 +1577,390 @@ function buildVictoriaMetricsExternalConfig(): VictoriaMetricsExternalConfig {
return { apiPath, lookback };
}
// ---------------------------------------------------------------------------
// Salesforce OAuth / device-code flow state
// ---------------------------------------------------------------------------
const salesforceAuthMode = ref<SalesforceAuthMode>("token");
const salesforceEnvironment = ref<SalesforceEnvironment>("production");
const salesforceLoginUrl = ref("");
const salesforceClientId = ref("");
const salesforceClientSecret = ref("");
// Username-password (ROPC) mode credentials. The password is never
// round-tripped from the backend; blank on edit unless re-typed.
const salesforceUsername = ref("");
const salesforceUserPassword = ref("");
const salesforceOauthRunning = ref(false);
const salesforceOauthError = ref("");
const salesforceOauthSuccess = ref("");
// When editing an existing connection whose credentials live in the backend's
// secret store, refreshToken/clientSecret are intentionally not sent back to
// the UI. Track "we have previously authorized" separately from whether we
// still hold the tokens in-memory so the form can show a status chip.
const salesforceOauthPreviouslyAuthorized = ref(false);
type SalesforceDevicePhase = "idle" | "polling" | "success" | "expired" | "denied" | "error";
const salesforceDevicePhase = ref<SalesforceDevicePhase>("idle");
const salesforceDeviceUserCode = ref("");
const salesforceDeviceVerificationUri = ref("");
const salesforceDeviceCode = ref("");
const salesforceDeviceIntervalSecs = ref(5);
const salesforceDeviceSecondsLeft = ref(0);
const salesforceDeviceError = ref("");
const salesforceDeviceUserCodeCopied = ref(false);
const salesforceDeviceVerificationCopied = ref(false);
let salesforceDevicePollTimer: ReturnType<typeof setTimeout> | null = null;
let salesforceDevicePollAbortId = 0;
let salesforceDeviceExpiresTimer: ReturnType<typeof setInterval> | null = null;
function salesforceAuthModeFromConfig(externalConfig: unknown, fallbackPassword?: string): SalesforceAuthMode {
const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined;
const mode = cfg?.auth?.mode;
if (mode === "oauth" || mode === "device" || mode === "password") return mode;
if (cfg?.auth && (cfg.auth.refreshToken || cfg.auth.clientId || cfg.auth.authorizedAt)) return "oauth";
// No persisted auth context: if a password/token is present, treat as manual token.
if (fallbackPassword?.trim()) return "token";
return "token";
}
function resetSalesforceOAuthFields(externalConfig?: unknown, fallbackPassword?: string) {
const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined;
const auth = cfg?.auth;
salesforceAuthMode.value = salesforceAuthModeFromConfig(externalConfig, fallbackPassword);
salesforceEnvironment.value = (auth?.environment as SalesforceEnvironment) || "production";
salesforceLoginUrl.value = auth?.loginUrl?.trim() || "";
salesforceClientId.value = auth?.clientId?.trim() || "";
// Client secret is never round-tripped from the backend for security; the
// input is blank on edit and only sent when the user re-types it.
salesforceClientSecret.value = "";
salesforceUsername.value = auth?.username?.trim() || "";
salesforceUserPassword.value = "";
salesforceOauthRunning.value = false;
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
// A Client ID alone only means the form was filled in, not that anybody ever
// signed in; claiming "authorized" from it showed a false reassurance banner.
salesforceOauthPreviouslyAuthorized.value = !!(auth?.refreshToken || auth?.authorizedAt);
salesforceDevicePhase.value = "idle";
salesforceDeviceUserCode.value = "";
salesforceDeviceVerificationUri.value = "";
salesforceDeviceCode.value = "";
salesforceDeviceIntervalSecs.value = 5;
salesforceDeviceSecondsLeft.value = 0;
salesforceDeviceError.value = "";
salesforceDeviceUserCodeCopied.value = false;
salesforceDeviceVerificationCopied.value = false;
salesforceDeviceStopPolling();
}
function hydrateSalesforceOAuthFields(value: unknown, fallbackPassword?: string) {
resetSalesforceOAuthFields(value, fallbackPassword);
}
function salesforceBuildAuthorizeParams(): SalesforceOAuthAuthorizeParams {
const clientId = salesforceClientId.value.trim();
if (!clientId) throw new Error(t("connection.salesforceOauthClientIdRequired"));
const environment = salesforceEnvironment.value;
const loginUrl = environment === "custom" ? salesforceLoginUrl.value.trim() : undefined;
if (environment === "custom" && !loginUrl) {
throw new Error(t("connection.salesforceOauthLoginUrlRequired"));
}
const clientSecret = salesforceClientSecret.value.trim() || undefined;
return { environment, loginUrl, clientId, clientSecret };
}
function salesforceApplyTokenToForm(token: { accessToken: string; instanceUrl: string }) {
form.value.password = token.accessToken;
// The backend normalizes bare hostnames; we mirror it so the card preview
// shows the final instance URL even before the backend canonicalizes it.
let instanceUrl = token.instanceUrl.trim();
if (instanceUrl && !/^https?:\/\//i.test(instanceUrl)) {
instanceUrl = `https://${instanceUrl.replace(/\/+$/, "")}`;
}
if (instanceUrl) {
form.value.host = instanceUrl.replace(/\/+$/, "");
}
}
function salesforceBuildAuthContext(mode: SalesforceAuthMode, token?: { refreshToken?: string }): SalesforceAuthContext | undefined {
if (mode === "token") return undefined;
const context: SalesforceAuthContext = {
mode,
environment: salesforceEnvironment.value,
clientId: salesforceClientId.value.trim() || undefined,
authorizedAt: new Date().toISOString(),
};
if (salesforceEnvironment.value === "custom") {
context.loginUrl = salesforceLoginUrl.value.trim() || undefined;
}
// Only forward the client secret when the user explicitly typed it in this
// session — otherwise we would overwrite the backend-stored secret with "".
if (salesforceClientSecret.value.trim()) {
context.clientSecret = salesforceClientSecret.value.trim();
}
if (mode === "password") {
context.username = salesforceUsername.value.trim() || undefined;
if (salesforceUserPassword.value) {
context.password = salesforceUserPassword.value;
}
}
if (token?.refreshToken) {
context.refreshToken = token.refreshToken;
}
return context;
}
function salesforceMergeAuthIntoExternalConfig(mode: SalesforceAuthMode, token?: { refreshToken?: string }) {
const existing = (form.value.external_config && typeof form.value.external_config === "object" ? (form.value.external_config as Record<string, unknown>) : {}) as SalesforceExternalConfig & Record<string, unknown>;
const auth = salesforceBuildAuthContext(mode, token);
if (!auth) {
const { auth: _dropped, ...rest } = existing;
form.value.external_config = Object.keys(rest).length > 0 ? rest : undefined;
return;
}
// Preserve an existing refreshToken when the user re-authorizes without the
// backend returning a new one (e.g. refresh flow that omits a rotation).
if (!auth.refreshToken && existing.auth?.refreshToken) {
auth.refreshToken = existing.auth.refreshToken;
}
// Same for the ROPC password: it is not round-tripped for display, so an
// edit-without-retype must not clobber the stored credential.
if (!auth.password && existing.auth?.password) {
auth.password = existing.auth.password;
}
form.value.external_config = { ...existing, auth };
}
async function salesforceStartBrowserAuthorize() {
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
salesforceOauthPreviouslyAuthorized.value = false;
let params: SalesforceOAuthAuthorizeParams;
try {
params = salesforceBuildAuthorizeParams();
} catch (e) {
salesforceOauthError.value = errorMessage(e);
return;
}
salesforceOauthRunning.value = true;
try {
const token = await api.salesforceOauthBrowserAuthorize(params);
salesforceApplyTokenToForm(token);
salesforceMergeAuthIntoExternalConfig("oauth", token);
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl });
salesforceOauthPreviouslyAuthorized.value = true;
} catch (e) {
salesforceOauthError.value = errorMessage(e);
} finally {
salesforceOauthRunning.value = false;
}
}
async function salesforceStartPasswordLogin() {
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
salesforceOauthPreviouslyAuthorized.value = false;
let params: SalesforceOAuthAuthorizeParams;
try {
params = salesforceBuildAuthorizeParams();
} catch (e) {
salesforceOauthError.value = errorMessage(e);
return;
}
const username = salesforceUsername.value.trim();
if (!username) {
salesforceOauthError.value = t("connection.salesforceUsernameRequired");
return;
}
if (!salesforceUserPassword.value) {
salesforceOauthError.value = t("connection.salesforceUserPasswordRequired");
return;
}
salesforceOauthRunning.value = true;
try {
const token = await api.salesforceOauthPasswordLogin(params, username, salesforceUserPassword.value);
salesforceApplyTokenToForm(token);
salesforceMergeAuthIntoExternalConfig("password", token);
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl });
salesforceOauthPreviouslyAuthorized.value = true;
} catch (e) {
salesforceOauthError.value = errorMessage(e);
} finally {
salesforceOauthRunning.value = false;
}
}
function salesforceDeviceStopPolling() {
salesforceDevicePollAbortId++;
if (salesforceDevicePollTimer) {
clearTimeout(salesforceDevicePollTimer);
salesforceDevicePollTimer = null;
}
if (salesforceDeviceExpiresTimer) {
clearInterval(salesforceDeviceExpiresTimer);
salesforceDeviceExpiresTimer = null;
}
}
function salesforceDeviceStartExpiryCountdown(expiresInSecs: number) {
salesforceDeviceSecondsLeft.value = Math.max(0, Math.floor(expiresInSecs));
if (salesforceDeviceExpiresTimer) clearInterval(salesforceDeviceExpiresTimer);
salesforceDeviceExpiresTimer = setInterval(() => {
salesforceDeviceSecondsLeft.value = Math.max(0, salesforceDeviceSecondsLeft.value - 1);
if (salesforceDeviceSecondsLeft.value <= 0 && salesforceDevicePhase.value === "polling") {
salesforceDeviceStopPolling();
salesforceDevicePhase.value = "expired";
}
}, 1000);
}
async function salesforceDeviceRequestCode() {
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
salesforceOauthPreviouslyAuthorized.value = false;
salesforceDeviceError.value = "";
salesforceDeviceUserCode.value = "";
salesforceDeviceVerificationUri.value = "";
salesforceDeviceUserCodeCopied.value = false;
salesforceDeviceVerificationCopied.value = false;
let params: SalesforceOAuthAuthorizeParams;
try {
params = salesforceBuildAuthorizeParams();
} catch (e) {
salesforceDeviceError.value = errorMessage(e);
salesforceDevicePhase.value = "error";
return;
}
salesforceOauthRunning.value = true;
salesforceDevicePhase.value = "idle";
try {
const start = await api.salesforceOauthDeviceStart(params);
salesforceDeviceUserCode.value = start.userCode;
salesforceDeviceVerificationUri.value = start.verificationUri;
salesforceDeviceCode.value = start.deviceCode;
salesforceDeviceIntervalSecs.value = Math.max(1, start.intervalSecs || 5);
salesforceDevicePhase.value = "polling";
salesforceDeviceStartExpiryCountdown(start.expiresInSecs);
await salesforceDevicePollLoop(params, start.deviceCode, salesforceDeviceIntervalSecs.value, salesforceDevicePollAbortId);
} catch (e) {
salesforceDeviceError.value = errorMessage(e);
salesforceDevicePhase.value = "error";
salesforceDeviceStopPolling();
} finally {
salesforceOauthRunning.value = false;
}
}
async function salesforceDevicePollLoop(params: SalesforceOAuthAuthorizeParams, deviceCode: string, intervalSecs: number, abortId: number): Promise<void> {
while (salesforceDevicePhase.value === "polling" && abortId === salesforceDevicePollAbortId) {
await new Promise<void>((resolve) => {
salesforceDevicePollTimer = setTimeout(() => resolve(), intervalSecs * 1000);
});
if (abortId !== salesforceDevicePollAbortId) return;
let result: SalesforceOAuthDevicePollResult;
try {
result = await api.salesforceOauthDevicePoll(params, deviceCode, intervalSecs);
} catch (e) {
// Transient network error: keep polling until the code expires.
if (salesforceDeviceSecondsLeft.value <= 0) {
salesforceDevicePhase.value = "expired";
salesforceDeviceStopPolling();
return;
}
salesforceDeviceError.value = errorMessage(e);
continue;
}
if (abortId !== salesforceDevicePollAbortId) return;
if (result.status === "pending") {
salesforceDeviceIntervalSecs.value = Math.max(1, result.intervalSecs || intervalSecs);
continue;
}
salesforceDeviceStopPolling();
if (result.status === "success") {
salesforceApplyTokenToForm(result.token);
salesforceMergeAuthIntoExternalConfig("device", result.token);
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: result.token.instanceUrl });
salesforceOauthPreviouslyAuthorized.value = true;
salesforceDevicePhase.value = "success";
return;
}
if (result.status === "expired") {
salesforceDevicePhase.value = "expired";
return;
}
if (result.status === "denied") {
salesforceDeviceError.value = result.reason || t("connection.salesforceDeviceDenied");
salesforceDevicePhase.value = "denied";
return;
}
}
}
function salesforceDeviceCancel() {
salesforceDeviceStopPolling();
salesforceDevicePhase.value = "idle";
salesforceDeviceUserCode.value = "";
salesforceDeviceVerificationUri.value = "";
salesforceDeviceCode.value = "";
}
function salesforceDeviceRestart() {
salesforceDeviceCancel();
salesforceDeviceError.value = "";
salesforceOauthError.value = "";
}
async function salesforceCopyUserCode() {
if (!salesforceDeviceUserCode.value) return;
await copyToClipboard(salesforceDeviceUserCode.value);
salesforceDeviceUserCodeCopied.value = true;
setTimeout(() => {
salesforceDeviceUserCodeCopied.value = false;
}, 1500);
}
async function salesforceCopyVerificationUri() {
if (!salesforceDeviceVerificationUri.value) return;
await copyToClipboard(salesforceDeviceVerificationUri.value);
salesforceDeviceVerificationCopied.value = true;
setTimeout(() => {
salesforceDeviceVerificationCopied.value = false;
}, 1500);
}
function salesforceOpenVerificationPage() {
if (!salesforceDeviceVerificationUri.value) return;
// Same pattern as the update-flow "open release notes" action: defer to the
// shell helper that picks the right opener for desktop vs. web.
void (async () => {
try {
if (isDesktop) {
const { open } = await import("@tauri-apps/plugin-shell");
await open(salesforceDeviceVerificationUri.value);
} else {
globalThis.open(salesforceDeviceVerificationUri.value, "_blank", "noopener,noreferrer");
}
} catch (e) {
salesforceDeviceError.value = errorMessage(e);
}
})();
}
function salesforceSetAuthMode(mode: SalesforceAuthMode) {
// Switching modes resets any in-flight state so stale tokens from one flow
// never leak into another.
if (salesforceOauthRunning.value && mode !== salesforceAuthMode.value) return;
salesforceAuthMode.value = mode;
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
if (mode !== "device") {
salesforceDeviceCancel();
salesforceDeviceError.value = "";
}
}
watch(influxDbVersion, (version, previousVersion) => {
if (form.value.db_type !== "influxdb") return;
if (version === "2" || version === "3") {
@@ -2546,6 +2933,9 @@ function applyProfile(val: string, preserveConnectionFields = false) {
form.value.connection_string = undefined;
form.value.url_params = "";
}
if (profile.type === "salesforce") {
resetSalesforceOAuthFields(form.value.external_config, form.value.password);
}
resetHiveKerberosFields(profile.type === "hive" || profile.type === "argo" || profile.type === "kyuubi" || profile.type === "impala" ? form.value : undefined);
}
if (profile.type === "meilisearch") {
@@ -2677,7 +3067,7 @@ watch(
db_type: oceanbasePatch?.db_type || profileConfig?.type || config.db_type,
driver_profile: config.db_type === "plugin" ? "plugin" : oceanbasePatch?.driver_profile || config.driver_profile || profile,
driver_label: config.driver_label || oceanbasePatch?.driver_label || driverProfiles[profile]?.label || config.db_type,
url_params: config.url_params || "",
...savedMysqlTlsFormFields(config),
agent_java_options: config.agent_java_options || [],
host: config.db_type === "h2" && h2FilePathFromJdbcUrl(config.connection_string) ? h2FilePathFromJdbcUrl(config.connection_string) : config.host,
port: profile === "tdengine" && (config.port === 0 || config.port === 6030) ? 6041 : config.port,
@@ -2694,10 +3084,6 @@ watch(
query_timeout_inherit: config.query_timeout_inherit === true,
idle_timeout_secs: config.idle_timeout_secs ?? 60,
keepalive_interval_secs: config.keepalive_interval_secs ?? 30,
ssl: config.ssl || false,
ca_cert_path: config.ca_cert_path || "",
client_cert_path: config.client_cert_path || "",
client_key_path: config.client_key_path || "",
sysdba: config.sysdba || isOracleSysUser(config),
oracle_connection_type: config.oracle_connection_type || "service_name",
connection_string: config.connection_string,
@@ -2777,6 +3163,11 @@ watch(
} else {
resetVictoriaMetricsFields();
}
if (config.db_type === "salesforce") {
hydrateSalesforceOAuthFields(config.external_config, config.password);
} else {
resetSalesforceOAuthFields(undefined, undefined);
}
resetElasticsearchProxyFields(config.db_type === "elasticsearch" ? config.external_config : undefined);
resetHiveKerberosFields(config.db_type === "hive" || config.db_type === "argo" || config.db_type === "kyuubi" || config.db_type === "impala" ? config : undefined);
resetDamengJvmOptions(config.db_type === "dameng" ? config : undefined);
@@ -3252,12 +3643,12 @@ const tlsCapableDatabaseTypes = new Set<DatabaseType>([
"cassandra",
"zookeeper",
]);
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type));
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type) || supportsMysqlTlsTab(form.value.db_type, selectedType.value));
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse" || form.value.db_type === "victoriametrics");
const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch" && form.value.db_type !== "hbase");
const showGenericUrlParamsHint = computed(() => form.value.db_type === "mysql" || form.value.db_type === "doris" || form.value.db_type === "starrocks");
const bareMysqlProfiles = new Set(["doris", "selectdb", "oceanbase"]);
const supportsMysqlTlsOptions = computed(() => form.value.db_type === "starrocks" || (form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value)));
const supportsMysqlTlsOptions = computed(() => mysqlTlsOptionsSupported(form.value.db_type, selectedType.value));
const supportsMysqlCleartextPasswordAuth = computed(() => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value));
const supportsDoltSystemTables = computed(() => isDoltDriverProfile(form.value.driver_profile));
const showDoltSystemTables = computed({
@@ -4426,6 +4817,51 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.connection_string = undefined;
config.database = "metrics";
config.username = config.username.trim();
} else if (config.db_type === "salesforce") {
// Token-mode connections never carry auth context (the access token itself is
// the credential in `password`). OAuth/device modes merge the auth context
// into `external_config.auth`; the backend handles refreshToken encryption.
config.connection_string = undefined;
config.database = undefined;
config.username = config.username.trim();
const existing = (config.external_config && typeof config.external_config === "object" ? (config.external_config as Record<string, unknown>) : {}) as SalesforceExternalConfig & Record<string, unknown>;
if (salesforceAuthMode.value === "token") {
const { auth: _dropped, ...rest } = existing;
config.external_config = Object.keys(rest).length > 0 ? rest : undefined;
} else {
// Preserve an existing refreshToken/clientSecret when the user saves the
// form without re-authorizing or re-typing them (they're not round-tripped
// from the backend). The hydrate step flagged this via
// `salesforceOauthPreviouslyAuthorized`.
const auth: SalesforceAuthContext = {
mode: salesforceAuthMode.value,
environment: salesforceEnvironment.value,
clientId: salesforceClientId.value.trim() || existing.auth?.clientId,
authorizedAt: existing.auth?.authorizedAt,
};
if (salesforceEnvironment.value === "custom") {
auth.loginUrl = salesforceLoginUrl.value.trim() || existing.auth?.loginUrl;
}
if (salesforceClientSecret.value.trim()) {
auth.clientSecret = salesforceClientSecret.value.trim();
} else if (existing.auth?.clientSecret) {
// Preserve secret indicator without leaking the value to the client.
auth.clientSecret = existing.auth.clientSecret;
}
if (salesforceAuthMode.value === "password") {
auth.username = salesforceUsername.value.trim() || existing.auth?.username;
if (salesforceUserPassword.value) {
auth.password = salesforceUserPassword.value;
} else if (existing.auth?.password) {
// Stored ROPC credential (scrubbed into the backend secret store);
// keep it when the user edits the connection without retyping.
auth.password = existing.auth.password;
}
} else if (existing.auth?.refreshToken) {
auth.refreshToken = existing.auth.refreshToken;
}
config.external_config = { ...existing, auth };
}
} else if (config.db_type === "elasticsearch") {
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value, elasticsearchIndexGroupingPattern.value, elasticsearchConnectivityCheckDisabled.value);
} else if (config.db_type === "meilisearch") {
@@ -4588,6 +5024,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.db_type !== "etcd" &&
config.db_type !== "consul" &&
config.db_type !== "starrocks" &&
config.db_type !== "doris" &&
config.db_type !== "mongodb" &&
config.db_type !== "victoriametrics" &&
config.db_type !== "zookeeper" &&
@@ -5476,6 +5913,7 @@ function resetForm(options: { preservePickerState?: boolean } = {}) {
appliedConnectionUrlInput.value = "";
resetMeilisearchHostInput();
oracleTnsAdminPath.value = "";
resetSalesforceOAuthFields(undefined, undefined);
if (!options.preservePickerState) {
dialogStep.value = "select";
dbSearchQuery.value = "";
@@ -6366,6 +6804,9 @@ onMounted(async () => {
onUnmounted(() => {
unlistenAgentInstallProgress?.();
unlistenAgentInstallProgress = null;
// Stop any in-flight Salesforce device-code poll / expiry countdown so
// closing the dialog does not leave orphan timers running.
salesforceDeviceStopPolling();
});
function openExternalUrl(url: string) {
@@ -6521,7 +6962,7 @@ function openExternalUrl(url: string) {
<TabsContent value="connection" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto pt-4 pr-2 pb-6" :class="{ 'connection-form-body--nacos': form.db_type === 'nacos' }">
<div v-if="!isPluginConnection && !isJdbcConnection && form.db_type !== 'nacos' && form.db_type !== 'consul' && form.db_type !== 'mq'" class="grid grid-cols-4 items-center gap-4">
<div v-if="!isPluginConnection && !isJdbcConnection && form.db_type !== 'nacos' && form.db_type !== 'consul' && form.db_type !== 'mq' && form.db_type !== 'salesforce'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="connectionUrlInput" class="flex-1" :placeholder="connectionUrlPlaceholder" @keydown.enter.prevent="applyConnectionUrl" />
@@ -7943,6 +8384,289 @@ function openExternalUrl(url: string) {
<CloudflareD1ConnectionFields v-model:account-id="form.host" v-model:database-id="form.database" v-model:api-token="form.password" />
</template>
<!-- Salesforce: instance URL + access token (SOQL) -->
<template v-else-if="form.db_type === 'salesforce'">
<!-- Instance URL (always visible so the user can confirm the
target org; OAuth/device flows overwrite it after a
successful authorize) -->
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceInstanceUrl") }}</Label>
<div class="col-span-3 space-y-1.5">
<div class="flex gap-2">
<Input v-model="form.host" class="flex-1" :placeholder="t('connection.salesforceInstanceUrlPlaceholder')" />
<Input v-model.number="form.port" type="number" class="w-24 shrink-0" />
</div>
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceInstanceUrlHint") }}</p>
</div>
</div>
<!-- Authentication mode picker -->
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceAuthMode") }}</Label>
<div class="col-span-3 grid h-8 grid-cols-4 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<button
type="button"
class="h-7 rounded-sm px-2 text-xs transition-colors"
:class="salesforceAuthMode === 'token' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="salesforceAuthMode === 'token'"
:disabled="salesforceOauthRunning"
@click="salesforceSetAuthMode('token')"
>
{{ t("connection.salesforceAuthModeToken") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-2 text-xs transition-colors"
:class="salesforceAuthMode === 'oauth' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50'"
:aria-pressed="salesforceAuthMode === 'oauth'"
:disabled="salesforceOauthRunning || !isDesktop"
:title="!isDesktop ? t('connection.salesforceAuthModeOauthDesktopOnly') : undefined"
@click="salesforceSetAuthMode('oauth')"
>
{{ t("connection.salesforceAuthModeOauth") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-2 text-xs transition-colors"
:class="salesforceAuthMode === 'device' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="salesforceAuthMode === 'device'"
:disabled="salesforceOauthRunning"
@click="salesforceSetAuthMode('device')"
>
{{ t("connection.salesforceAuthModeDevice") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-2 text-xs transition-colors"
:class="salesforceAuthMode === 'password' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="salesforceAuthMode === 'password'"
:disabled="salesforceOauthRunning"
@click="salesforceSetAuthMode('password')"
>
{{ t("connection.salesforceAuthModePassword") }}
</button>
</div>
</div>
<div v-if="!isDesktop && salesforceAuthMode === 'oauth'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceAuthModeOauthDesktopOnly") }}</p>
</div>
<!-- Manual access token -->
<template v-if="salesforceAuthMode === 'token'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceAccessToken") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.salesforceAccessTokenPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceAccessTokenHint") }}</p>
</div>
</template>
<!-- Shared OAuth / device-code fields -->
<template v-else>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceEnvironment") }}</Label>
<Select v-model="salesforceEnvironment" :disabled="salesforceOauthRunning">
<SelectTrigger class="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="production">{{ t("connection.salesforceEnvironmentProduction") }}</SelectItem>
<SelectItem value="sandbox">{{ t("connection.salesforceEnvironmentSandbox") }}</SelectItem>
<SelectItem value="custom">{{ t("connection.salesforceEnvironmentCustom") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="salesforceEnvironment === 'custom'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceLoginUrl") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="salesforceLoginUrl" :placeholder="t('connection.salesforceLoginUrlPlaceholder')" :disabled="salesforceOauthRunning" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceLoginUrlHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">
{{ t("connection.salesforceClientId") }}
<span class="ml-1 text-destructive">*</span>
</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="salesforceClientId" :placeholder="t('connection.salesforceClientIdPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="off" />
<!-- The callback URL only matters for the browser redirect flow; the
other two modes never see one, so keep it out of their way. -->
<p v-if="salesforceAuthMode === 'oauth'" class="text-xs leading-5 text-muted-foreground">
{{ t("connection.salesforceClientIdHint", { callbackUrl: SALESFORCE_OAUTH_CALLBACK_URL }) }}
</p>
<p v-else class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceClientIdHintShared") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceClientSecret") }}</Label>
<div class="col-span-3 space-y-1.5">
<PasswordInput v-model="salesforceClientSecret" :placeholder="t('connection.salesforceClientSecretPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="new-password" />
</div>
</div>
<!-- Username & password (ROPC) credentials -->
<template v-if="salesforceAuthMode === 'password'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceUsernameLabel") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="salesforceUsername" :placeholder="t('connection.salesforceUsernamePlaceholder')" :disabled="salesforceOauthRunning" autocomplete="off" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceUsernameHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceUserPasswordLabel") }}</Label>
<div class="col-span-3 space-y-1.5">
<PasswordInput v-model="salesforceUserPassword" :placeholder="t('connection.salesforceUserPasswordPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="new-password" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceUserPasswordHint") }}</p>
</div>
</div>
</template>
<!-- Previously-authorized banner (edit flow) -->
<div v-if="salesforceOauthPreviouslyAuthorized && !salesforceOauthRunning && salesforceDevicePhase !== 'polling'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
<ShieldCheck class="h-4 w-4 shrink-0" />
<span>{{ t("connection.salesforceOauthAuthorized") }}</span>
</div>
</div>
<!-- Browser OAuth action -->
<template v-if="salesforceAuthMode === 'oauth'">
<div class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2">
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim() || !isDesktop" @click="salesforceStartBrowserAuthorize">
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
{{ salesforceOauthRunning ? t("connection.salesforceOauthAuthorizing") : salesforceOauthPreviouslyAuthorized ? t("connection.salesforceOauthReauthorize") : t("connection.salesforceOauthAuthorize") }}
</Button>
</div>
</div>
<div v-if="salesforceOauthRunning" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceOauthAuthorizingHint") }}</p>
</div>
</template>
<!-- Username-password sign in -->
<template v-if="salesforceAuthMode === 'password'">
<div class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2">
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim() || !salesforceUsername.trim() || !salesforceUserPassword" @click="salesforceStartPasswordLogin">
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
{{ salesforceOauthRunning ? t("connection.salesforceOauthAuthorizing") : salesforceOauthPreviouslyAuthorized ? t("connection.salesforcePasswordSignInAgain") : t("connection.salesforcePasswordSignIn") }}
</Button>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforcePasswordRopcNote") }}</p>
</div>
</template>
<!-- Device-code flow -->
<template v-if="salesforceAuthMode === 'device'">
<div v-if="salesforceDevicePhase === 'idle' || salesforceDevicePhase === 'error'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2">
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim()" @click="salesforceDeviceRequestCode">
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
{{ t("connection.salesforceDeviceGetCode") }}
</Button>
</div>
</div>
<div v-if="salesforceDevicePhase === 'idle' || salesforceDevicePhase === 'error'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceDeviceFlowNote") }}</p>
</div>
<template v-if="salesforceDevicePhase === 'polling'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceDeviceUserCodeLabel") }}</Label>
<div class="col-span-3 space-y-1.5">
<div class="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2">
<span class="font-mono text-lg font-semibold tracking-widest">{{ salesforceDeviceUserCode }}</span>
<Button type="button" variant="ghost" size="icon" class="h-7 w-7" @click="salesforceCopyUserCode">
<Check v-if="salesforceDeviceUserCodeCopied" class="h-4 w-4 text-emerald-600" />
<Copy v-else class="h-4 w-4" />
</Button>
</div>
<div v-if="salesforceDeviceVerificationUri" class="flex items-center gap-2 text-xs">
<a class="text-primary underline underline-offset-2 hover:no-underline" href="#" @click.prevent="salesforceOpenVerificationPage">{{ t("connection.salesforceDeviceOpenVerification") }}</a>
<code class="truncate rounded bg-muted px-1 py-0.5 text-[11px]">{{ salesforceDeviceVerificationUri }}</code>
<Button type="button" variant="ghost" size="icon" class="h-6 w-6" @click="salesforceCopyVerificationUri">
<Check v-if="salesforceDeviceVerificationCopied" class="h-3.5 w-3.5 text-emerald-600" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-3 text-xs text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>{{ t("connection.salesforceDevicePolling") }}</span>
<span v-if="salesforceDeviceSecondsLeft > 0">· {{ t("connection.salesforceDeviceSecondsLeft", { seconds: salesforceDeviceSecondsLeft }) }}</span>
<Button type="button" variant="outline" size="sm" class="ml-auto" @click="salesforceDeviceCancel">
{{ t("connection.salesforceDeviceCancel") }}
</Button>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceDevicePollingHint") }}</p>
</div>
</template>
<div v-else-if="salesforceDevicePhase === 'expired'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs">
<span class="text-amber-700 dark:text-amber-300">{{ t("connection.salesforceDeviceExpired") }}</span>
<Button type="button" size="sm" variant="outline" class="self-start" @click="salesforceDeviceRestart">
{{ t("connection.salesforceDeviceRestart") }}
</Button>
</div>
</div>
<div v-else-if="salesforceDevicePhase === 'denied'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex flex-col gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
<span class="text-destructive">{{ salesforceDeviceError || t("connection.salesforceDeviceDenied") }}</span>
<Button type="button" size="sm" variant="outline" class="self-start" @click="salesforceDeviceRestart">
{{ t("connection.salesforceDeviceRestart") }}
</Button>
</div>
</div>
</template>
</template>
<!-- Shared status / error strip (oauth success + any error) -->
<div v-if="salesforceOauthSuccess && (salesforceAuthMode === 'oauth' || salesforceAuthMode === 'password' || salesforceDevicePhase === 'success')" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
<ShieldCheck class="h-4 w-4 shrink-0" />
<span>{{ salesforceOauthSuccess }}</span>
</div>
</div>
<div v-if="salesforceOauthError && (salesforceAuthMode === 'oauth' || salesforceAuthMode === 'password')" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{{ salesforceOauthError }}
</div>
</div>
<div v-if="salesforceDeviceError && salesforceAuthMode === 'device' && salesforceDevicePhase !== 'polling' && salesforceDevicePhase !== 'denied'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{{ salesforceDeviceError }}
</div>
</div>
</template>
<!-- MySQL / PostgreSQL: host, port, user, password, database -->
<template v-else>
<div v-if="form.db_type === 'ignite' || form.db_type === 'ignite3'" class="grid grid-cols-4 items-start gap-4">
@@ -5,9 +5,8 @@ import { Check, ChevronDown, ChevronRight, Folder, Search } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
import { buildConnectionPickerRows, connectionPickerSelectableRows } from "@/lib/connection/connectionPickerTree";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import { cn } from "@/lib/common/utils";
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
@@ -184,7 +183,7 @@ function handleKeydown(event: KeyboardEvent) {
@click="selectConnection(row.id)"
>
<Check :class="cn('h-3.5 w-3.5 shrink-0', row.id === modelValue ? 'opacity-100' : 'opacity-0')" />
<DatabaseIcon :db-type="connectionIconType(connectionById.get(row.id))" class="h-3.5 w-3.5 shrink-0" />
<ConnectionIcon :connection="connectionById.get(row.id)" class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ row.label }}</span>
</button>
</template>
@@ -0,0 +1,266 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { ConnectionConfig } from "@/types/database";
const { store, settings, backend } = vi.hoisted(() => ({
store: {
connectionGroupOptions: [],
newConnectionGroupId: null,
selectedConnectionGroupId: null,
addConnection: vi.fn(),
updateConnection: vi.fn(),
connect: vi.fn(),
ensureConnected: vi.fn(),
addEphemeralConnection: vi.fn(),
removeConnection: vi.fn(),
startEditing: vi.fn(),
stopEditing: vi.fn(),
clearConnectionError: vi.fn(),
updateConnectionDatabaseInfo: vi.fn(),
applyGlobalTimeouts: vi.fn((config) => config),
getConfig: vi.fn(),
},
settings: {
editorSettings: { sidebarShowConnectionNotes: false, globalConnectTimeoutSecs: 10, globalQueryTimeoutSecs: 0 },
rememberedDatabaseForConnection: vi.fn(() => ""),
persistEditorSettings: vi.fn(),
updateEditorSettings: vi.fn(),
updateEditorSettingsAndPersist: vi.fn(),
},
backend: {
connectDb: vi.fn(),
disconnectDb: vi.fn(),
testConnectionWithInfo: vi.fn(),
listDatabases: vi.fn(),
listPlugins: vi.fn(),
listJdbcDrivers: vi.fn(),
listJdbcMavenBundles: vi.fn(),
listJdbcLocalBundles: vi.fn(),
listSshConfigHosts: vi.fn(),
listInstalledAgentsLocal: vi.fn(),
listenAgentInstallProgress: vi.fn(),
},
}));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => store, CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "cancelled" }));
vi.mock("@/stores/settingsStore", async (original) => ({ ...(await original<typeof import("@/stores/settingsStore")>()), useSettingsStore: () => settings }));
vi.mock("@/stores/tunnelProfileStore", () => ({ useTunnelProfileStore: () => ({ profiles: [], profileById: () => undefined, init: vi.fn() }) }));
vi.mock("@/lib/backend/api", async (original) => ({ ...(await original<typeof import("@/lib/backend/api")>()), ...backend }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup:
(_props, { slots }) =>
() =>
h("div", slots.default?.()),
});
const Dialog = defineComponent({
props: { open: Boolean },
emits: ["update:open"],
setup:
(props, { slots, emit }) =>
() =>
props.open ? h("section", [h("button", { "data-testid": "dismiss-dialog", onClick: () => emit("update:open", false) }, "Dismiss"), slots.default?.()]) : null,
});
return { Dialog, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("button", attrs, slots.default?.()),
}),
};
});
vi.mock("@/components/ui/input", async () => {
const { defineComponent, h } = await import("vue");
return {
Input: defineComponent({
props: ["modelValue"],
emits: ["update:modelValue"],
setup:
(props, { attrs, emit }) =>
() =>
h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }),
}),
};
});
vi.mock("@/components/ui/PasswordInput.vue", async () => {
const { defineComponent, h } = await import("vue");
return {
default: defineComponent({
props: ["modelValue"],
setup:
(props, { attrs }) =>
() =>
h("input", { ...attrs, type: "password", value: props.modelValue }),
}),
};
});
vi.mock("@/components/ui/tabs", async () => {
const { defineComponent, h } = await import("vue");
const pass = defineComponent({
setup:
(_props, { slots }) =>
() =>
h("div", slots.default?.()),
});
const TabsContent = defineComponent({
props: ["value"],
setup:
(props, { slots }) =>
() =>
props.value === "connection" ? h("div", slots.default?.()) : null,
});
return { Tabs: pass, TabsContent, TabsList: pass, TabsTrigger: pass };
});
vi.mock("@/components/ui/tooltip", async () => {
const { defineComponent, h } = await import("vue");
const pass = defineComponent({
setup:
(_props, { slots }) =>
() =>
h("span", slots.default?.()),
});
return { HelpTooltip: pass, Tooltip: pass, TooltipContent: pass, TooltipTrigger: pass };
});
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
const mountedApps: App[] = [];
function salesforceConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "sfdc-sandbox",
name: "Salesforce sandbox",
db_type: "salesforce",
driver_profile: "salesforce",
driver_label: "Salesforce",
host: "https://acme--qas1.sandbox.my.salesforce.com",
port: 443,
password: "saved-access-token",
ssl: true,
save_password: true,
...overrides,
} as ConnectionConfig;
}
async function settle() {
for (let i = 0; i < 8; i++) await nextTick();
}
async function mountDialog(config: ConnectionConfig) {
const props = reactive({ open: true, editConfig: config });
store.getConfig.mockReturnValue(config);
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup: () => () =>
h(ConnectionDialog, {
...props,
"onUpdate:open": (value: boolean) => {
props.open = value;
},
}),
}),
);
app.use(i18n);
mountedApps.push(app);
app.mount(container);
await settle();
return props;
}
function findModeButton(label: string): HTMLButtonElement | undefined {
return [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
}
beforeEach(() => {
vi.clearAllMocks();
i18n.global.locale.value = "en";
for (const fn of Object.values(backend)) fn.mockResolvedValue([]);
backend.listenAgentInstallProgress.mockResolvedValue(() => {});
store.updateConnection.mockResolvedValue(undefined);
store.addConnection.mockResolvedValue(undefined);
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("ConnectionDialog Salesforce username-password mode", () => {
it("switches to password mode and reveals the credential inputs", async () => {
await mountDialog(salesforceConnection());
const passwordTab = findModeButton("Username & Password");
expect(passwordTab, "password mode button should exist").toBeTruthy();
expect(passwordTab!.disabled, "password mode button should be clickable").toBe(false);
passwordTab!.click();
await settle();
expect(passwordTab!.getAttribute("aria-pressed")).toBe("true");
const username = [...document.querySelectorAll("input")].find((input) => input.getAttribute("placeholder") === "user@example.com");
expect(username, "username input should render in password mode").toBeTruthy();
expect((username as HTMLInputElement).disabled).toBe(false);
const signIn = findModeButton("Sign In");
expect(signIn, "sign-in button should render in password mode").toBeTruthy();
});
it("keeps the password mode button enabled while a browser OAuth authorize is not running", async () => {
await mountDialog(salesforceConnection({ external_config: { auth: { mode: "oauth", environment: "sandbox", clientId: "consumer-key", authorizedAt: "2026-09-21T00:00:00.000Z" } } } as Partial<ConnectionConfig>));
const passwordTab = findModeButton("Username & Password");
expect(passwordTab).toBeTruthy();
expect(passwordTab!.disabled).toBe(false);
passwordTab!.click();
await settle();
expect(passwordTab!.getAttribute("aria-pressed")).toBe("true");
});
it("shows each mode only the guidance that applies to it", async () => {
const bodyText = () => document.body.textContent ?? "";
// Token mode carries no Connected App at all: neither client-ID hint renders,
// and Salesforce has no TLS toggle (the driver always speaks HTTPS).
await mountDialog(salesforceConnection());
expect(bodyText()).not.toContain("The Connected App consumer key.");
expect(bodyText()).not.toContain("Create a Connected App in Salesforce Setup");
expect(bodyText()).not.toContain("Enable encrypted connection");
const passwordTab = findModeButton("Username & Password");
passwordTab!.click();
await settle();
expect(bodyText()).toContain("The Connected App consumer key.");
expect(bodyText()).toContain("No callback URL needed");
expect(bodyText()).not.toContain("Create a Connected App in Salesforce Setup");
const deviceTab = findModeButton("Device Code");
deviceTab!.click();
await settle();
expect(bodyText()).toContain("If requesting the code fails");
expect(bodyText()).not.toContain("No callback URL needed");
});
it("reserves the callback-URL guidance for browser OAuth", async () => {
await mountDialog(salesforceConnection({ external_config: { auth: { mode: "oauth", environment: "sandbox", clientId: "consumer-key" } } } as Partial<ConnectionConfig>));
await settle();
const text = document.body.textContent ?? "";
expect(text).toContain("Create a Connected App in Salesforce Setup");
expect(text).not.toContain("The Connected App consumer key.");
expect(text).not.toContain("If requesting the code fails");
});
});
@@ -26,6 +26,13 @@ vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
}),
}));
vi.mock("@/components/plugins/PluginIcon.vue", () => ({
default: defineComponent({
props: ["pluginId", "contributionId"],
setup: (props) => () => h("span", { "data-plugin-id": props.pluginId, "data-contribution-id": props.contributionId }),
}),
}));
const mountedApps: App[] = [];
const connections: ConnectionConfig[] = [
@@ -45,12 +52,12 @@ const layout: SidebarLayout = {
order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }],
};
async function mountPicker() {
async function mountPicker(extraConnections: ConnectionConfig[] = []) {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(ConnectionTreeSelect, {
modelValue: "",
connections,
connections: [...connections, ...extraConnections],
layout,
placeholder: "Select connection",
searchPlaceholder: "Search connections",
@@ -82,6 +89,13 @@ afterEach(() => {
});
describe("ConnectionTreeSelect search collapse behavior", () => {
it("passes plugin identity and provider logo metadata to the shared icon", async () => {
await mountPicker([{ ...connections[0], id: "plugin", name: "Plugin", db_type: "plugin", plugin_id: "sample.plugin", plugin_connection_provider: "sample.connection" }]);
const icon = document.body.querySelector('[data-picker-connection="plugin"] [data-plugin-id]');
expect(icon?.getAttribute("data-plugin-id")).toBe("sample.plugin");
expect(icon?.getAttribute("data-contribution-id")).toBe("sample.connection");
});
it("ignores group clicks during search without changing the post-search state", async () => {
await mountPicker();
await setSearchText("Primary");
@@ -114,7 +114,7 @@ let initializingPrefillGeneration = 0;
let componentUnmounted = false;
let shownSessionError = "";
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(connection.db_type)));
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
// Bridge the shared TableMultiSelect `string[]` v-model with the Set-based selection store.
@@ -1447,7 +1447,7 @@ onBeforeUnmount(() => {
</div>
<DialogFooter v-if="!hasResults">
<Button variant="outline" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
<Button variant="outline" size="sm" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
<span v-if="compareProgressLabel" class="text-xs text-muted-foreground self-center">{{ compareProgressLabel }}</span>
<Button size="sm" :disabled="!canCompare || comparing" @click="startCompare">
<Loader2 v-if="comparing" class="w-3.5 h-3.5 animate-spin mr-1" />
@@ -1457,7 +1457,7 @@ onBeforeUnmount(() => {
</DialogFooter>
<DialogFooter v-else class="flex items-center gap-2">
<Button variant="outline" :disabled="(executing && manualTransaction) || resolvingTransaction" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
<Button variant="outline" size="sm" :disabled="(executing && manualTransaction) || resolvingTransaction" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
<Button variant="outline" size="sm" :disabled="comparing || executionLocked || !canCompare" @click="startCompare">
<Loader2 v-if="comparing" class="w-3 h-3 animate-spin mr-1" />
<RotateCcw v-else class="w-3 h-3 mr-1" />
@@ -84,7 +84,7 @@ const targetSchemas = ref<string[]>([]);
const sourceDbVersion = ref<string | null>(null);
const targetDbVersion = ref<string | null>(null);
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "solr", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "solr", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(c.db_type)));
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
@@ -9,7 +9,7 @@ import { DEFAULT_CUSTOM_THEME_DDL_COLORS } from "@/stores/settingsStore";
import { useDiffScrollSync } from "@/composables/useDiffScrollSync";
import { buildHunks, type DiffLine } from "@/components/diff/DiffHunkBuilder";
import { buildSchemaDiffHighlightSegments, type SchemaDiffHighlightSegment } from "@/lib/schema/schemaDiffHighlight";
import { findSchemaDiffDdlLineNumber } from "@/lib/schema/schemaDiffDdlLocate";
import { findFirstSchemaDiffChangedLineNumber, findSchemaDiffDdlLineNumber } from "@/lib/schema/schemaDiffDdlLocate";
import DiffSvgConnector from "@/components/diff/DiffSvgConnector.vue";
import { FileCode, ScrollText, Copy, Play, FileDiff } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
@@ -206,14 +206,19 @@ function scrollPaneToLine(pane: HTMLDivElement | undefined, lineNumber: number |
function locateFocusedObject() {
const selectedObject = props.selectedObject;
const focusedObject = props.focusedObject;
if (!selectedObject || !focusedObject || focusedObject.id === selectedObject.id) {
if (!selectedObject || !focusedObject) {
focusedSourceLineNumber.value = null;
focusedTargetLineNumber.value = null;
return;
}
focusedSourceLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.sourceDdl ?? "", focusedObject, "source");
focusedTargetLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.targetDdl ?? "", focusedObject, "target");
if (focusedObject.id === selectedObject.id) {
focusedSourceLineNumber.value = focusedObject.operationType === "modify" ? findFirstSchemaDiffChangedLineNumber(hunks.value, "source") : null;
focusedTargetLineNumber.value = focusedObject.operationType === "modify" ? findFirstSchemaDiffChangedLineNumber(hunks.value, "target") : null;
} else {
focusedSourceLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.sourceDdl ?? "", focusedObject, "source");
focusedTargetLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.targetDdl ?? "", focusedObject, "target");
}
scrollPaneToLine(leftPaneRef.value, focusedSourceLineNumber.value);
scrollPaneToLine(rightPaneRef.value, focusedTargetLineNumber.value);
}
@@ -40,6 +40,7 @@ import {
schemaDiffDeployTargetSchema,
normalizeDialectKind,
databaseTypeToDialectKind,
schemaDiffEngineDatabaseType,
findSchemaDiffObject,
flattenSchemaDiffObjects,
schemaDiffSelectionTargets,
@@ -107,6 +108,10 @@ const showFieldMappingDialog = ref(false);
const showConfigSelector = ref(false);
const sourceDbType = computed(() => store.getConfig(sourceConnectionId.value)?.db_type ?? "");
const targetDbType = computed(() => store.getConfig(targetConnectionId.value)?.db_type ?? "");
// A JDBC connection stores `jdbc` in db_type while its product lives in the driver
// profile; the diff engine needs the product for clause shapes and view comparison.
const sourceEngineDbType = computed(() => schemaDiffEngineDatabaseType(store.getConfig(sourceConnectionId.value)));
const targetEngineDbType = computed(() => schemaDiffEngineDatabaseType(store.getConfig(targetConnectionId.value)));
// Clear stale field mappings when source and target are the same type
watch([sourceDbType, targetDbType], ([src, tgt]) => {
@@ -742,6 +747,8 @@ function handleCompare(): void {
targetSchema: targetSchema.value,
sourceDbType,
targetDbType,
sourceEngineDbType: sourceEngineDbType.value,
targetEngineDbType: targetEngineDbType.value,
options,
ignoreComments: ignoreComments.value,
label: `${comparisonEndpointLabel(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value)} → ${comparisonEndpointLabel(targetConnectionId.value, targetDatabase.value, targetSchema.value)}`,
@@ -802,11 +809,12 @@ function rebuildDiffGroups() {
}
function buildSchemaSyncPlanOptions(options: SchemaDiffCompareOptions) {
const engineDbType = targetEngineDbType.value ?? getDbType();
return {
databaseType: getDbType(),
databaseType: engineDbType,
targetSchema: schemaDiffDeployTargetSchema(getDbType(), targetDatabase.value, targetSchema.value),
cascadeDelete: options.cascadeDelete,
sourceDialect: options.sourceDialect ? normalizeDialectKind(options.sourceDialect) : sourceDbType.value ? databaseTypeToDialectKind(sourceDbType.value) : undefined,
sourceDialect: options.sourceDialect ? normalizeDialectKind(options.sourceDialect) : sourceEngineDbType.value ? databaseTypeToDialectKind(sourceEngineDbType.value) : undefined,
fieldMappings: options.fieldMappings,
enableRollback: options.enableRollback,
};
@@ -0,0 +1,630 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { emptyAnnotations } from "@/docs/annotationEdits";
import type { SchemaSnapshot, SnapshotWarning, TableKind } from "@/docs/types";
import * as api from "@/lib/backend/api";
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import {
applyTemplate,
dataDictionaryFileName,
dictionaryCatalogNames,
exportBlockedBySkippedTables,
isDictionaryProfile,
objectKey,
orderDictionaryTables,
resolveDictionaryExportPath,
tableKindFrom,
warningsForSelection,
toggleOrderedKey,
moveOrderedKey,
type DataDictionaryLabels,
type DictionaryLayout,
type DictionaryObjectRef,
type DictionaryProfile,
type DictionaryTable,
type DictionaryTemplateId,
} from "@/lib/docs/dataDictionary";
import { buildDataDictionaryPdf } from "@/lib/docs/dataDictionaryPdf";
import { DictionaryFileExistsError, chooseDataDictionaryPath } from "@/lib/docs/saveDataDictionaryFile";
import { hasActiveDictionaryExport, startDataDictionaryExport } from "@/lib/docs/dataDictionaryExportTask";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useConnectionStore } from "@/stores/connectionStore";
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
prefillTableNames?: string[];
snapshot?: SchemaSnapshot | null;
}>();
const open = defineModel<boolean>("open", { default: false });
const { t } = useI18n();
const connectionStore = useConnectionStore();
const { toast } = useToast();
const step = ref(0);
const loading = ref(false);
const exporting = ref(false);
const loadError = ref<string | null>(null);
const exportError = ref<string | null>(null);
const databases = ref<Array<{ name: string; checked: boolean }>>([]);
const schemas = ref<Array<{ database: string; name: string; checked: boolean }>>([]);
const objects = ref<DictionaryObjectRef[]>([]);
const order = ref<string[]>([]);
const objectSearch = ref("");
const templateId = ref<DictionaryTemplateId>("standard");
const layout = ref<DictionaryLayout>(blankLayout());
const appendTimestamp = ref(false);
const overwrite = ref(true);
const continueOnError = ref(true);
const outputPath = ref("");
const previewUrl = ref("");
let generation = 0;
const steps = computed(() => [t("dataDictionary.stepDatabases"), t("dataDictionary.stepObjects"), t("dataDictionary.stepTemplate"), t("dataDictionary.stepLayout"), t("dataDictionary.stepFile")]);
const dbType = computed(() => connectionStore.getConfig(props.prefillConnectionId || "")?.db_type);
const schemaAware = computed(() => isSchemaAware(dbType.value));
const checkedDatabases = computed(() => databases.value.filter((item) => item.checked).map((item) => item.name));
const checkedSchemas = computed(() => schemas.value.filter((item) => item.checked && checkedDatabases.value.includes(item.database)));
const filteredObjects = computed(() => {
const query = objectSearch.value.trim().toLowerCase();
return objects.value.filter((item) => !query || `${item.database} ${item.schema} ${item.name}`.toLowerCase().includes(query));
});
const orderedObjects = computed(() => order.value.flatMap((key) => objects.value.filter((item) => objectKey(item) === key)));
const canNext = computed(() => {
if (loading.value) return false;
if (step.value === 0) return checkedDatabases.value.length > 0 && (!schemaAware.value || checkedSchemas.value.length > 0 || schemas.value.length === 0);
if (step.value === 1) return order.value.length > 0;
return true;
});
function blankLayout(): DictionaryLayout {
return applyTemplate("standard", { title: "", introduction: "", detailedIntroduction: "", leftFooter: "" });
}
function labels(): DataDictionaryLabels {
return {
column: t("dataDictionary.column"),
type: t("dataDictionary.type"),
length: t("dataDictionary.length"),
precision: t("dataDictionary.precision"),
scale: t("dataDictionary.scale"),
primaryKey: t("dataDictionary.primaryKey"),
nullable: t("dataDictionary.nullable"),
unique: t("dataDictionary.unique"),
defaultValue: t("dataDictionary.defaultValue"),
extra: t("dataDictionary.extra"),
comment: t("dataDictionary.comment"),
indexName: t("dataDictionary.indexName"),
indexColumns: t("dataDictionary.indexColumns"),
indexType: t("dataDictionary.indexType"),
constraintName: t("dataDictionary.constraintName"),
refSchema: t("dataDictionary.refSchema"),
refTable: t("dataDictionary.refTable"),
refColumn: t("dataDictionary.refColumn"),
onUpdate: t("dataDictionary.onUpdate"),
onDelete: t("dataDictionary.onDelete"),
yes: t("dataDictionary.yes"),
no: t("dataDictionary.no"),
indexesHeading: t("dataDictionary.indexesHeading"),
foreignKeysHeading: t("dataDictionary.foreignKeysHeading"),
contentsHeading: t("dataDictionary.contentsHeading"),
kindTable: t("dataDictionary.kindTable"),
kindView: t("dataDictionary.kindView"),
kindMaterializedView: t("dataDictionary.kindMaterializedView"),
};
}
function kindLabel(kind: TableKind): string {
if (kind === "VIEW") return t("dataDictionary.kindView");
if (kind === "MATERIALIZED_VIEW") return t("dataDictionary.kindMaterializedView");
return t("dataDictionary.kindTable");
}
function objectLabel(item: DictionaryObjectRef): string {
const schema = item.schema ? `${item.schema}.` : "";
return `${item.database}.${schema}${item.name}`;
}
function warningText(warning: SnapshotWarning): string {
switch (warning.kind) {
case "tableSkipped":
return t("dataDictionary.warningTableSkipped", { table: warning.table, reason: warning.reason });
case "noForeignKeyMetadata":
return t("dataDictionary.warningNoForeignKeys", { engine: warning.engine });
case "commentsUnsupported":
return t("dataDictionary.warningCommentsUnsupported", { engine: warning.engine });
default:
return "";
}
}
function chooseTemplate(id: DictionaryTemplateId): void {
templateId.value = id;
const title = checkedDatabases.value.join(", ") || props.prefillDatabase || "Data Dictionary";
layout.value = applyTemplate(id, {
title,
introduction: t("dataDictionary.introductionDefault"),
detailedIntroduction: `${t("dataDictionary.introductionDefault")}\n${t("dataDictionary.detailedIntroduction")}`,
leftFooter: title,
});
}
async function loadDatabases(): Promise<void> {
const mine = ++generation;
loading.value = true;
loadError.value = null;
databases.value = [];
schemas.value = [];
try {
const connectionId = props.prefillConnectionId;
if (!connectionId) throw new Error(t("dataDictionary.empty"));
await connectionStore.ensureConnected(connectionId);
if (mine !== generation) return;
const listed = await api.listDatabases(connectionId);
if (mine !== generation) return;
const names = dictionaryCatalogNames(
listed.map((item) => item.name).filter((name) => name !== ""),
dbType.value,
props.prefillDatabase,
);
const preferred = props.prefillDatabase && names.includes(props.prefillDatabase) ? props.prefillDatabase : names[0];
databases.value = (names.length ? names : [props.prefillDatabase || ""].filter(Boolean)).map((name) => ({ name, checked: name === preferred }));
await loadSchemas();
} catch (error) {
if (mine === generation) loadError.value = t("dataDictionary.loadFailed", { error: error instanceof Error ? error.message : String(error) });
} finally {
if (mine === generation) loading.value = false;
}
}
async function toggleDatabase(item: { name: string; checked: boolean }, checked: boolean): Promise<void> {
item.checked = checked;
await loadSchemas();
}
async function loadSchemas(): Promise<void> {
if (!schemaAware.value) {
schemas.value = [];
return;
}
const connectionId = props.prefillConnectionId;
if (!connectionId) return;
const previous = new Set(schemas.value.filter((item) => item.checked).map((item) => `${item.database}.${item.name}`));
const firstLoad = schemas.value.length === 0;
const next: Array<{ database: string; name: string; checked: boolean }> = [];
for (const database of checkedDatabases.value) {
const names = dictionaryCatalogNames(await api.listSchemas(connectionId, database), dbType.value, database === props.prefillDatabase ? props.prefillSchema : undefined);
for (const name of names) {
const key = `${database}.${name}`;
const prefilled = !props.prefillSchema || (database === props.prefillDatabase && name === props.prefillSchema);
next.push({ database, name, checked: firstLoad ? prefilled : previous.has(key) });
}
}
if (firstLoad && props.prefillSchema && next.length > 0 && !next.some((item) => item.checked)) next.forEach((item) => (item.checked = true));
schemas.value = next;
}
async function loadObjects(): Promise<void> {
const connectionId = props.prefillConnectionId;
if (!connectionId) return;
loading.value = true;
loadError.value = null;
try {
const targets = schemaAware.value ? checkedSchemas.value.map((item) => ({ database: item.database, schema: item.name })) : checkedDatabases.value.map((database) => ({ database, schema: "" }));
const listed: DictionaryObjectRef[] = [];
for (const target of targets) {
const tables = await api.listTables(connectionId, target.database, target.schema, undefined, undefined, undefined, ["TABLE", "VIEW", "MATERIALIZED_VIEW"] satisfies SidebarObjectKind[]);
for (const table of tables) {
listed.push({ database: target.database, schema: target.schema || table.parent_schema || "", name: table.name, kind: tableKindFrom(table.table_type) });
}
}
objects.value = listed;
const wanted = new Set(props.prefillTableNames ?? []);
const initial = listed.filter((item) => (wanted.size > 0 ? wanted.has(item.name) : item.kind === "TABLE"));
order.value = (initial.length ? initial : listed).map(objectKey);
} catch (error) {
loadError.value = t("dataDictionary.loadFailed", { error: error instanceof Error ? error.message : String(error) });
objects.value = [];
order.value = [];
} finally {
loading.value = false;
}
}
async function collectSelection(): Promise<{ tables: DictionaryTable[]; warnings: string[] }> {
const connectionId = props.prefillConnectionId;
if (!connectionId) throw new Error(t("dataDictionary.empty"));
const warnings: string[] = [];
const tables: DictionaryTable[] = [];
if (props.snapshot) {
const database = props.snapshot.project.database || props.prefillDatabase || "";
const fromSnapshot = orderDictionaryTables(
props.snapshot.tables.map((table) => ({ ...table, database })),
order.value,
);
if (fromSnapshot.length === order.value.length) {
const relevant = warningsForSelection(props.snapshot.warnings, orderedObjects.value);
if (exportBlockedBySkippedTables(relevant, continueOnError.value)) {
throw new Error(relevant.map(warningText).find(Boolean) || database);
}
return { tables: fromSnapshot, warnings: relevant.map(warningText).filter(Boolean) };
}
}
for (const database of checkedDatabases.value) {
const schemaNames = schemaAware.value ? checkedSchemas.value.filter((item) => item.database === database).map((item) => item.name) : [];
const tableNames = orderedObjects.value.filter((item) => item.database === database).map((item) => (item.schema ? `${item.schema}.${item.name}` : item.name));
if (tableNames.length === 0) continue;
try {
const raw = await api.collectDocsSnapshot(connectionId, database, schemaNames, tableNames, database);
const file = (await api.loadDocsAnnotations(connectionId)) ?? emptyAnnotations();
const merged = await api.applyDocsAnnotations(connectionId, raw, file);
const relevant = warningsForSelection(merged.warnings, orderedObjects.value);
if (exportBlockedBySkippedTables(relevant, continueOnError.value)) throw new Error(relevant.map(warningText).find(Boolean) || database);
warnings.push(...relevant.map(warningText).filter(Boolean));
tables.push(...merged.tables.map((table) => ({ ...table, database })));
} catch (error) {
if (!continueOnError.value) throw error;
warnings.push(t("dataDictionary.warningTableSkipped", { table: database, reason: error instanceof Error ? error.message : String(error) }));
}
}
return { tables: orderDictionaryTables(tables, order.value), warnings };
}
async function refreshPreview(): Promise<void> {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
try {
const document = await collectSelection();
const bytes = buildDataDictionaryPdf(document.tables, labels(), layout.value, document.warnings);
previewUrl.value = URL.createObjectURL(new Blob([bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer], { type: "application/pdf" }));
} catch (error) {
exportError.value = t("dataDictionary.previewFailed", { error: error instanceof Error ? error.message : String(error) });
}
}
async function goNext(): Promise<void> {
exportError.value = null;
if (step.value === 0) {
await loadObjects();
if (loadError.value || order.value.length === 0) return;
chooseTemplate(templateId.value);
}
if (step.value === 1 && order.value.length === 0) return;
const enteringLayout = step.value === 2;
if (step.value < 4) step.value += 1;
if (enteringLayout) await refreshPreview();
}
async function browse(): Promise<void> {
if (!isTauriRuntime()) return;
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: dataDictionaryFileName(checkedDatabases.value[0] || "DataDictionary", appendTimestamp.value), filters: [{ name: "PDF", extensions: ["pdf"] }] });
if (path) outputPath.value = path;
}
async function exportDictionary(): Promise<void> {
const connectionId = props.prefillConnectionId;
if (!connectionId) return;
if (hasActiveDictionaryExport(connectionId)) {
exportError.value = t("dataDictionary.alreadyRunning");
return;
}
exporting.value = true;
exportError.value = null;
try {
const fallback = dataDictionaryFileName(checkedDatabases.value[0] || "DataDictionary", false);
const suggested = resolveDictionaryExportPath(outputPath.value, fallback, appendTimestamp.value);
const path = await chooseDataDictionaryPath(suggested);
if (!path) return;
const started = startDataDictionaryExport({
connectionId,
databases: [...checkedDatabases.value],
schemas: checkedSchemas.value.map((item) => ({ database: item.database, name: item.name })),
objects: orderedObjects.value.map((item) => ({ ...item })),
snapshot: props.snapshot,
layout: { ...layout.value },
labels: labels(),
outputPath: path,
overwrite: overwrite.value,
continueOnError: continueOnError.value,
warningText: (warning) => warningText(warning),
skippedDatabase: (database, reason) => t("dataDictionary.warningTableSkipped", { table: database, reason }),
missingObject: (object) => t("dataDictionary.warningTableSkipped", { table: objectLabel(object), reason: t("dataDictionary.objectUnavailable") }),
onSuccess: (saved, warnings) => toast(t("dataDictionary.backgroundDone", { path: saved }) + (warnings ? ` · ${t("dataDictionary.warningCount", { count: warnings })}` : ""), 8000),
onFailure: (error) => toast(t("dataDictionary.backgroundFailed", { error: error instanceof DictionaryFileExistsError ? t("dataDictionary.fileExists") : error instanceof Error ? error.message : String(error) }), 8000),
});
if (!started) throw new Error(t("dataDictionary.alreadyRunning"));
toast(t("dataDictionary.backgroundStarted"));
open.value = false;
} catch (error) {
exportError.value = error instanceof DictionaryFileExistsError ? t("dataDictionary.fileExists") : t("dataDictionary.exportFailed", { error: error instanceof Error ? error.message : String(error) });
} finally {
exporting.value = false;
}
}
function profilePayload(): DictionaryProfile {
return {
version: 1,
databases: checkedDatabases.value,
schemas: checkedSchemas.value.map((item) => ({ database: item.database, name: item.name })),
objects: orderedObjects.value.map((item) => ({ database: item.database, schema: item.schema, name: item.name })),
layout: layout.value,
appendTimestamp: appendTimestamp.value,
overwrite: overwrite.value,
continueOnError: continueOnError.value,
fileName: outputPath.value,
};
}
async function saveProfile(): Promise<void> {
const content = JSON.stringify(profilePayload(), null, 2);
if (isTauriRuntime()) {
const [{ save }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const path = await save({ defaultPath: "data-dictionary.json", filters: [{ name: "JSON", extensions: ["json"] }] });
if (path) await fs.writeTextFile(path, content);
return;
}
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "data-dictionary.json";
anchor.click();
URL.revokeObjectURL(url);
}
async function loadProfile(): Promise<void> {
try {
const text = isTauriRuntime() ? await readProfileFromDialog() : await readProfileFromBrowser();
if (!text) return;
const parsed: unknown = JSON.parse(text);
if (!isDictionaryProfile(parsed)) throw new Error(t("dataDictionary.profileInvalid"));
layout.value = parsed.layout;
appendTimestamp.value = parsed.appendTimestamp;
overwrite.value = parsed.overwrite;
continueOnError.value = parsed.continueOnError;
outputPath.value = parsed.fileName;
databases.value = databases.value.map((item) => ({ ...item, checked: parsed.databases.includes(item.name) }));
await loadSchemas();
schemas.value = schemas.value.map((item) => ({ ...item, checked: parsed.schemas.some((schema) => schema.database === item.database && schema.name === item.name) }));
await loadObjects();
order.value = parsed.objects.map((item) => objectKey(item)).filter((key) => objects.value.some((item) => objectKey(item) === key));
} catch (error) {
loadError.value = t("dataDictionary.loadFailed", { error: error instanceof Error ? error.message : String(error) });
}
}
async function readProfileFromDialog(): Promise<string | null> {
const [{ open: pick }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const path = await pick({ filters: [{ name: "JSON", extensions: ["json"] }] });
if (!path || Array.isArray(path)) return null;
return fs.readTextFile(path);
}
function readProfileFromBrowser(): Promise<string | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json,application/json";
input.onchange = () => {
const file = input.files?.[0];
if (!file) return resolve(null);
void file.text().then(resolve);
};
input.click();
});
}
function setFontSize(size: "small" | "medium" | "large"): void {
const scale = size === "small" ? 0.85 : size === "large" ? 1.15 : 1;
layout.value = { ...layout.value, headingSize: Math.round(16 * scale), bodySize: Math.round(9 * scale) };
}
watch(
open,
(isOpen) => {
if (!isOpen) {
generation += 1;
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
return;
}
step.value = 0;
exportError.value = null;
outputPath.value = "";
appendTimestamp.value = false;
overwrite.value = true;
continueOnError.value = true;
templateId.value = "standard";
void loadDatabases();
},
{ immediate: true },
);
onBeforeUnmount(() => {
generation += 1;
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="flex h-[min(760px,88vh)] w-[min(980px,94vw)] max-w-[94vw] flex-col gap-0 p-0 sm:max-w-[94vw]">
<DialogHeader class="border-b px-4 py-3 pr-12">
<DialogTitle>{{ t("dataDictionary.title") }}</DialogTitle>
<DialogDescription>{{ steps[step] }}</DialogDescription>
</DialogHeader>
<div class="flex items-center gap-2 border-b px-4 py-2 text-xs">
<span v-for="(label, index) in steps" :key="label" :class="index === step ? 'font-medium text-foreground' : 'text-muted-foreground'">{{ index + 1 }}. {{ label }}</span>
<span class="ml-auto flex gap-2">
<Button type="button" size="sm" variant="outline" @click="loadProfile()">{{ t("dataDictionary.loadProfile") }}</Button>
<Button type="button" size="sm" variant="outline" @click="saveProfile()">{{ t("dataDictionary.saveProfile") }}</Button>
</span>
</div>
<div class="min-h-0 flex-1 overflow-auto p-4">
<p v-if="loading" class="text-sm text-muted-foreground">{{ t("dataDictionary.loading") }}</p>
<p v-if="loadError" class="mb-2 text-sm text-destructive">{{ loadError }}</p>
<div v-if="step === 0" class="grid gap-4 md:grid-cols-2">
<section>
<h3 class="mb-2 text-sm font-medium">{{ t("dataDictionary.databases") }}</h3>
<label v-for="item in databases" :key="item.name" class="flex items-center gap-2 py-1 text-sm">
<input type="checkbox" :checked="item.checked" @change="toggleDatabase(item, ($event.target as HTMLInputElement).checked)" />
<span>{{ item.name }}</span>
</label>
</section>
<section v-if="schemaAware">
<h3 class="mb-2 text-sm font-medium">{{ t("dataDictionary.schemasHeading") }}</h3>
<p v-if="schemas.length === 0" class="text-sm text-muted-foreground">{{ t("dataDictionary.noSchemaLevel") }}</p>
<label v-for="item in schemas" :key="`${item.database}.${item.name}`" class="flex items-center gap-2 py-1 text-sm">
<input v-model="item.checked" type="checkbox" />
<span>{{ item.database }}.{{ item.name }}</span>
</label>
</section>
</div>
<div v-else-if="step === 1" class="grid h-full gap-3 md:grid-cols-2">
<section class="flex min-h-0 flex-col gap-2">
<div class="flex gap-2">
<Input v-model="objectSearch" :placeholder="t('dataDictionary.searchObjects')" />
<Button type="button" size="sm" variant="outline" @click="order = filteredObjects.map(objectKey)">{{ t("dataDictionary.selectAll") }}</Button>
<Button type="button" size="sm" variant="outline" @click="order = order.filter((key) => !filteredObjects.some((item) => objectKey(item) === key))">{{ t("dataDictionary.selectNone") }}</Button>
</div>
<div class="min-h-0 flex-1 overflow-auto rounded border">
<label v-for="item in filteredObjects" :key="objectKey(item)" class="flex items-center gap-2 border-b px-2 py-1 text-sm">
<input type="checkbox" :checked="order.includes(objectKey(item))" @change="order = toggleOrderedKey(order, objectKey(item), ($event.target as HTMLInputElement).checked)" />
<span class="min-w-0 flex-1 truncate">{{ objectLabel(item) }}</span>
<span class="text-xs text-muted-foreground">{{ kindLabel(item.kind) }}</span>
</label>
</div>
</section>
<section>
<h3 class="mb-2 text-sm font-medium">{{ t("dataDictionary.selectedCount", { count: order.length }) }}</h3>
<div class="max-h-[420px] overflow-auto rounded border">
<div v-for="item in orderedObjects" :key="objectKey(item)" class="flex items-center gap-2 border-b px-2 py-1 text-sm">
<span class="min-w-0 flex-1 truncate">{{ objectLabel(item) }}</span>
<Button type="button" size="sm" variant="outline" @click="order = moveOrderedKey(order, objectKey(item), -1)">{{ t("dataDictionary.moveUp") }}</Button>
<Button type="button" size="sm" variant="outline" @click="order = moveOrderedKey(order, objectKey(item), 1)">{{ t("dataDictionary.moveDown") }}</Button>
</div>
</div>
</section>
</div>
<div v-else-if="step === 2" class="grid gap-2">
<button v-for="id in ['standard', 'detailed', 'compact', 'catalog', 'reference']" :key="id" type="button" class="rounded border px-3 py-2 text-left" :class="templateId === id ? 'border-foreground bg-muted' : 'border-border'" @click="chooseTemplate(id as DictionaryTemplateId)">
<span class="block text-sm font-medium">{{ t(`dataDictionary.template${id[0]!.toUpperCase()}${id.slice(1)}`) }}</span>
<span class="text-xs text-muted-foreground">{{ t(`dataDictionary.template${id[0]!.toUpperCase()}${id.slice(1)}Hint`) }}</span>
</button>
</div>
<div v-else-if="step === 3" class="grid h-full gap-3 md:grid-cols-[280px_minmax(0,1fr)]">
<div class="flex flex-col gap-3 overflow-auto pr-1">
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeCover") }}</span
><input v-model="layout.includeCover" type="checkbox"
/></label>
<Label>{{ t("dataDictionary.header") }}</Label>
<Input v-model="layout.header" />
<Label>{{ t("dataDictionary.dictionaryTitle") }}</Label>
<Input v-model="layout.title" />
<Label>{{ t("dataDictionary.subtitle") }}</Label>
<Input v-model="layout.subtitle" />
<Label>{{ t("dataDictionary.remarks") }}</Label>
<Input v-model="layout.remarks" />
<Label>{{ t("dataDictionary.coverFooter") }}</Label>
<Input v-model="layout.coverFooter" />
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeToc") }}</span
><input v-model="layout.includeToc" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeBreadcrumbs") }}</span
><input v-model="layout.includeBreadcrumbs" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeLeftFooter") }}</span
><input v-model="layout.includeLeftFooter" type="checkbox"
/></label>
<Input v-model="layout.leftFooter" />
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includePageNumber") }}</span
><input v-model="layout.includePageNumber" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeIntroduction") }}</span
><input v-model="layout.includeIntroduction" type="checkbox"
/></label>
<textarea v-model="layout.introduction" class="min-h-24 rounded border bg-transparent p-2 text-sm" />
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeIndexes") }}</span
><input v-model="layout.includeIndexesAndForeignKeys" type="checkbox"
/></label>
<Label>{{ t("dataDictionary.paper") }}</Label>
<select v-model="layout.paper" class="rounded border bg-transparent px-2 py-1 text-sm">
<option value="A4">A4</option>
<option value="A3">A3</option>
<option value="Letter">Letter</option>
<option value="Legal">Legal</option>
</select>
<Label>{{ t("dataDictionary.orientation") }}</Label>
<select v-model="layout.orientation" class="rounded border bg-transparent px-2 py-1 text-sm">
<option value="portrait">{{ t("dataDictionary.portrait") }}</option>
<option value="landscape">{{ t("dataDictionary.landscape") }}</option>
</select>
<Label>{{ t("dataDictionary.margin") }}</Label>
<Input v-model.number="layout.marginCm" type="number" min="0.5" max="4" step="0.1" />
<Label>{{ t("dataDictionary.fontSize") }}</Label>
<select class="rounded border bg-transparent px-2 py-1 text-sm" @change="setFontSize(($event.target as HTMLSelectElement).value as 'small' | 'medium' | 'large')">
<option value="medium">{{ t("dataDictionary.fontMedium") }}</option>
<option value="small">{{ t("dataDictionary.fontSmall") }}</option>
<option value="large">{{ t("dataDictionary.fontLarge") }}</option>
</select>
<Button type="button" variant="outline" @click="refreshPreview()">{{ t("dataDictionary.preview") }}</Button>
</div>
<iframe v-if="previewUrl" :src="previewUrl" class="h-full min-h-80 w-full rounded border" :title="t('dataDictionary.preview')" />
<p v-else class="text-sm text-muted-foreground">{{ t("dataDictionary.preview") }}</p>
</div>
<div v-else class="flex max-w-xl flex-col gap-3">
<Label>{{ t("dataDictionary.exportToFile") }}</Label>
<div class="flex gap-2">
<Input v-model="outputPath" :placeholder="dataDictionaryFileName(checkedDatabases[0] || 'DataDictionary', appendTimestamp)" />
<Button v-if="isTauriRuntime()" type="button" variant="outline" @click="browse()">{{ t("dataDictionary.browse") }}</Button>
</div>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.appendTimestamp") }}</span
><input v-model="appendTimestamp" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.overwriteExisting") }}</span
><input v-model="overwrite" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.continueOnError") }}</span
><input v-model="continueOnError" type="checkbox"
/></label>
<p v-if="exporting" class="text-sm text-muted-foreground">{{ t("dataDictionary.exporting") }}</p>
<p v-if="exportError" class="text-sm text-destructive">{{ exportError }}</p>
</div>
</div>
<DialogFooter class="border-t px-4 py-3">
<Button v-if="step > 0" type="button" variant="outline" @click="step -= 1">{{ t("dataDictionary.back") }}</Button>
<Button v-if="step < 4" type="button" :disabled="!canNext" @click="goNext()">{{ t("dataDictionary.next") }}</Button>
<Button v-else type="button" :disabled="exporting || order.length === 0" @click="exportDictionary()">{{ exporting ? t("dataDictionary.exporting") : t("dataDictionary.start") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Download, Network } from "@lucide/vue";
import { Download, FileText, Network } from "@lucide/vue";
import DataDictionaryDialog from "@/components/docs/DataDictionaryDialog.vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DocsApp from "@/docs/DocsApp.vue";
@@ -77,6 +78,7 @@ const canOpenDiagram = computed(() => (props.prefillConnectionId ?? "") !== "" &
const exporting = ref(false);
const exportError = ref<string | null>(null);
const showDataDictionary = ref(false);
async function load(): Promise<void> {
const connectionId = props.prefillConnectionId;
@@ -208,7 +210,7 @@ watch(
<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">
<DialogHeader class="px-4 py-3 border-b pr-12">
<DialogTitle class="flex items-center gap-2">
<DialogTitle class="flex flex-wrap items-center gap-2">
<span>{{ t("docs.title") }}</span>
<span v-if="statusLabel" class="text-xs font-normal" :class="status.state === 'failed' ? 'text-destructive' : 'text-muted-foreground'">
{{ statusLabel }}
@@ -218,6 +220,10 @@ watch(
<Network class="w-4 h-4" />
{{ t("docs.openDiagram") }}
</Button>
<Button v-if="snapshot" variant="outline" size="sm" @click="showDataDictionary = true">
<FileText class="w-4 h-4" />
{{ t("dataDictionary.title") }}
</Button>
<Button v-if="snapshot" variant="outline" size="sm" :disabled="exporting" @click="exportHtml()">
<Download class="w-4 h-4" />
{{ exporting ? t("docs.exporting") : t("docs.exportHtml") }}
@@ -232,4 +238,5 @@ watch(
</div>
</DialogContent>
</Dialog>
<DataDictionaryDialog v-if="showDataDictionary && snapshot" v-model:open="showDataDictionary" :prefill-connection-id="prefillConnectionId" :prefill-database="prefillDatabase" :prefill-schema="prefillSchema" :snapshot="snapshot" />
</template>
@@ -14,6 +14,7 @@ import DataGrid from "@/components/grid/DataGrid.vue";
import DataGridColumnLayoutPopover from "@/components/grid/DataGridColumnLayoutPopover.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
import DataGridColumnWidthModeControl from "@/components/grid/DataGridColumnWidthModeControl.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { Switch } from "@/components/ui/switch";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
@@ -2833,6 +2834,7 @@ defineExpose({ focusSearch });
</button>
</div>
</div>
<DataGridColumnWidthModeControl />
<DataGridFontFamilyControl />
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
@@ -1113,6 +1113,7 @@ describe("DocumentBrowser MongoDB filter value types", () => {
buttonWithTitle("grid.viewOptions").click();
await flushUi();
expect(document.body.textContent).toContain("grid.renderMode");
expect(document.body.textContent).toContain("grid.columnWidthMode");
expect(document.body.textContent).toContain("grid.tableFontFamily");
expect(document.body.textContent).toContain("grid.tableFontSize");
expect(document.body.textContent).toContain("grid.transposeMultiRowToggle");
@@ -1121,6 +1122,9 @@ describe("DocumentBrowser MongoDB filter value types", () => {
buttonWithText("grid.columnWidthCompact").click();
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ columnWidthDensity: "compact" });
buttonWithText("grid.columnWidthModeContent").click();
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ dataGridColumnWidthMode: "content" });
buttonWithText("grid.domRenderMode").click();
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ dataGridRenderMode: "dom" });
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, h, nextTick, onMounted, onUnmounted, reactive, ref, toRaw, watch, type Component } from "vue";
import { uuid } from "@/lib/common/utils";
import { deferUntilPanelResizeEnd } from "@/lib/app/panelResizeState";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import {
@@ -66,8 +67,8 @@ import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { useUserSkillStore } from "@/stores/userSkillStore";
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { supportsAiAssistantContext } from "@/lib/database/databaseFeatureSupport";
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
import { useQueryStore } from "@/stores/queryStore";
@@ -89,6 +90,7 @@ import {
type AiAction,
type AiActionSelection,
type AiAssistantMode,
type AiContext,
type AiContextTarget,
type AiCsvFileContext,
type AiTextAttachmentEncoding,
@@ -154,11 +156,11 @@ import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { buildAiAgentStepItems, formatToolDurationMs, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
import { buildAiAgentStepItems, formatAgentToolName, formatToolDurationMs, toolCallStepKey, updateAgentStepApproval, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
import { aiStream, aiCancelStream, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
import { aiStream, aiCancelStream, resolveAiToolApproval, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
import type { AiMessage } from "@/lib/backend/api";
import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
@@ -177,15 +179,17 @@ import { DBX_TABLE_REFERENCE_DROP_EVENT, clearActiveTableReferencePayload } from
import { canSubmitAiPrompt, isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/ai/aiPromptKeyboard";
import { isActionableWriteProposalMessage, isActionableWriteSqlProposal, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative } from "@/lib/ai/aiProposalDetect";
import { classifyIntentByLlm, routeIntentByRules, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";
import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
import { retryableUserMessageIndex, visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation";
import { saveTextFile } from "@/lib/export/saveTextFile";
import { buildAiAnalysisExport } from "@/lib/export/aiAnalysisExport";
import { buildAiConversationExport, type AiConversationExportFormat } from "@/lib/export/aiConversationExport";
import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch";
import AiAttachmentCard from "@/components/editor/AiAttachmentCard.vue";
import AiToolApprovalCard from "@/components/editor/AiToolApprovalCard.vue";
import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy";
import { buildPluginAiRequest, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
import { buildPluginAiRequest, createPluginAiConversation, pluginComposerConnectionLabel, pluginContextConnectionId, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
const { t } = useI18n();
const AiChartRenderer = defineAsyncComponent({
@@ -289,6 +293,7 @@ const props = defineProps<{
tab?: QueryTab;
connection?: ConnectionConfig;
maximized?: boolean;
pluginRecommendations?: PluginAiRecommendationHostUpdate;
}>();
// Every AI-initiated action carries the *target* it must run against: the
@@ -327,19 +332,6 @@ let defaultModeInitialized = false;
let initialConversationStateLoaded = false;
let initialConversationRestored = false;
let assistantViewMounted = false;
watch(
() => settings.isAiConfigLoaded,
(loaded) => {
if (loaded && !defaultModeInitialized) {
assistantMode.value = settings.defaultAiMode;
// Same apply-once rule as the mode: later setting changes must not
// disturb an active conversation.
activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode);
defaultModeInitialized = true;
}
},
{ immediate: true },
);
const currentSessionId = ref("");
const conversationId = ref("");
const conversations = ref<AiConversation[]>([]);
@@ -367,6 +359,46 @@ const boundConnectionId = computed(() => conversationBinding.value.connectionId)
const boundConnection = computed(() => (boundConnectionId.value ? connectionStore.getConfig(boundConnectionId.value) : undefined));
const boundDatabase = computed(() => conversationBinding.value.database);
const boundSchema = computed(() => conversationBinding.value.schema);
const pluginContextConnection = computed(() => {
const contextConnectionId = pluginContext.value ? pluginContextConnectionId(pluginContext.value) : undefined;
return boundConnection.value ?? (contextConnectionId ? connectionStore.getConfig(contextConnectionId) : undefined);
});
const pluginComposerConnectionName = computed(() => {
const context = pluginContext.value;
if (!context) return "";
return pluginComposerConnectionLabel(context, pluginContextConnection.value?.name, activeConversation.value?.connectionName);
});
function canUsePluginAgentMode(context = pluginContext.value, connection = boundConnection.value): boolean {
return !context || !!connection;
}
watch(
[pluginContext, boundConnection],
([context, connection]) => {
if (context && !connection && assistantMode.value === "agent") assistantMode.value = "ask";
},
{ immediate: true },
);
// `immediate` runs this during setup whenever the AI config finished loading
// before the panel mounted (the usual case: the app loads it at startup), and
// `resolveDefaultActionSelection` reads `boundConnection`. It must therefore
// stay below the binding computeds, or setup throws a TDZ ReferenceError and
// the panel never mounts.
watch(
() => settings.isAiConfigLoaded,
(loaded) => {
if (loaded && !defaultModeInitialized) {
assistantMode.value = settings.defaultAiMode;
// Same apply-once rule as the mode: later setting changes must not
// disturb an active conversation.
activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode);
defaultModeInitialized = true;
}
},
{ immediate: true },
);
/**
* Binding the visible conversation's *active run* is executing against.
@@ -1193,10 +1225,15 @@ const AI_TEXTAREA_HEIGHT_STORAGE_KEY = "dbx-ai-textarea-height";
const textareaHeight = ref<number>(AI_TEXTAREA_MIN_HEIGHT_PX);
const assistantRootRef = ref<HTMLElement | null>(null);
const promptPanelRef = ref<HTMLElement | null>(null);
const compactContextControls = ref(false);
const compactActionControls = ref(false);
const isResizing = ref<boolean>(false);
let resizeStartY = 0;
let resizeStartHeight = 0;
let promptPanelResizeObserver: ResizeObserver | undefined;
let responsiveControlMeasureFrame: number | null = null;
let responsiveControlMeasureForce = false;
let lastResponsiveControlWidth: number | null = null;
interface AiTableMentionCandidate {
kind: "table";
@@ -1650,6 +1687,7 @@ function openCodeSnapshot(seg: { content: string; lang: string }) {
}
const showActionButtons = computed(() => {
if (pluginContext.value) return false;
if (!boundConnection.value) return true;
return !isVectorDbType(boundConnection.value.db_type);
});
@@ -1664,6 +1702,7 @@ const modeActionTriggerLabel = computed(() => {
});
function switchModeActionTab(mode: "ask" | "agent") {
if (mode === "agent" && !canUsePluginAgentMode()) return;
activeAction.value = resolveDefaultActionSelection(mode);
if (assistantMode.value !== mode) {
// Set the mode after the action so the tab label and picker stay aligned.
@@ -1774,9 +1813,11 @@ watch(
);
watch([dbSelectOptions, selectedNamespace], syncSelectedDatabases, { immediate: true });
const showAiDatabaseSelector = computed(() => !!boundConnection.value && supportsAiAssistantContext(boundConnection.value.db_type));
const showAiSchemaSelector = computed(() => {
const connection = boundConnection.value;
return !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection);
return showAiDatabaseSelector.value && !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection);
});
const aiSchemaDatabaseKey = computed(() => {
@@ -1798,7 +1839,7 @@ async function loadAiSchemas() {
}
async function loadDatabases(connection = boundConnection.value): Promise<string[]> {
if (!connection) return [];
if (!connection || !supportsAiAssistantContext(connection.db_type)) return [];
if (connection.db_type !== "dameng") {
await loadDatabaseOptions(connection.id);
return databaseOptions.value[connection.id] || [];
@@ -2097,6 +2138,21 @@ function parseExplainFromData(explainData: unknown, dbType: string): ParsedExpla
}
}
/** Sends the user's answer for a pending plugin tool approval back to the run that asked. */
async function resolveToolApproval(step: AiAgentStepItem, approved: boolean) {
const approval = step.approval;
if (!approval || approval.status !== "pending") return;
approval.status = "submitting";
try {
// The final status arrives with `tool_approval_resolved`; `false` means
// the run stopped waiting (timed out, cancelled, or app restarted).
if (!(await resolveAiToolApproval(approval.sessionId, approval.approvalId, approved))) approval.status = "expired";
} catch (error) {
approval.status = "pending";
toast(t("ai.toolApproval.answerFailed", { message: error instanceof Error ? error.message : String(error) }), 5000);
}
}
function agentEventToStep(event: AgentEvent, index: number, now: number): AiAgentStepItem | undefined {
if (event.type === "context_compacted") {
return {
@@ -2337,7 +2393,7 @@ function mentionTargetDatabase(): string {
async function loadMentionCandidates(query: string) {
const connection = boundConnection.value;
const mentionDatabase = mentionTargetDatabase();
if (pluginContext.value || !connection || !boundConnectionId.value || !mentionDatabase) return;
if (pluginContext.value || !connection || !supportsAiAssistantContext(connection.db_type) || !boundConnectionId.value || !mentionDatabase) return;
const key = mentionCacheKey(boundConnectionId.value, mentionDatabase, query);
if (mentionCache.value[key]) {
@@ -3215,9 +3271,9 @@ async function send() {
// database. When it still matches, dropping it would silently discard what
// the user attached alongside the affirmative.
const confirmationRetargets = !!confirmationTarget && !sameConversationBinding(runBinding, conversationBinding.value);
const connection = runPluginContext ? undefined : runBinding.connectionId ? connectionStore.getConfig(runBinding.connectionId) : undefined;
const connection = runBinding.connectionId ? connectionStore.getConfig(runBinding.connectionId) : undefined;
const tab = runPluginContext ? undefined : aiContextTargetFor(runBinding, props.tab);
const runSourceName = runPluginContext?.pluginName ?? connection?.name ?? "";
const runSourceName = connection?.name ?? runPluginContext?.pluginName ?? "";
if (!runPluginContext && (!connection || !tab)) {
clearPendingWriteGrant();
return;
@@ -3437,7 +3493,7 @@ async function send() {
if (autoSendVisible) scrollToBottom({ force: true });
const requestedSelection: AiActionSelection = auto ? auto.action : activeAction.value;
const requestedMode: AiAssistantMode = runPluginContext ? "ask" : auto ? auto.mode : assistantMode.value;
const requestedMode: AiAssistantMode = auto ? auto.mode : assistantMode.value;
// A confirmed-write turn (the ✅ reply, or the segment that resumes an
// `awaiting_write_confirmation` run) is a continuation of the pending proposal,
// not a new user request: its reply text is component copy, so the Auto router
@@ -3452,8 +3508,8 @@ async function send() {
// router entirely, so their behavior is unchanged (#9118).
let requestedAction: AiAction;
if (runPluginContext) {
// A plugin conversation carries its own data snapshot: the Auto router must
// not classify the prompt, and the task contract stays host-owned.
// Plugin requests use the general action; the Agent decides which live
// plugin tools are needed from their advertised definitions.
requestedAction = "general";
} else if (!isAutoActionSelection(requestedSelection)) {
requestedAction = requestedSelection;
@@ -3687,9 +3743,60 @@ async function send() {
if (step) upsertAgentStep(msg.agentSteps, step);
}
}
// A plugin tool call waits for (or got) the user's approval: keep the
// state on the tool card so the answer buttons live next to the call.
if (event.type === "tool_approval_required" || event.type === "tool_approval_resolved") {
const msg = runMessages[assistantIdx];
if (msg?.agentSteps) {
const key = toolCallStepKey(event.tool_call_id, agentEvents.length - 1, "tool_call_start");
if (event.type === "tool_approval_required") {
updateAgentStepApproval(msg.agentSteps, key, () => ({
approvalId: event.approval_id,
sessionId,
pluginName: event.plugin_name,
pluginTool: event.plugin_tool,
connectionName: event.connection_name,
args: event.args ?? {},
expiresAtMs: Date.now() + event.timeout_secs * 1000,
status: "pending",
}));
} else {
updateAgentStepApproval(msg.agentSteps, key, (approval) => (approval && approval.approvalId === event.approval_id ? { ...approval, status: event.outcome } : approval));
}
}
}
if (runIsVisible()) scrollToBottom();
};
if (runPluginContext) {
if (runPluginContext && requestedMode === "agent" && connection) {
if (!generationCanContinue()) return;
if (runIsVisible()) generationStatus.value = { ...generationStatus.value, phase: "waiting_model" };
const context: AiContext = {
connectionId: connection.id,
connectionName: connection.name,
databaseType: "plugin",
database: "",
currentSql: "",
tables: [],
sqlFiles: [],
truncated: false,
};
const instruction = [text, `Plugin: ${runPluginContext.pluginName}`, `Recommendation context: ${runPluginContext.title}`, "Use the connected plugin tools to retrieve the current state before answering."].filter(Boolean).join("\n\n");
await runAgentStream(
{
config: activeConfig,
action: requestedAction,
mode: requestedMode,
instruction,
taskContractUserRequest: text,
context,
inlineImages: imageAttachments.map(({ mediaType, data }) => ({ mediaType, data })),
},
history,
onEvent,
sessionId,
customPromptContext,
);
} else if (runPluginContext) {
if (!generationCanContinue()) return;
if (runIsVisible()) generationStatus.value = { ...generationStatus.value, phase: "waiting_model" };
const request = buildPluginAiRequest(
@@ -4233,6 +4340,37 @@ function canCopyMessage(msg: ChatMessage): boolean {
return messageCopyText(msg) !== null;
}
/** Returns whether a user message can be submitted again. The helper accepts
* the following visible index so the same eligibility rules cover text,
* mentions, and attachment-only requests. */
function canRetryUserMessage(visibleIndex: number): boolean {
return retryableUserMessageIndex(visibleMessages.value, visibleIndex + 1) === visibleIndex;
}
/** Re-runs a user request through the existing edit-and-resend path. This
* removes the selected turn and later replies before sending with the current
* AI configuration. */
function retryUserMessage(visibleIndex: number) {
if (isGenerating.value) return;
if (!canRetryUserMessage(visibleIndex)) return;
startEditMessage(visibleIndex);
submitEdit(visibleIndex);
}
const retryConfirmMessageIndex = ref<number | null>(null);
function cancelRetryUserMessage() {
retryConfirmMessageIndex.value = null;
}
function confirmRetryUserMessage() {
const visibleIndex = retryConfirmMessageIndex.value;
retryConfirmMessageIndex.value = null;
if (visibleIndex !== null) retryUserMessage(visibleIndex);
}
watch(conversationId, cancelRetryUserMessage);
function messageCopyKey(index: number): string {
return `message:${index}`;
}
@@ -4642,7 +4780,7 @@ function selectConversation(conv: AiConversation) {
const activeRun = backgroundAiRunsEnabled ? desktopAiRun<ChatMessage>(conv.id) : undefined;
messages.value = activeRun?.messages ?? chatMessagesFromConversation(conv);
if (pluginContext.value) {
assistantMode.value = "ask";
assistantMode.value = pluginContext.value.mode === "agent" && !boundConnection.value ? "ask" : (pluginContext.value.mode ?? "ask");
activeAction.value = "general";
}
unreadConversations.delete(conv.id);
@@ -4991,7 +5129,9 @@ onMounted(async () => {
if (typeof ResizeObserver !== "undefined" && assistantRootRef.value) {
promptPanelResizeObserver = new ResizeObserver(handlePanelResize);
promptPanelResizeObserver.observe(assistantRootRef.value);
if (promptPanelRef.value) promptPanelResizeObserver.observe(promptPanelRef.value);
}
scheduleResponsiveControlMeasurement(true);
});
function maxTextareaHeight() {
@@ -5008,8 +5148,91 @@ function clampTextareaHeight(height: number) {
function handlePanelResize() {
textareaHeight.value = clampTextareaHeight(textareaHeight.value);
scheduleResponsiveControlMeasurement();
}
function hasHorizontalOverflow(element: HTMLElement | null): boolean {
return !!element && element.scrollWidth > element.clientWidth + 1;
}
function hasOverflowingLabel(element: HTMLElement | null, selector: string): boolean {
if (!element) return false;
return Array.from(element.querySelectorAll<HTMLElement>(selector)).some((label) => label.scrollWidth > label.clientWidth + 1);
}
async function measureResponsiveControls(force = false) {
const panel = promptPanelRef.value;
if (!panel) return;
// Reading clientWidth/scrollWidth here forces a document-wide synchronous
// relayout, and the AI panel divider drag fires resize events every frame.
// Skip measurements while the drag is in flight and re-measure once at the end.
if (
deferUntilPanelResizeEnd(() => {
void measureResponsiveControls(force);
})
)
return;
const panelWidth = panel.clientWidth;
if (!force && lastResponsiveControlWidth === panelWidth) return;
// Measure the full labels before deciding to compact. This lets a wide
// composer recover from icon mode after it grows, while the actual
// overflow checks decide independently for the context and action rows.
if (compactContextControls.value || compactActionControls.value) {
compactContextControls.value = false;
compactActionControls.value = false;
await nextTick();
}
const contextRow = panel.querySelector<HTMLElement>("[data-ai-composer-context-row]");
const actionRow = panel.querySelector<HTMLElement>("[data-ai-composer-actions]");
const contextOverflow = hasHorizontalOverflow(contextRow) || hasOverflowingLabel(contextRow, ".ai-template-selector-label, .ai-skills-selector-label");
const actionOverflow = hasHorizontalOverflow(actionRow) || hasOverflowingLabel(actionRow, ".ai-mode-action-label, .ai-model-selector-label, .ai-prompt-queue-label");
compactContextControls.value = contextOverflow;
compactActionControls.value = actionOverflow;
lastResponsiveControlWidth = panelWidth;
}
function scheduleResponsiveControlMeasurement(force = false) {
responsiveControlMeasureForce ||= force;
if (responsiveControlMeasureFrame !== null) return;
const measure = () => {
responsiveControlMeasureFrame = null;
const forceMeasure = responsiveControlMeasureForce;
responsiveControlMeasureForce = false;
void measureResponsiveControls(forceMeasure);
};
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
responsiveControlMeasureFrame = window.requestAnimationFrame(measure);
} else {
void nextTick(measure);
}
}
watch(
() => [
templateSelectorTriggerLabel.value,
selectedSkillIds.value.join(","),
modeActionTriggerLabel.value,
activeFullConfig.value?.model,
boundConnectionId.value,
selectedDatabaseLabel.value,
boundSchema.value,
connectionStore.connections.length,
showAiDatabaseSelector.value,
showAiSchemaSelector.value,
settings.aiConfigs.length,
hasActiveRunForCurrentConversation.value,
isGenerating.value,
pluginContext.value?.pluginName,
pluginContext.value?.title,
currentQueuedInput.value?.text,
],
() => scheduleResponsiveControlMeasurement(true),
{ flush: "post" },
);
function startResize(event: MouseEvent) {
event.preventDefault();
isResizing.value = true;
@@ -5070,6 +5293,11 @@ onUnmounted(() => {
document.removeEventListener("dbx:tauri-file-drop", onTauriFileDrop as EventListener);
window.removeEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
promptPanelResizeObserver?.disconnect();
if (responsiveControlMeasureFrame !== null && typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
window.cancelAnimationFrame(responsiveControlMeasureFrame);
responsiveControlMeasureFrame = null;
}
responsiveControlMeasureForce = false;
});
function triggerAction(action: AiAction, instruction?: string) {
@@ -5093,12 +5321,21 @@ function openPluginConversation(request: AiPluginConversationRequest) {
defaultModeInitialized = true;
startNewChat();
draftPluginContext.value = request.context;
assistantMode.value = "ask";
const pluginConnectionId = pluginContextConnectionId(request.context);
const pluginConnection = pluginConnectionId ? connectionStore.getConfig(pluginConnectionId) : undefined;
draftBinding.value = pluginConnection ? { connectionId: pluginConnection.id, connectionName: pluginConnection.name, database: "", schema: undefined } : null;
assistantMode.value = request.mode === "agent" && !pluginConnection ? "ask" : (request.mode ?? request.context.mode ?? "ask");
activeAction.value = "general";
setPrompt(request.prompt, true);
if (request.send) void send();
}
function sendPluginRecommendation(item: { id: string; label: string; prompt: string }) {
const update = props.pluginRecommendations;
if (!update || !update.items.some((candidate) => candidate.id === item.id)) return;
openPluginConversation(createPluginAiConversation({ id: update.pluginId, name: update.pluginName }, { title: item.label, prompt: item.prompt, context: update.context, send: true, mode: "agent" }));
}
function setPrompt(text: string, fromPlugin = false) {
if (!fromPlugin && pluginContext.value) startNewChat();
prompt.value = text;
@@ -5323,10 +5560,34 @@ async function openExternalUrl(url: string) {
<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(pluginContext ? "ai.pluginWelcome" : "ai.welcome") }}</p>
<div v-if="pluginRecommendations?.items.length" class="mt-5 flex max-w-[95%] flex-wrap justify-center gap-2">
<button
v-for="recommendation in pluginRecommendations.items"
:key="recommendation.id"
type="button"
class="rounded-full border border-border/80 bg-background px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
:title="recommendation.prompt"
@click="sendPluginRecommendation(recommendation)"
>
{{ recommendation.label }}
</button>
</div>
</div>
<div v-else class="relative min-h-0 flex-1">
<ScrollArea ref="scrollRef" class="ai-message-scroll h-full overflow-hidden">
<div v-else class="relative flex min-h-0 flex-1 flex-col">
<ScrollArea ref="scrollRef" class="ai-message-scroll min-h-0 flex-1 overflow-hidden">
<div class="flex flex-col gap-3 p-3">
<div v-if="pluginRecommendations?.items.length" class="flex shrink-0 flex-wrap justify-center gap-2 border-b border-border/60 px-3 py-2" data-ai-plugin-recommendations>
<button
v-for="recommendation in pluginRecommendations.items"
:key="`history:${recommendation.id}`"
type="button"
class="rounded-full border border-border/80 bg-background px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
:title="recommendation.prompt"
@click="sendPluginRecommendation(recommendation)"
>
{{ recommendation.label }}
</button>
</div>
<template v-for="(msg, i) in visibleMessages" :key="i">
<div v-if="awayUpdatesBaselineIndex >= 0 && i === awayUpdatesBaselineIndex" class="mb-1 flex items-center gap-2 py-0.5" role="separator" :aria-label="t('ai.awayUpdatesDivider')">
<span class="h-px flex-1 bg-primary/25" />
@@ -5478,8 +5739,26 @@ async function openExternalUrl(url: string) {
<span class="truncate">{{ t("ai.routing.chip", { action: actionLabelFor(routedActionOf(msg)) }) }}</span>
</button>
</div>
<div v-if="canCopyMessage(msg)" class="mt-1 flex justify-end">
<div v-if="canCopyMessage(msg) || (!isGenerating && canRetryUserMessage(i))" class="mt-1 flex justify-end gap-1">
<Popover v-if="!isGenerating && canRetryUserMessage(i)" :open="retryConfirmMessageIndex === i" @update:open="(open: boolean) => (retryConfirmMessageIndex = open ? i : null)">
<PopoverTrigger as-child>
<button data-ai-message-retry="user" type="button" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.retryMessage')" :aria-label="t('ai.retryMessage')">
<RefreshCw class="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent side="top" align="end" :side-offset="8" class="w-72 max-w-[calc(100vw-2rem)] gap-2 p-3" @click.stop>
<p class="flex items-start gap-2 text-xs text-foreground">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span>{{ t("ai.retryConfirmDescription") }}</span>
</p>
<div class="flex justify-end gap-2">
<Button type="button" size="sm" variant="outline" class="h-7 px-2 text-xs" @click="cancelRetryUserMessage">{{ t("common.cancel") }}</Button>
<Button type="button" size="sm" variant="destructive" class="h-7 px-2 text-xs" @click="confirmRetryUserMessage">{{ t("common.confirm") }}</Button>
</div>
</PopoverContent>
</Popover>
<button
v-if="canCopyMessage(msg)"
data-ai-message-copy="user"
type="button"
class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
@@ -5524,16 +5803,17 @@ async function openExternalUrl(url: string) {
<Loader2 v-if="step.tone === 'active' && step.toolName" class="h-3 w-3 shrink-0 animate-spin" />
<component :is="agentStepIcon(step.tone)" v-else class="h-3 w-3 shrink-0" />
<span class="font-medium">{{ t(step.labelKey) }}</span>
<span v-if="step.toolName" class="text-muted-foreground">: {{ step.toolName }}</span>
<span v-if="step.toolName" class="text-muted-foreground">: {{ formatAgentToolName(step.toolName) }}</span>
<template v-if="step.tone === 'active' && step.toolName">
<span class="ml-auto flex shrink-0 items-center gap-1">
<Loader2 class="h-3 w-3 animate-spin" />
<span>{{ t("ai.agentSteps.executing") }}</span>
<span>{{ step.approval?.status === "pending" || step.approval?.status === "submitting" ? t("ai.toolApproval.waiting") : t("ai.agentSteps.executing") }}</span>
</span>
</template>
<span v-else-if="step.durationMs !== undefined" class="ml-auto shrink-0 tabular-nums" :class="step.tone === 'danger' ? 'text-red-600 dark:text-red-400' : 'text-chart-2'">{{ formatToolDurationMs(step.durationMs) }}</span>
<ChevronRight v-if="step.toolResult || step.toolArgs?.sql" class="h-3 w-3 shrink-0 transition-transform duration-150" :class="[{ 'rotate-90': expandedSteps.has(step.key) }, !agentStepHasTail(step) ? 'ml-auto' : '']" />
</button>
<AiToolApprovalCard v-if="step.approval" :approval="step.approval" @resolve="resolveToolApproval(step, $event)" />
<div v-if="expandedSteps.has(step.key)" class="border-t border-current/10 px-2 pb-2 pt-1">
<div v-if="step.toolArgs?.sql" class="mb-1 rounded bg-background/50 px-2 py-1 font-mono text-[10px] text-foreground/80 whitespace-pre-wrap">{{ step.toolArgs.sql }}</div>
<Button v-if="step.toolName === 'explain_query' && step.toolArgs?.sql" size="sm" variant="outline" class="mb-1 h-6 gap-1 text-[10px]" @click="emit('openExplainPlan', step.toolArgs.sql as string, conversationBinding)">
@@ -5713,13 +5993,14 @@ async function openExternalUrl(url: string) {
</div>
<div class="resize-handle" @mousedown="startResize"></div>
<div class="px-2 pb-2 pt-1">
<div data-ai-composer-context-row :class="['ai-prompt-context-row mb-1 flex items-center gap-x-1 text-xs text-foreground/80', showAiSchemaSelector && 'ai-prompt-context-row--schema']">
<details v-if="pluginContext" class="min-w-0 flex-1" data-ai-plugin-context>
<summary class="cursor-pointer truncate">{{ pluginContext.pluginName }} · {{ pluginContext.title }}</summary>
<pre class="max-h-56 overflow-auto whitespace-pre-wrap break-all p-2 text-[11px]">{{ pluginContextText(pluginContext) }}</pre>
</details>
<template v-else-if="connectionStore.connections.length">
<DatabaseIcon v-if="boundConnection" :db-type="connectionIconType(boundConnection)" class="h-3 w-3 shrink-0" />
<div data-ai-composer-context-row :class="['ai-prompt-context-row mb-1 flex min-w-0 items-center gap-x-1 text-xs text-foreground/80', showAiSchemaSelector && 'ai-prompt-context-row--schema', compactContextControls && 'ai-prompt-context-row--compact']">
<div v-if="pluginContext && !connectionStore.connections.length" class="flex min-w-0 max-w-[14rem] items-center gap-1" data-ai-plugin-context :title="pluginContext.title">
<ConnectionIcon v-if="pluginContextConnection" :connection="pluginContextConnection" class="h-3 w-3 shrink-0" />
<Server v-else class="h-3 w-3 shrink-0" />
<span class="truncate">{{ pluginComposerConnectionName }}</span>
</div>
<template v-if="connectionStore.connections.length">
<ConnectionIcon v-if="boundConnection" :connection="boundConnection" class="h-3 w-3 shrink-0" />
<Server v-else class="h-3 w-3 shrink-0" />
<ConnectionTreeSelect
:model-value="boundConnectionId"
@@ -5728,12 +6009,12 @@ async function openExternalUrl(url: string) {
:placeholder="t('editor.selectConnection')"
:search-placeholder="t('editor.searchConnection')"
:empty-text="t('grid.noSearchResults')"
:trigger-class="['h-5 px-1 text-foreground/80', showAiSchemaSelector && 'min-w-0 max-w-56 flex-1']"
:trigger-class="['h-5 min-w-0 max-w-full px-1 text-foreground/80', showAiSchemaSelector && 'max-w-56 flex-1']"
trigger-icon-class="h-3 w-3"
list-class="w-72 max-w-[calc(100vw-2rem)]"
@update:model-value="(v) => changeConnection(v)"
/>
<template v-if="boundConnection">
<template v-if="boundConnection && showAiDatabaseSelector">
<Database class="h-3 w-3 shrink-0 text-foreground/40" />
<Popover
@update:open="
@@ -5743,7 +6024,7 @@ async function openExternalUrl(url: string) {
"
>
<PopoverTrigger as-child>
<Button variant="ghost" :title="selectedDatabaseLabel" :class="['h-5 max-w-64 justify-start border-0 p-0 px-1 text-xs font-normal text-foreground/80 shadow-none', showAiSchemaSelector && 'min-w-0 flex-1']">
<Button variant="ghost" :title="selectedDatabaseLabel" :class="['h-5 min-w-0 max-w-64 justify-start border-0 p-0 px-1 text-xs font-normal text-foreground/80 shadow-none', showAiSchemaSelector && 'flex-1']">
<span class="truncate">{{ selectedDatabaseLabel }}</span>
</Button>
</PopoverTrigger>
@@ -5836,10 +6117,10 @@ async function openExternalUrl(url: string) {
<!-- Skill selector (read-only user SKILL.md library) -->
<Popover v-model:open="showSkillSelector">
<PopoverTrigger as-child>
<button type="button" class="flex min-w-0 items-center gap-1 rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('ai.skillsEntry')" :title="t('ai.skillsEntry')">
<button type="button" class="ai-skills-selector-trigger flex min-w-0 items-center gap-1 rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('ai.skillsEntry')" :title="t('ai.skillsEntry')">
<Layers class="h-3 w-3" />
<span class="truncate">{{ t("ai.skillsEntry") }}</span>
<span v-if="selectedSkillIds.length" class="rounded-sm bg-primary px-1 text-[10px] font-medium text-primary-foreground">{{ selectedSkillIds.length }}</span>
<span class="ai-skills-selector-label truncate">{{ t("ai.skillsEntry") }}</span>
<span v-if="selectedSkillIds.length" class="ai-skills-selector-count rounded-sm bg-primary px-1 text-[10px] font-medium text-primary-foreground">{{ selectedSkillIds.length }}</span>
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 gap-0 p-1.5">
@@ -6066,7 +6347,7 @@ async function openExternalUrl(url: string) {
<Clock class="h-3.5 w-3.5 shrink-0" />
<span>{{ t("ai.status.longRunningHint") }}</span>
</div>
<div class="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
<div data-ai-composer-actions :class="['ai-prompt-action-row flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden', compactActionControls && 'ai-prompt-action-row--compact']">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="isGenerating" @click="selectCsvFile">
@@ -6080,13 +6361,12 @@ async function openExternalUrl(url: string) {
</TooltipContent>
</Tooltip>
<!-- Combined mode + action selector -->
<span v-if="pluginContext" class="shrink-0 text-xs text-muted-foreground">{{ t("ai.modes.ask") }}</span>
<Popover v-else v-model:open="modeActionOpen">
<Popover v-model:open="modeActionOpen">
<PopoverTrigger as-child>
<button type="button" class="flex shrink-0 items-center gap-1 whitespace-nowrap rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="modeActionTriggerLabel">
<button type="button" class="ai-mode-action-trigger flex shrink-0 items-center gap-1 whitespace-nowrap rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="modeActionTriggerLabel" :title="modeActionTriggerLabel">
<component :is="modeIcon" class="h-3 w-3" />
<span>{{ modeActionTriggerLabel }}</span>
<svg class="h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
<span class="ai-mode-action-label">{{ modeActionTriggerLabel }}</span>
<svg class="ai-mode-action-chevron h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-56 gap-0 p-1.5" @click.stop>
@@ -6124,12 +6404,17 @@ async function openExternalUrl(url: string) {
</template>
</PopoverContent>
</Popover>
<span class="min-w-0 flex-1" />
<span class="ai-prompt-action-spacer min-w-0 flex-1" />
<template v-if="settings.aiConfigs.length > 0">
<!-- Combined provider + model selector -->
<Popover v-model:open="providerSelectorOpen">
<PopoverTrigger as-child>
<button type="button" class="min-w-0 flex shrink items-center gap-1.5 max-w-[220px] rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground">
<button
type="button"
class="ai-model-selector-trigger min-w-0 flex shrink items-center gap-1.5 max-w-[220px] rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
:aria-label="activeFullConfig?.model || t('ai.selectModel')"
:title="activeFullConfig?.model || t('ai.selectModel')"
>
<AiProviderLogo
:provider="activeFullConfig?.provider ?? 'claude'"
:label="aiConfigProviderLabel(activeFullConfig)"
@@ -6137,8 +6422,8 @@ async function openExternalUrl(url: string) {
:icon-path="activeFullConfig ? getAiProviderPreset(activeFullConfig.provider, activeFullConfig.endpoint).iconPath : undefined"
class="h-3 w-3 shrink-0"
/>
<span class="min-w-0 truncate">{{ activeFullConfig?.model || t("ai.selectModel") }}</span>
<svg class="h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
<span class="ai-model-selector-label min-w-0 truncate">{{ activeFullConfig?.model || t("ai.selectModel") }}</span>
<svg class="ai-model-selector-chevron h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
</PopoverTrigger>
<PopoverContent align="end" class="max-h-(--reka-popover-content-available-height) w-80 gap-0 overflow-y-auto p-1.5" @open-auto-focus.prevent>
@@ -6326,14 +6611,21 @@ async function openExternalUrl(url: string) {
</PopoverContent>
</Popover>
</template>
<button v-if="isGenerating" class="h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center" :title="t('ai.stopGenerating')" @click="cancelStream">
<button v-if="isGenerating" class="ai-prompt-send-control h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center" :title="t('ai.stopGenerating')" @click="cancelStream">
<Square class="h-3.5 w-3.5" />
</button>
<button v-else-if="hasActiveRunForCurrentConversation" class="h-7 shrink-0 items-center gap-1 rounded-full bg-foreground px-2.5 text-[11px] font-medium text-background-solid disabled:opacity-30 flex" :disabled="!canSubmitPrompt" :title="t('ai.queueSendHint')" @click="onSendClick">
<button
v-else-if="hasActiveRunForCurrentConversation"
class="ai-prompt-send-control ai-prompt-queue-control h-7 shrink-0 items-center gap-1 rounded-full bg-foreground px-2.5 text-[11px] font-medium text-background-solid disabled:opacity-30 flex"
:disabled="!canSubmitPrompt"
:aria-label="t('ai.queueSend')"
:title="t('ai.queueSendHint')"
@click="onSendClick"
>
<Hourglass class="h-3.5 w-3.5" />
<span>{{ t("ai.queueSend") }}</span>
<span class="ai-prompt-queue-label">{{ t("ai.queueSend") }}</span>
</button>
<button v-else class="h-7 w-7 shrink-0 rounded-full bg-foreground text-background-solid flex items-center justify-center disabled:opacity-30" :disabled="!canSubmitPrompt" @click="send">
<button v-else class="ai-prompt-send-control h-7 w-7 shrink-0 rounded-full bg-foreground text-background-solid flex items-center justify-center disabled:opacity-30" :disabled="!canSubmitPrompt" @click="send">
<ArrowUp class="h-4 w-4" />
</button>
</div>
@@ -6383,28 +6675,66 @@ async function openExternalUrl(url: string) {
</template>
<style scoped>
.ai-prompt-context-container {
container-type: inline-size;
.ai-prompt-context-row--compact .ai-prompt-context-spacer {
flex: 0 0 0;
}
@container (max-width: 28rem) {
.ai-prompt-context-row--schema .ai-prompt-context-spacer {
flex: 0 0 0;
}
.ai-prompt-context-row--compact .ai-template-selector-trigger,
.ai-prompt-context-row--compact .ai-skills-selector-trigger {
flex: 0 0 1.5rem;
width: 1.5rem;
max-width: 1.5rem;
height: 1.5rem;
justify-content: center;
padding: 0;
}
.ai-prompt-context-row--schema .ai-template-selector-trigger {
flex: 0 0 1.5rem;
width: 1.5rem;
max-width: 1.5rem;
height: 1.5rem;
justify-content: center;
padding: 0;
}
.ai-prompt-context-row--compact .ai-template-selector-label,
.ai-prompt-context-row--compact .ai-template-selector-chevron,
.ai-prompt-context-row--compact .ai-skills-selector-label,
.ai-prompt-context-row--compact .ai-skills-selector-count {
display: none;
}
.ai-prompt-context-row--schema .ai-template-selector-label,
.ai-prompt-context-row--schema .ai-template-selector-chevron {
display: none;
}
.ai-prompt-action-row--compact {
gap: 0.25rem;
}
.ai-prompt-action-row--compact .ai-mode-action-trigger,
.ai-prompt-action-row--compact .ai-mode-static-trigger,
.ai-prompt-action-row--compact .ai-model-selector-trigger {
flex: 0 0 1.75rem;
width: 1.75rem;
max-width: 1.75rem;
height: 1.75rem;
justify-content: center;
padding: 0;
}
.ai-prompt-action-row--compact .ai-mode-action-label,
.ai-prompt-action-row--compact .ai-mode-action-chevron,
.ai-prompt-action-row--compact .ai-model-selector-label,
.ai-prompt-action-row--compact .ai-model-selector-chevron {
display: none;
}
.ai-prompt-action-row--compact .ai-model-selector-trigger {
min-width: 1.75rem;
}
.ai-prompt-action-row--compact .ai-prompt-send-control {
flex: 0 0 auto;
}
.ai-prompt-action-row--compact .ai-prompt-queue-control {
width: 1.75rem;
height: 1.75rem;
justify-content: center;
padding: 0;
}
.ai-prompt-action-row--compact .ai-prompt-queue-label {
display: none;
}
.ai-markdown :deep(h1) {
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Loader2, ShieldAlert, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import type { AiAgentStepApproval } from "@/lib/ai/aiAgentStepPresentation";
/** Inline approval for a plugin tool call that may change state. */
const props = defineProps<{ approval: AiAgentStepApproval }>();
const emit = defineEmits<{ resolve: [approved: boolean] }>();
const { t } = useI18n();
const expired = ref(false);
watch(
() => [props.approval.status, props.approval.expiresAtMs] as const,
([status, expiresAtMs], _previous, onCleanup) => {
expired.value = expiresAtMs <= Date.now();
if (expired.value || (status !== "pending" && status !== "submitting")) return;
const timeout = setTimeout(() => {
expired.value = true;
}, expiresAtMs - Date.now());
onCleanup(() => clearTimeout(timeout));
},
{ immediate: true },
);
const argumentsText = computed(() => JSON.stringify(props.approval.args ?? {}, null, 2));
// A pending card restored from history after its deadline (or an app restart)
// has nobody waiting for the answer anymore.
const waiting = computed(() => (props.approval.status === "pending" || props.approval.status === "submitting") && !expired.value);
const displayStatus = computed(() => (props.approval.status === "pending" || props.approval.status === "submitting" ? "expired" : props.approval.status));
const expiresAt = computed(() => new Date(props.approval.expiresAtMs).toLocaleTimeString());
</script>
<template>
<div class="space-y-1.5 border-t border-current/10 px-2 pb-2 pt-1.5 text-[10px]" data-ai-tool-approval>
<div class="flex items-start gap-1.5 text-foreground">
<ShieldAlert class="mt-px h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" />
<span class="min-w-0 wrap-anywhere">{{ t("ai.toolApproval.prompt", { plugin: approval.pluginName, tool: approval.pluginTool, connection: approval.connectionName }) }}</span>
</div>
<pre class="max-h-40 overflow-auto rounded bg-background/60 px-2 py-1 font-mono text-[10px] text-foreground/80 whitespace-pre-wrap wrap-anywhere">{{ argumentsText }}</pre>
<div v-if="waiting" class="flex flex-wrap items-center gap-1.5">
<Button size="sm" class="h-6 gap-1 px-2 text-[10px]" :disabled="approval.status === 'submitting'" @click="emit('resolve', true)">
<Loader2 v-if="approval.status === 'submitting'" class="h-3 w-3 animate-spin" />
<Check v-else class="h-3 w-3" />
{{ t("ai.toolApproval.approve") }}
</Button>
<Button size="sm" variant="outline" class="h-6 gap-1 px-2 text-[10px]" :disabled="approval.status === 'submitting'" @click="emit('resolve', false)">
<X class="h-3 w-3" />
{{ t("ai.toolApproval.deny") }}
</Button>
<span class="text-muted-foreground">{{ t("ai.toolApproval.expiresAt", { time: expiresAt }) }}</span>
</div>
<div v-else class="text-muted-foreground">{{ t(`ai.toolApproval.status.${displayStatus}`) }}</div>
</div>
</template>
@@ -151,7 +151,7 @@ async function copyPreview() {
<template>
<Dialog v-model:open="open">
<DialogContent class="max-h-[86vh] border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[620px]">
<DialogContent class="max-h-[86vh] border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[620px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<List class="h-5 w-5 text-primary" />
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { cleanupMcpHistoryRetention, loadMcpHistoryRetentionLimit, saveMcpHistoryRetentionLimit } from "@/lib/backend/api";
import { HISTORY_RETENTION_LIMITS, useHistoryRetentionSetting } from "@/composables/useHistoryRetentionSetting";
import { ref, watch, shallowRef, computed, onMounted, onUnmounted, nextTick } from "vue";
import type { Ref } from "vue";
@@ -107,6 +108,7 @@ import {
type ClickTableNavigationTarget,
type EditorSettings,
type SqlCompletionTriggerMode,
type SqlTableCompletionSchemaQualification,
type TableHoverLookupMode,
SIDEBAR_INDENT_MIN,
SIDEBAR_INDENT_MAX,
@@ -132,12 +134,16 @@ import {
aiTestConnection,
checkMcpServerStatus,
installMcpServer,
installNativeMcpServer,
uninstallMcpServer,
uninstallNpmMcpServer,
loadMcpHttpServerSettings,
saveMcpHttpServerSettings,
mcpHttpServerStatus,
rotateMcpHttpServerToken,
loadWebMcpHttpStatus,
saveWebMcpHttpSettings,
rotateWebMcpToken,
forgetSnippetSavedToken,
forgetWebdavSyncSecretsPassphrase,
forgetWebdavSavedPassword,
@@ -171,6 +177,7 @@ import {
type McpHttpServerSettings,
type McpHttpServerStatus,
type WebMcpHttpStatus,
type WebMcpHttpSettings,
type McpServerStatus,
type SnippetProvider,
type SnippetSyncConfig,
@@ -206,6 +213,7 @@ import {
buildMcpWorkBuddyConfig,
mcpWebBackendUrl,
type McpLaunchConfig,
preferMcpNativeLaunch,
} from "@/lib/mcp/mcpConfigTemplates";
import { beginMcpStatusRequest, mcpUpdateAvailability } from "@/lib/mcp/mcpUpdateStatus";
import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents";
@@ -299,7 +307,10 @@ const { t, locale } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const historyRetention = useHistoryRetentionSetting();
const mcpHistoryRetention = useHistoryRetentionSetting(loadMcpHistoryRetentionLimit, saveMcpHistoryRetentionLimit);
const { draft: editHistoryRetentionLimit, loaded: historyRetentionLoaded, loading: historyRetentionLoading, saving: historyRetentionSaving, loadError: historyRetentionLoadError } = historyRetention;
const { draft: editMcpHistoryRetentionLimit, loaded: mcpHistoryRetentionLoaded, loading: mcpHistoryRetentionLoading, saving: mcpHistoryRetentionSaving, loadError: mcpHistoryRetentionLoadError } = mcpHistoryRetention;
const mcpHistoryCleanupLoading = ref(false);
const connectionStore = useConnectionStore();
const hasSqlServerConnection = computed(() => connectionStore.connections.some((connection) => effectiveDatabaseTypeForConnection(connection) === "sqlserver"));
const savedSqlStore = useSavedSqlStore();
@@ -613,6 +624,15 @@ const editShowLineNumbers = ref(settingsStore.editorSettings.showLineNumbers);
const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurrentStatementFrame);
const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValueHints);
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
const editTableCompletionSchemaQualification = ref<SqlTableCompletionSchemaQualification>(settingsStore.editorSettings.tableCompletionSchemaQualification);
const tableCompletionSchemaQualificationDescription = computed(() => {
const key = {
never: "settings.tableCompletionSchemaQualificationNeverDescription",
collision: "settings.tableCompletionSchemaQualificationCollisionDescription",
always: "settings.tableCompletionSchemaQualificationAlwaysDescription",
}[editTableCompletionSchemaQualification.value];
return t(key);
});
const editInsertSpaceAfterCompletion = ref(settingsStore.editorSettings.insertSpaceAfterCompletion);
const editSqlServerSpaceConfirmsCompletion = ref(settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion);
const showSqlServerSpaceConfirmsCompletion = computed(() => hasSqlServerConnection.value || settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion || editSqlServerSpaceConfirmsCompletion.value);
@@ -661,6 +681,8 @@ const debugLogCopied = ref(false);
const debugLogDownloaded = ref(false);
const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader);
const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnTypesInHeader);
const editShowColumnHeaderTooltips = ref(settingsStore.editorSettings.showColumnHeaderTooltips);
const editShowResultSourceDatabase = ref(settingsStore.editorSettings.showResultSourceDatabase);
const editDataGridShowTransposeFieldMetadata = ref(settingsStore.editorSettings.dataGridShowTransposeFieldMetadata);
const editColorizeDataGridCellTypes = ref(settingsStore.editorSettings.colorizeDataGridCellTypes);
const editShowIndexIndicatorsInHeader = ref(settingsStore.editorSettings.showIndexIndicatorsInHeader);
@@ -979,6 +1001,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
showCurrentStatementFrame: editShowCurrentStatementFrame.value,
showInsertValueHints: editShowInsertValueHints.value,
autoAliasTables: editAutoAliasTables.value,
tableCompletionSchemaQualification: editTableCompletionSchemaQualification.value,
insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value,
sqlServerSpaceConfirmsCompletion: editSqlServerSpaceConfirmsCompletion.value,
sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically.value,
@@ -1002,6 +1025,8 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
tabSortMode: editTabSortMode.value,
showColumnCommentsInHeader: editShowColumnCommentsInHeader.value,
showColumnTypesInHeader: editShowColumnTypesInHeader.value,
showColumnHeaderTooltips: editShowColumnHeaderTooltips.value,
showResultSourceDatabase: editShowResultSourceDatabase.value,
dataGridShowTransposeFieldMetadata: editDataGridShowTransposeFieldMetadata.value,
colorizeDataGridCellTypes: editColorizeDataGridCellTypes.value,
dataGridTypeColorSchemes: editDataGridTypeColorSchemes.value,
@@ -1623,6 +1648,7 @@ function syncEditorSettingsDraftFromStore() {
editShowCurrentStatementFrame.value = settingsStore.editorSettings.showCurrentStatementFrame;
editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints;
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
editTableCompletionSchemaQualification.value = settingsStore.editorSettings.tableCompletionSchemaQualification;
editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion;
editSqlServerSpaceConfirmsCompletion.value = settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion;
editSortCompletionColumnsAlphabetically.value = settingsStore.editorSettings.sortCompletionColumnsAlphabetically;
@@ -1647,6 +1673,8 @@ function syncEditorSettingsDraftFromStore() {
editTabSortMode.value = settingsStore.editorSettings.tabSortMode;
editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader;
editShowColumnHeaderTooltips.value = settingsStore.editorSettings.showColumnHeaderTooltips;
editShowResultSourceDatabase.value = settingsStore.editorSettings.showResultSourceDatabase;
editDataGridShowTransposeFieldMetadata.value = settingsStore.editorSettings.dataGridShowTransposeFieldMetadata;
editColorizeDataGridCellTypes.value = settingsStore.editorSettings.colorizeDataGridCellTypes;
editDataGridTypeColorSchemes.value = cloneDataGridTypeColorSchemes(settingsStore.editorSettings.dataGridTypeColorSchemes);
@@ -1759,6 +1787,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
showCurrentStatementFrame: editShowCurrentStatementFrame,
showInsertValueHints: editShowInsertValueHints,
autoAliasTables: editAutoAliasTables,
tableCompletionSchemaQualification: editTableCompletionSchemaQualification,
insertSpaceAfterCompletion: editInsertSpaceAfterCompletion,
sqlServerSpaceConfirmsCompletion: editSqlServerSpaceConfirmsCompletion,
sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically,
@@ -1780,6 +1809,8 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
tabSortMode: editTabSortMode,
showColumnCommentsInHeader: editShowColumnCommentsInHeader,
showColumnTypesInHeader: editShowColumnTypesInHeader,
showColumnHeaderTooltips: editShowColumnHeaderTooltips,
showResultSourceDatabase: editShowResultSourceDatabase,
dataGridShowTransposeFieldMetadata: editDataGridShowTransposeFieldMetadata,
colorizeDataGridCellTypes: editColorizeDataGridCellTypes,
dataGridTypeColorSchemes: editDataGridTypeColorSchemes,
@@ -1894,6 +1925,7 @@ watch(
if (open) {
syncEditorSettingsDraftFromStore();
void historyRetention.load();
void mcpHistoryRetention.load();
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
editQuitOnClose.value = settingsStore.desktopSettings.quit_on_close;
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
@@ -1903,6 +1935,7 @@ watch(
editSidebarTablePageSize.value = settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
} else {
historyRetention.discard();
mcpHistoryRetention.discard();
clearThemePaletteOptionPreview();
clearUiFontOptionPreview();
restoreLocaleOptionPreview();
@@ -2102,11 +2135,25 @@ const queryResultRowLimitDraftTouched = computed(
normalizeTableOpenPageSizeDraft(editPageSize.value) !== normalizeTableOpenPageSizeDraft(editEditorSettingsBase.value.pageSize),
);
const hasBlockingQueryResultRowLimit = computed(() => queryResultRowLimitViolated.value && queryResultRowLimitDraftTouched.value);
const hasApplyBlocker = computed(() => historyRetention.invalid.value || historyRetentionSaving.value || hasBlockingShortcutConflicts.value || hasBlockingFormatterConfig.value || hasBlockingQueryResultRowLimit.value);
const hasApplyBlocker = computed(() => historyRetention.invalid.value || mcpHistoryRetention.invalid.value || historyRetentionSaving.value || mcpHistoryRetentionSaving.value || hasBlockingShortcutConflicts.value || hasBlockingFormatterConfig.value || hasBlockingQueryResultRowLimit.value);
async function cleanupMcpHistory() {
if (mcpHistoryCleanupLoading.value) return;
mcpHistoryCleanupLoading.value = true;
try {
const deleted = await cleanupMcpHistoryRetention();
toast(t("settings.mcpHistoryCleanupCompleted", { count: deleted }));
} catch (error) {
toast(t("settings.mcpHistoryCleanupFailed", { error: error instanceof Error ? error.message : String(error) }), 5000);
} finally {
mcpHistoryCleanupLoading.value = false;
}
}
function hasChanges(): boolean {
return (
historyRetention.changed.value ||
mcpHistoryRetention.changed.value ||
hasImportedSettingsPendingApply.value ||
hasEditorDraftChanges.value ||
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
@@ -2123,6 +2170,7 @@ function hasChanges(): boolean {
async function persistSettings() {
if (hasApplyBlocker.value) return;
await historyRetention.save();
await mcpHistoryRetention.save();
const editorSettingsPatch = editorSettingsPatchFromDraft(currentEditorSettingsDraft(), editEditorSettingsBase.value);
const sidebarObjectDisplayChanged = editorSettingsPatch.sidebarObjectDisplay !== undefined && editorSettingsPatch.sidebarObjectDisplay !== settingsStore.editorSettings.sidebarObjectDisplay;
const sidebarTablePageSizeChanged = editSidebarTablePageSize.value !== (settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE);
@@ -2228,6 +2276,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editTableCompletionSchemaQualification.value = DEFAULT_EDITOR_SETTINGS.tableCompletionSchemaQualification;
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
editSqlServerSpaceConfirmsCompletion.value = DEFAULT_EDITOR_SETTINGS.sqlServerSpaceConfirmsCompletion;
editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically;
@@ -2302,8 +2351,11 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
} else if (tab === "data") {
historyRetention.reset();
mcpHistoryRetention.reset();
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
editShowColumnHeaderTooltips.value = DEFAULT_EDITOR_SETTINGS.showColumnHeaderTooltips;
editShowResultSourceDatabase.value = DEFAULT_EDITOR_SETTINGS.showResultSourceDatabase;
editDataGridShowTransposeFieldMetadata.value = DEFAULT_EDITOR_SETTINGS.dataGridShowTransposeFieldMetadata;
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
// Back to the built-in palette, but keep the user's saved schemes available.
@@ -2361,6 +2413,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
function resetAllDefaults() {
historyRetention.reset();
mcpHistoryRetention.reset();
// Same contract as resetDefaultsForTab: a full reset also exits any
// in-progress shortcut capture (#9066).
editingShortcutId.value = null;
@@ -2382,6 +2435,7 @@ function resetAllDefaults() {
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editTableCompletionSchemaQualification.value = DEFAULT_EDITOR_SETTINGS.tableCompletionSchemaQualification;
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
editSqlServerSpaceConfirmsCompletion.value = DEFAULT_EDITOR_SETTINGS.sqlServerSpaceConfirmsCompletion;
editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically;
@@ -2411,6 +2465,7 @@ function resetAllDefaults() {
editSidebarTablePageSize.value = DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
editShowColumnHeaderTooltips.value = DEFAULT_EDITOR_SETTINGS.showColumnHeaderTooltips;
editDataGridShowTransposeFieldMetadata.value = DEFAULT_EDITOR_SETTINGS.dataGridShowTransposeFieldMetadata;
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
// Reset the selection only; saved schemes survive a full settings reset.
@@ -2629,6 +2684,12 @@ function onCompletionTriggerModeChange(v: any) {
}
}
function onTableCompletionSchemaQualificationChange(v: any) {
if (v === "never" || v === "collision" || v === "always") {
editTableCompletionSchemaQualification.value = v;
}
}
function onTableHoverLookupModeChange(v: any) {
if (v === "current" || v === "fallback" || v === "always") {
editTableHoverLookupMode.value = v;
@@ -3289,6 +3350,11 @@ const mcpHttpSettings = ref<McpHttpServerSettings>({
});
const mcpHttpStatus = ref<McpHttpServerStatus | null>(null);
const webMcpHttpStatus = ref<WebMcpHttpStatus | null>(null);
const webMcpEnabledDraft = ref(false);
const webMcpAllowedHostsText = ref("");
const webMcpAllowedOriginsText = ref("");
const webMcpSaving = ref(false);
const webMcpError = ref("");
const mcpHttpLoading = ref(false);
const mcpHttpSaving = ref(false);
const mcpHttpError = ref("");
@@ -3466,6 +3532,7 @@ async function saveMcpPolicy(
databaseScope: "all" | "selected" | "none";
allowedDatabases: string[];
databasePolicies: { databaseName: string; readOnly: boolean; allowDangerousSql: boolean }[];
allowSalesforceDml: boolean;
}[];
groupPolicies?: McpGroupPolicy[];
queryTimeoutSecs?: number | null;
@@ -3654,6 +3721,14 @@ function onMcpGroupExecutionModeChange(groupId: string, mode: McpConnectionExecu
void saveMcpPolicy({ groupPolicies });
}
// A connection rule is only worth persisting when it actually changes something: an
// explicit execution mode, a narrowed database scope, a per-database override, or the
// Salesforce DML opt-in. Rules that merely restate the inherited defaults are dropped so
// the stored policy stays readable and keeps following later global changes.
function mcpConnectionPolicyIsMeaningful(rule: McpConnectionPolicy): boolean {
return rule.executionModeConfigured || rule.databaseScope !== "all" || rule.databasePolicies.length > 0 || rule.allowSalesforceDml;
}
function onMcpConnectionExecutionModeChange(connectionId: string, mode: McpConnectionExecutionMode | "inherit") {
if (mode === "high_risk_write" && !window.confirm(t("settings.mcpExecutionModeHighRiskConfirm"))) return;
const existing = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId);
@@ -3677,8 +3752,38 @@ function onMcpConnectionExecutionModeChange(connectionId: string, mode: McpConne
databaseScope: existing?.databaseScope ?? ("all" as const),
allowedDatabases: existing?.allowedDatabases ?? [],
databasePolicies: selectedMode.databasePolicies,
// Switching a connection to read-only silently revokes the DML opt-in, matching
// the backend's ceiling: an agent must never keep a write path it lost.
allowSalesforceDml: (existing?.allowSalesforceDml ?? false) && !selectedMode.readOnly,
};
if (next.executionModeConfigured || next.databaseScope !== "all" || next.databasePolicies.length > 0) rules.push(next);
if (mcpConnectionPolicyIsMeaningful(next)) rules.push(next);
void saveMcpPolicy({ connectionPolicies: rules });
}
function onMcpConnectionSalesforceDmlChange(connectionId: string, allowed: boolean) {
const existing = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId);
if (allowed) {
// Read-only is a hard ceiling on the server too, so letting the box stay checked
// here would only advertise a write path that every prepare call then refuses.
const effectiveMode = mcpEffectiveExecutionMode(mcpInheritedGroupExecutionMode(connectionId), mcpConnectionExecutionMode(connectionId), "inherit");
if (effectiveMode === "read_only") {
toast(t("settings.mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked"), 5000);
return;
}
}
const rules = settingsStore.mcpGlobalPolicy.connectionPolicies.filter((item) => item.connectionId !== connectionId);
const next: McpConnectionPolicy = {
connectionId,
readOnly: existing?.readOnly ?? false,
allowDangerousSql: existing?.allowDangerousSql ?? false,
executionModeConfigured: existing?.executionModeConfigured ?? false,
executionModePolicyVersion: existing?.executionModePolicyVersion ?? null,
databaseScope: existing?.databaseScope ?? "all",
allowedDatabases: existing?.allowedDatabases ?? [],
databasePolicies: existing?.databasePolicies ?? [],
allowSalesforceDml: allowed,
};
if (mcpConnectionPolicyIsMeaningful(next)) rules.push(next);
void saveMcpPolicy({ connectionPolicies: rules });
}
@@ -3698,7 +3803,7 @@ function onMcpDatabaseExecutionModeChange(connectionId: string, databaseName: st
databasePolicies,
};
const rules = settingsStore.mcpGlobalPolicy.connectionPolicies.filter((item) => item.connectionId !== connectionId);
if (next.executionModeConfigured || next.databaseScope !== "all" || next.databasePolicies.length > 0) rules.push(next);
if (mcpConnectionPolicyIsMeaningful(next)) rules.push(next);
void saveMcpPolicy({ connectionPolicies: rules });
}
@@ -3755,7 +3860,12 @@ async function loadMcpHttpSettings() {
mcpHttpError.value = "";
try {
if (isWeb) {
webMcpHttpStatus.value = await loadWebMcpHttpStatus();
const status = await loadWebMcpHttpStatus();
webMcpHttpStatus.value = status;
webMcpEnabledDraft.value = status.enabled;
webMcpAllowedHostsText.value = status.allowedHosts.join("\n");
webMcpAllowedOriginsText.value = status.allowedOrigins.join("\n");
webMcpError.value = "";
return;
}
const [settings, status] = await Promise.all([loadMcpHttpServerSettings(), mcpHttpServerStatus()]);
@@ -3771,6 +3881,41 @@ async function loadMcpHttpSettings() {
}
}
async function saveWebMcpSettings() {
if (webMcpSaving.value || webMcpHttpStatus.value?.deploymentManaged) return;
webMcpSaving.value = true;
webMcpError.value = "";
const settings: WebMcpHttpSettings = {
enabled: webMcpEnabledDraft.value,
allowedHosts: mcpHttpList(webMcpAllowedHostsText.value),
allowedOrigins: mcpHttpList(webMcpAllowedOriginsText.value),
};
try {
const status = await saveWebMcpHttpSettings(settings);
webMcpHttpStatus.value = status;
webMcpEnabledDraft.value = status.enabled;
webMcpAllowedHostsText.value = status.allowedHosts.join("\n");
webMcpAllowedOriginsText.value = status.allowedOrigins.join("\n");
} catch (error: unknown) {
webMcpError.value = formatMcpHttpError(error);
} finally {
webMcpSaving.value = false;
}
}
async function rotateWebMcpAccessToken() {
if (webMcpSaving.value || webMcpHttpStatus.value?.deploymentManaged) return;
webMcpSaving.value = true;
webMcpError.value = "";
try {
webMcpHttpStatus.value = await rotateWebMcpToken();
} catch (error: unknown) {
webMcpError.value = formatMcpHttpError(error);
} finally {
webMcpSaving.value = false;
}
}
async function saveMcpHttpSettings() {
if (mcpHttpSaving.value) return;
mcpHttpSaving.value = true;
@@ -3814,6 +3959,9 @@ const mcpLaunchConfig = computed<McpLaunchConfig | undefined>(() => {
};
}
const env = mcpStatus.value?.data_dir ? { DBX_DATA_DIR: mcpStatus.value.data_dir } : undefined;
if (mcpStatus.value?.native_bin_path) {
return preferMcpNativeLaunch({ command: "dbx-mcp-server", env }, mcpStatus.value.native_bin_path);
}
if (mcpStatus.value?.node_path && mcpStatus.value.script_path) {
return {
command: mcpStatus.value.node_path,
@@ -3829,17 +3977,9 @@ const mcpLaunchConfig = computed<McpLaunchConfig | undefined>(() => {
const mcpJsonRecommendedConfig = computed(() => buildMcpJsonConfig(mcpLaunchConfig.value));
const mcpTraeRecommendedConfig = computed(() => {
// TRAE currently splits Windows executable paths containing spaces, so bypass Node and launch the native MCP binary directly.
const nativeBinPath = !isWeb && isWindows() ? mcpStatus.value?.native_bin_path : undefined;
return buildMcpTraeConfig(mcpLaunchConfig.value, nativeBinPath ?? undefined);
});
const mcpTraeRecommendedConfig = computed(() => buildMcpTraeConfig(mcpLaunchConfig.value));
const mcpQoderRecommendedConfig = computed(() => {
// Qoder follows TRAE's mcpServers JSON format and has the same Windows path parsing limitation.
const nativeBinPath = !isWeb && isWindows() ? mcpStatus.value?.native_bin_path : undefined;
return buildMcpQoderConfig(mcpLaunchConfig.value, nativeBinPath ?? undefined);
});
const mcpQoderRecommendedConfig = computed(() => buildMcpQoderConfig(mcpLaunchConfig.value));
const mcpVsCodeRecommendedConfig = computed(() => buildMcpVsCodeConfig(mcpLaunchConfig.value));
@@ -3868,11 +4008,19 @@ const mcpStatusLabel = computed(() => {
});
const mcpCommand = computed(() => {
if (!mcpStatus.value) return "npm install -g @dbx-app/mcp-server@latest";
if (!mcpStatus.value) return isWindows() ? 'powershell -NoProfile -Command "irm https://dbxio.com/install-mcp.ps1 | iex"' : "curl -fsSL https://dbxio.com/install-mcp | sh";
return mcpStatus.value.installed ? mcpStatus.value.update_command : mcpStatus.value.install_command;
});
const mcpUninstallCommand = computed(() => mcpStatus.value?.uninstall_command || "npm uninstall -g @dbx-app/mcp-server");
const mcpNativeMigrationAvailable = computed(() => mcpStatus.value?.installation_source === "npm");
const mcpInstallDisabled = computed(() => mcpInstalling.value || mcpUninstalling.value || Boolean(mcpStatus.value?.installed && !mcpStatus.value.update_available && !mcpNativeMigrationAvailable.value));
const mcpInstallButtonLabel = computed(() => {
if (mcpInstalling.value) return t("settings.mcpInstalling");
if (mcpNativeMigrationAvailable.value) return t("settings.mcpSwitchToNativeButton");
if (!mcpStatus.value?.installed) return t("settings.mcpInstallButton");
return mcpStatus.value.update_available ? t("settings.mcpUpdateButton") : t("settings.mcpUpToDate");
});
async function refreshMcpStatus() {
if (mcpStatusLoading.value) return;
@@ -3913,7 +4061,7 @@ async function installMcp() {
mcpInstallMessage.value = "";
mcpInstallError.value = false;
try {
const result = await installMcpServer();
const result = mcpNativeMigrationAvailable.value || !mcpStatus.value?.installed ? await installNativeMcpServer() : await installMcpServer();
mcpInstallMessage.value = result;
mcpInstallError.value = false;
// 安装成功后刷新状态
@@ -3954,6 +4102,28 @@ async function uninstallMcp() {
}
}
async function uninstallNpmMcpFallback() {
if (mcpInstalling.value || mcpUninstalling.value || !mcpStatus.value?.npm_installed) return;
if (!window.confirm(t("settings.mcpRemoveNpmFallbackConfirm"))) return;
mcpUninstalling.value = true;
mcpInstallMessage.value = "";
mcpInstallError.value = false;
try {
mcpInstallMessage.value = await uninstallNpmMcpServer();
await refreshMcpStatus();
notifyComponentUpdatesChanged();
} catch (e: any) {
mcpInstallMessage.value = t("settings.mcpRemoveNpmFallbackFailed", { error: e?.message || String(e) });
mcpInstallError.value = true;
} finally {
mcpUninstalling.value = false;
window.setTimeout(() => {
mcpInstallMessage.value = "";
mcpInstallError.value = false;
}, 3000);
}
}
// ---------- WebDAV Sync ----------
const webdavEndpoint = ref(localStorage.getItem("dbx-webdav-endpoint") || "");
const webdavUsername = ref(localStorage.getItem("dbx-webdav-username") || "");
@@ -6300,6 +6470,25 @@ onUnmounted(() => {
</div>
<Switch id="editor-auto-alias-tables" v-model="editAutoAliasTables" class="mt-0.5" />
</div>
<div class="settings-item flex items-start justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" data-editor-table-completion-schema-qualification>
<div class="min-w-0 space-y-1">
<Label>{{ t("settings.tableCompletionSchemaQualification") }}</Label>
<p class="text-xs leading-tight text-muted-foreground">
{{ tableCompletionSchemaQualificationDescription }}
</p>
</div>
<Select :model-value="editTableCompletionSchemaQualification" @update:model-value="onTableCompletionSchemaQualificationChange">
<SelectTrigger class="h-8 w-44 shrink-0">
<SelectValue :placeholder="t('settings.tableCompletionSchemaQualification')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="never">{{ t("settings.tableCompletionSchemaQualificationNever") }}</SelectItem>
<SelectItem value="collision">{{ t("settings.tableCompletionSchemaQualificationCollision") }}</SelectItem>
<SelectItem value="always">{{ t("settings.tableCompletionSchemaQualificationAlways") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
@@ -7555,11 +7744,13 @@ onUnmounted(() => {
<!-- Data Tab -->
<section v-else-if="activeSettingsTab === 'data'" data-settings-search-id="data" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('data')]">
<div data-settings-search-id="history-retention" :class="['space-y-2', settingsSearchTargetClass('history-retention')]">
<Label for="history-retention-limit">{{ t("settings.historyRetentionLimit") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.historyRetentionDescription") }}</p>
<div data-settings-search-id="history-retention" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('history-retention')]">
<div class="min-w-0 space-y-1">
<Label for="history-retention-limit">{{ t("settings.historyRetentionLimit") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.historyRetentionDescription") }}</p>
</div>
<Select :model-value="String(editHistoryRetentionLimit)" :disabled="!historyRetentionLoaded || historyRetentionSaving" @update:model-value="(value) => (editHistoryRetentionLimit = Number(value))">
<SelectTrigger id="history-retention-limit" class="w-40"><SelectValue /></SelectTrigger>
<SelectTrigger id="history-retention-limit" class="w-40 shrink-0"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="limit in HISTORY_RETENTION_LIMITS" :key="limit" :value="String(limit)">{{ limit === 0 ? t("settings.historyRetentionUnlimited") : String(limit) }}</SelectItem>
</SelectContent>
@@ -7570,6 +7761,28 @@ onUnmounted(() => {
<Button type="button" variant="outline" size="sm" @click="historyRetention.load">{{ t("common.retry") }}</Button>
</div>
</div>
<div data-settings-search-id="mcp-history-retention" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('mcp-history-retention')]">
<div class="min-w-0 space-y-1">
<Label for="mcp-history-retention-limit">{{ t("settings.mcpHistoryRetentionLimit") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpHistoryRetentionDescription") }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Select :model-value="String(editMcpHistoryRetentionLimit)" :disabled="!mcpHistoryRetentionLoaded || mcpHistoryRetentionSaving" @update:model-value="(value) => (editMcpHistoryRetentionLimit = Number(value))">
<SelectTrigger id="mcp-history-retention-limit" class="w-40"><SelectValue /></SelectTrigger>
<SelectContent
><SelectItem v-for="limit in HISTORY_RETENTION_LIMITS" :key="limit" :value="String(limit)">{{ limit === 0 ? t("settings.historyRetentionUnlimited") : String(limit) }}</SelectItem></SelectContent
>
</Select>
<p v-if="mcpHistoryRetentionLoading" class="text-xs text-muted-foreground">{{ t("common.loading") }}</p>
<div v-if="mcpHistoryRetentionLoadError" class="flex items-center gap-2 text-xs text-destructive" role="alert">
<span>{{ t("settings.historyRetentionLoadFailed", { error: mcpHistoryRetentionLoadError }) }}</span>
<Button type="button" variant="outline" size="sm" @click="mcpHistoryRetention.load">{{ t("common.retry") }}</Button>
</div>
<Button type="button" variant="outline" size="sm" :disabled="mcpHistoryCleanupLoading || !mcpHistoryRetentionLoaded" @click="cleanupMcpHistory">
{{ mcpHistoryCleanupLoading ? t("common.loading") : t("settings.mcpHistoryCleanup") }}
</Button>
</div>
</div>
<div id="data-grid-toolbar-layout" data-settings-search-id="data-grid-toolbar-layout" :class="['settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('data-grid-toolbar-layout')]">
<div class="min-w-0 space-y-1">
<Label for="data-grid-toolbar-layout-select">{{ t("settings.dataGridToolbarLayout") }}</Label>
@@ -7908,6 +8121,28 @@ onUnmounted(() => {
</div>
<Switch id="show-column-types-in-header" v-model="editShowColumnTypesInHeader" />
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="show-column-header-tooltips">
{{ t("settings.showColumnHeaderTooltips") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.showColumnHeaderTooltipsDescription") }}
</p>
</div>
<Switch id="show-column-header-tooltips" v-model="editShowColumnHeaderTooltips" />
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="show-result-source-database">
{{ t("settings.showResultSourceDatabase") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.showResultSourceDatabaseDescription") }}
</p>
</div>
<Switch id="show-result-source-database" v-model="editShowResultSourceDatabase" />
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="data-grid-show-transpose-field-metadata">
@@ -9337,7 +9572,7 @@ LIMIT 100;</pre
<span class="flex w-full min-w-0 items-center gap-2">
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" :icon-path="provider.iconPath" />
<span class="min-w-0 flex-1 truncate">{{ provider.label }}</span>
<Badge variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] font-normal">{{ t("ai.jalapenoSponsored") }}</Badge>
<Badge v-if="provider.badgeKey" variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] font-normal">{{ t(provider.badgeKey) }}</Badge>
</span>
</SelectItem>
</SelectGroup>
@@ -9637,25 +9872,63 @@ LIMIT 100;</pre
</TabsList>
<TabsContent value="http" class="m-0 space-y-4">
<div v-if="isWeb" class="space-y-4">
<div class="rounded-md border bg-muted/20 p-4 space-y-2">
<div class="space-y-3 border-t border-border/60 pt-3">
<div class="flex items-center justify-between gap-3">
<Label class="text-base">{{ t("settings.mcpHttpWebServiceTitle") }}</Label>
<Badge :variant="webMcpHttpStatus?.enabled ? 'default' : 'outline'">{{ webMcpHttpStatus?.enabled ? t("settings.mcpHttpStatusLabelEnabled") : t("settings.mcpHttpStatusLabelDisabled") }}</Badge>
</div>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebServiceDescription") }}</p>
<p v-if="mcpHttpError" class="text-xs text-destructive">{{ mcpHttpError }}</p>
<template v-if="webMcpHttpStatus">
<code class="block rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpEndpoint }}</code>
<p v-if="!webMcpHttpStatus.enabled" class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebDisabledHint") }}</p>
<code v-else class="block overflow-x-auto rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpEndpoint }}</code>
<p class="text-[11px] text-muted-foreground">
{{
t("settings.mcpHttpWebTokenSourceAndHosts", {
tokenSource: webMcpHttpStatus.tokenSource || t("settings.mcpHttpNotConfigured"),
tokenSource: webMcpHttpStatus.tokenSource === "managed" ? t("settings.mcpHttpWebManagedTokenSource") : webMcpHttpStatus.tokenSource || t("settings.mcpHttpNotConfigured"),
hosts: webMcpHttpStatus.allowedHosts.join(", ") || t("settings.mcpHttpNotConfigured"),
})
}}
</p>
<p v-if="webMcpHttpStatus.allowedOrigins.length" class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpWebAllowedOrigins", { origins: webMcpHttpStatus.allowedOrigins.join(", ") }) }}</p>
<p v-if="webMcpHttpStatus.deploymentManaged" class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebDeploymentManaged") }}</p>
<p v-else-if="!webMcpHttpStatus.managementAvailable" class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebPasswordRequired") }}</p>
<template v-else>
<div class="flex items-center justify-between gap-3 border-t border-border/60 pt-3">
<Label for="web-mcp-enabled" class="text-sm">{{ t("settings.mcpHttpWebEnableLabel") }}</Label>
<Switch id="web-mcp-enabled" v-model="webMcpEnabledDraft" :disabled="webMcpSaving" />
</div>
<div class="space-y-1.5">
<Label for="web-mcp-hosts">{{ t("settings.mcpHttpAllowedHostsLabel") }}</Label>
<textarea id="web-mcp-hosts" v-model="webMcpAllowedHostsText" rows="2" :disabled="webMcpSaving" class="min-h-16 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs" placeholder="192.168.0.77:4224" />
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpHostsHint") }}</p>
</div>
<div class="space-y-1.5">
<Label for="web-mcp-origins">{{ t("settings.mcpHttpAllowedOriginsLabel") }}</Label>
<textarea id="web-mcp-origins" v-model="webMcpAllowedOriginsText" rows="2" :disabled="webMcpSaving" class="min-h-16 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs" placeholder="https://mcp-client.example.com" />
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpWebOriginsHint") }}</p>
</div>
<div v-if="webMcpHttpStatus.enabled && webMcpHttpStatus.accessToken" class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<Label>Bearer Token</Label>
<Button type="button" variant="outline" size="sm" :disabled="webMcpSaving" @click="rotateWebMcpAccessToken">{{ t("settings.mcpHttpRotateToken") }}</Button>
</div>
<div class="flex min-w-0 items-center gap-2">
<code class="min-w-0 flex-1 overflow-x-auto rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpHttpStatus.accessToken }}</code>
<Button type="button" variant="outline" size="icon" :title="t('common.copy')" @click="copyMcpText('http-token', webMcpHttpStatus.accessToken || '')">
<CheckCircle2 v-if="mcpCopied === 'http-token'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p v-if="webMcpError" class="text-xs text-destructive">{{ webMcpError }}</p>
<div class="flex flex-wrap justify-end gap-2">
<Button type="button" variant="outline" size="sm" :disabled="mcpHttpLoading || webMcpSaving" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
<Button type="button" size="sm" :disabled="webMcpSaving || (webMcpEnabledDraft && !mcpHttpList(webMcpAllowedHostsText).length)" @click="saveWebMcpSettings">{{ t("settings.mcpHttpWebSave") }}</Button>
</div>
</template>
</template>
<Button type="button" variant="outline" size="sm" :disabled="mcpHttpLoading" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
<Button v-if="!webMcpHttpStatus || webMcpHttpStatus.deploymentManaged" type="button" variant="outline" size="sm" :disabled="mcpHttpLoading" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
</div>
</div>
@@ -9666,7 +9939,7 @@ LIMIT 100;</pre
<div class="flex items-center gap-2">
<Label class="text-base">{{ t("settings.mcpHttpServiceTitle") }}</Label>
<Badge :variant="mcpHttpStatus?.running ? 'default' : 'outline'">
{{ mcpHttpStatus?.running ? t("settings.mcpHttpStatusLabelRunning") : mcpHttpSettings.enabled ? t("settings.mcpHttpStatusLabelPending") : t("settings.mcpHttpStatusLabelDisabled") }}
{{ mcpHttpStatus?.running ? t("settings.mcpHttpStatusLabelRunning") : mcpHttpSettings.enabled ? t("settings.mcpHttpStatusLabelNotRunning") : t("settings.mcpHttpStatusLabelDisabled") }}
</Badge>
</div>
<p class="text-xs leading-relaxed text-muted-foreground">{{ t("settings.mcpHttpServiceDescription") }}</p>
@@ -9732,10 +10005,9 @@ LIMIT 100;</pre
{{ mcpHttpHasUnsavedChanges ? t("settings.mcpHttpUnsavedChangesHint") : t("settings.mcpHttpSaveHint") }}
</p>
<div class="flex shrink-0 justify-end gap-2">
<Button type="button" variant="outline" :disabled="mcpHttpLoading || mcpHttpSaving" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReload") }}</Button>
<Button type="button" :disabled="mcpHttpLoading || mcpHttpSaving || Boolean(mcpHttpDraftValidationError)" @click="saveMcpHttpSettings">
<Loader2 v-if="mcpHttpSaving" class="mr-2 h-4 w-4 animate-spin" />
{{ t("settings.mcpHttpSaveAndStart") }}
{{ t("settings.mcpHttpApply") }}
</Button>
</div>
</div>
@@ -9744,10 +10016,9 @@ LIMIT 100;</pre
<div v-else class="flex flex-col gap-3 rounded-md border border-dashed px-4 py-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<p>{{ t("settings.mcpHttpDisabledHint") }}</p>
<div class="flex shrink-0 justify-end gap-2">
<Button type="button" variant="outline" :disabled="mcpHttpLoading || mcpHttpSaving" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReload") }}</Button>
<Button type="button" :disabled="mcpHttpLoading || mcpHttpSaving" @click="saveMcpHttpSettings">
<Loader2 v-if="mcpHttpSaving" class="mr-2 h-4 w-4 animate-spin" />
{{ t("settings.mcpHttpSaveAndStop") }}
{{ t("settings.mcpHttpApply") }}
</Button>
</div>
</div>
@@ -9851,7 +10122,10 @@ LIMIT 100;</pre
</div>
<div v-if="!isWeb" class="space-y-2">
<Label>{{ mcpStatus?.installed ? t("settings.mcpUpdateCommand") : t("settings.mcpInstallCommand") }}</Label>
<Label>{{ mcpStatus?.installed && !mcpNativeMigrationAvailable ? t("settings.mcpUpdateCommand") : t("settings.mcpInstallCommand") }}</Label>
<p v-if="mcpNativeMigrationAvailable" class="rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2 text-xs text-blue-700 dark:text-blue-300">
{{ t("settings.mcpNativeMigrationHint") }}
</p>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1 overflow-x-auto rounded-md border bg-background px-3 py-2 font-mono text-xs whitespace-nowrap">
{{ mcpCommand }}
@@ -9860,10 +10134,10 @@ LIMIT 100;</pre
<CheckCircle2 v-if="mcpCopied === 'install'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<Button type="button" variant="default" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus?.npm_available || (mcpStatus?.installed && !mcpStatus?.update_available)" @click="installMcp">
<Button type="button" variant="default" :disabled="mcpInstallDisabled" @click="installMcp">
<Loader2 v-if="mcpInstalling" class="mr-2 h-4 w-4 animate-spin" />
<CheckCircle2 v-if="!mcpInstalling && mcpStatus?.installed && !mcpStatus?.update_available" class="mr-2 h-4 w-4" />
{{ mcpInstalling ? t("settings.mcpInstalling") : !mcpStatus?.installed ? t("settings.mcpInstallButton") : mcpStatus?.update_available ? t("settings.mcpUpdateButton") : t("settings.mcpUpToDate") }}
<CheckCircle2 v-if="!mcpInstalling && mcpStatus?.installed && !mcpStatus?.update_available && !mcpNativeMigrationAvailable" class="mr-2 h-4 w-4" />
{{ mcpInstallButtonLabel }}
</Button>
</div>
<div
@@ -9887,13 +10161,26 @@ LIMIT 100;</pre
<CheckCircle2 v-if="mcpCopied === 'uninstall'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<Button type="button" variant="outline" class="text-destructive hover:text-destructive" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus.npm_available" @click="uninstallMcp">
<Button type="button" variant="outline" class="text-destructive hover:text-destructive" :disabled="mcpInstalling || mcpUninstalling" @click="uninstallMcp">
<Loader2 v-if="mcpUninstalling" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
{{ mcpUninstalling ? t("settings.mcpUninstalling") : t("settings.mcpUninstallButton") }}
</Button>
</div>
</div>
<div v-if="!isWeb && mcpStatus?.installation_source !== 'npm' && mcpStatus?.npm_installed" class="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="min-w-0 flex-1 text-xs text-amber-800 dark:text-amber-200">
{{ t("settings.mcpNpmFallbackHint") }}
</p>
<Button type="button" variant="outline" size="sm" :disabled="mcpInstalling || mcpUninstalling" @click="uninstallNpmMcpFallback">
<Loader2 v-if="mcpUninstalling" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
{{ t("settings.mcpRemoveNpmFallbackButton") }}
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</div>
@@ -9922,6 +10209,7 @@ LIMIT 100;</pre
@update:scope="onMcpResourceScopeChange"
@set:group-policy="onMcpGroupExecutionModeChange"
@set:connection-policy="onMcpConnectionExecutionModeChange"
@set:connection-salesforce-dml="onMcpConnectionSalesforceDmlChange"
/>
</div>
</template>
@@ -10305,7 +10593,7 @@ LIMIT 100;</pre
<div v-if="mcpTransportTab === 'stdio'" class="flex items-center gap-2 text-xs text-muted-foreground">
<Terminal class="h-3.5 w-3.5" />
<span>{{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNpmBoundary") }}</span>
<span>{{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNativeManagementBoundary") }}</span>
</div>
</div>
</Tabs>
@@ -111,7 +111,8 @@ import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatch
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
import { shouldUseQueryEditorLargeDocumentMode } from "@/lib/editor/queryEditorLargeDocument";
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
@@ -122,24 +123,20 @@ import { addNextQueryEditorSelectionOccurrence, selectAllQueryEditorSelectionOcc
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut";
import { createQueryEditorSelectionCaseShortcutBindings } from "@/lib/editor/queryEditorSelectionCaseShortcut";
import { createQueryEditorExecutionShortcutBindings, createQueryEditorPostCompositionKeyGuard } from "@/lib/editor/queryEditorExecutionShortcut";
import { supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import { queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { resolveSqlDialectId } from "@/lib/sql/semantic/dialect";
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion";
const props = defineProps<QueryEditorProps>();
function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined {
return props.syntaxDialect === "clickhouse" ? props.dialect : (props.syntaxDialect ?? props.dialect);
// clickhouse and soql ride the SQL editor but have no matching behavior dialect;
// fall back to the connection's dialect (undefined for Salesforce).
return props.syntaxDialect === "clickhouse" || props.syntaxDialect === "soql" ? props.dialect : (props.syntaxDialect ?? props.dialect);
}
function queryEditorSelectionLanguage(): "sql" | "text" {
@@ -158,6 +155,9 @@ const COMPLETION_ENTER_MAX_WAIT_MS = 125;
const SQL_SIGNATURE_HELP_WINDOW_CHARS = 10_000;
// Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path.
const SEMANTIC_SQL_COMPLETION_ENABLED = true;
// Master switch for colouring table/column names that the connected database does not have.
// There is no settings entry yet: flip this to false to turn the whole feature off.
const SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED = true;
const emit = defineEmits<{
"update:modelValue": [value: string];
@@ -214,7 +214,8 @@ const sqlDriverProfile = computed(() => (props.connectionId ? connectionStore.ge
const MAX_COMPLETION_TABLES = 200;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX = 2;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = 20;
const SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS = 900;
const SQL_UNKNOWN_OBJECT_DEBOUNCE_MS = 600;
const liveFontSize = ref(settingsStore.editorSettings.fontSize);
const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize);
const isGestureZooming = ref(false);
@@ -437,6 +438,8 @@ interface EditorGestureEvent extends Event {
}
const codeMirrorRuntime = createQueryEditorCodeMirrorRuntime();
const largeDocumentMode = shouldUseQueryEditorLargeDocumentMode(props.modelValue);
const fullEditorFeaturesEnabled = () => !largeDocumentMode;
let previewContextRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let editorIsActive = true;
@@ -447,6 +450,7 @@ const postCompositionKeyGuard = createQueryEditorPostCompositionKeyGuard();
let postCompositionKeyGuardCleanup: (() => void) | null = null;
function runStatementGutterExtension(): import("@codemirror/state").Extension {
if (!fullEditorFeaturesEnabled()) return [];
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
return shouldShowStatementGutter(showRunButtons) ? (codeMirrorRuntime.buildRunStatementGutterExtension?.() ?? []) : [];
}
@@ -776,7 +780,7 @@ function schedulePreviewContextRefresh(currentView: EditorViewType) {
}
function selectStarExpansionTargetForView(currentView: EditorViewType, position?: number): SelectStarExpansionTarget | null {
if (!props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
if (!fullEditorFeaturesEnabled() || !props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
const sql = currentEditorDocText(currentView);
const selection = currentView.state.selection.main;
@@ -863,6 +867,11 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
return;
}
if (!fullEditorFeaturesEnabled()) {
contextObjectTarget.value = null;
return;
}
const sql = currentEditorDocText(currentView);
if (!props.connectionId || props.database == null) {
const candidate = queryTableCandidateAtSqlPosition({
@@ -928,6 +937,7 @@ function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") {
// 与「执行」使用同一套候选解析:选区优先,否则取 position(右键点击处)/ 光标处的单条语句。
// 注意:不跟随 executeAllOnBlankLine 回退到“整篇文档”(那会包含多条语句)。
function resolvePreviewDmlCandidate(position?: number): string {
if (!fullEditorFeaturesEnabled()) return "";
const currentView = view.value;
if (!currentView) return "";
const selection = currentView.state.selection.main;
@@ -1497,52 +1507,56 @@ async function expandSelectStar(target = selectStarExpansionTarget.value) {
currentView.focus();
}
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics } = useQueryEditorDiagnostics({
props,
view,
settingsStore,
connectionStore,
sqlDriverProfile,
sqlStatementParameterOptions,
sqlBehaviorDialect,
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
maxCompletionTables: MAX_COMPLETION_TABLES,
runtime: {
get setSqlDiagnosticsEffect() {
return codeMirrorRuntime.setSqlDiagnosticsEffect;
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics, loadSqlUnknownObjectSpans } =
useQueryEditorDiagnostics({
props,
view,
settingsStore,
connectionStore,
sqlDriverProfile,
sqlStatementParameterOptions,
sqlBehaviorDialect,
queryEditorSelectionLanguage,
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
maxCompletionTables: MAX_COMPLETION_TABLES,
unknownObjectHighlightEnabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
fullFeaturesEnabled: fullEditorFeaturesEnabled,
runtime: {
get setSqlDiagnosticsEffect() {
return codeMirrorRuntime.setSqlDiagnosticsEffect;
},
get diagnosticComp() {
return codeMirrorRuntime.diagnosticComp;
},
get buildSqlDiagnosticExtension() {
return codeMirrorRuntime.buildSqlDiagnosticExtension;
},
get codeMirrorCompletionStatus() {
return codeMirrorRuntime.codeMirrorCompletionStatus;
},
get executableStatementRangeCache() {
return executableStatementRangeCache;
},
set executableStatementRangeCache(value) {
executableStatementRangeCache = value;
},
get editorIsActive() {
return editorIsActive;
},
},
get diagnosticComp() {
return codeMirrorRuntime.diagnosticComp;
metadata: {
get cachedTables() {
return completionMetadata.cachedTables;
},
cachedColumnsByTable,
loadedColumnsByTable,
usesOracleSessionCompletionColumns,
findExactSemanticDiagnosticTable,
completionCacheKey,
ensureColumnsForTable,
isMissingTableMetadataError,
},
get buildSqlDiagnosticExtension() {
return codeMirrorRuntime.buildSqlDiagnosticExtension;
},
get codeMirrorCompletionStatus() {
return codeMirrorRuntime.codeMirrorCompletionStatus;
},
get executableStatementRangeCache() {
return executableStatementRangeCache;
},
set executableStatementRangeCache(value) {
executableStatementRangeCache = value;
},
get editorIsActive() {
return editorIsActive;
},
},
metadata: {
get cachedTables() {
return completionMetadata.cachedTables;
},
cachedColumnsByTable,
loadedColumnsByTable,
usesOracleSessionCompletionColumns,
findExactSemanticDiagnosticTable,
completionCacheKey,
ensureColumnsForTable,
isMissingTableMetadataError,
},
});
});
async function formatCurrentSql() {
if (props.readOnly) return;
@@ -1736,7 +1750,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
view,
runtime: codeMirrorRuntime,
beforeLoad() {
hoverContent.initializeHighlighter();
if (fullEditorFeaturesEnabled()) hoverContent.initializeHighlighter();
},
async prepare(modules) {
const initializedRuntime = modules.runtime;
@@ -1793,6 +1807,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
currentExecutableStatementRange,
executeSqlStatementFromGutter,
signatureHelpWindowChars: SQL_SIGNATURE_HELP_WINDOW_CHARS,
fullFeaturesEnabled: fullEditorFeaturesEnabled,
});
const initialSettings = settingsStore.editorSettings;
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value);
@@ -1826,21 +1841,23 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
initializedRuntime.runGutterComp.of(runStatementGutterExtension()),
initializedRuntime.lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
currentStatementFrameExtension,
fullEditorFeaturesEnabled() ? currentStatementFrameExtension : [],
highlightActiveLineGutter(),
highlightSpecialChars(),
initializedRuntime.historyResetComp.of(history()),
foldGutter({
markerDOM(open: boolean) {
const span = document.createElement("span");
span.className = "cm-foldMarker-svg";
span.innerHTML = open
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
return span;
},
}),
sqlBlockFoldService,
fullEditorFeaturesEnabled()
? foldGutter({
markerDOM(open: boolean) {
const span = document.createElement("span");
span.className = "cm-foldMarker-svg";
span.innerHTML = open
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
return span;
},
})
: [],
fullEditorFeaturesEnabled() ? sqlBlockFoldService : [],
drawSelection(),
editorClipboardLineEndingsExtension(EditorView),
trimmedSelectionLayer(),
@@ -1859,8 +1876,8 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
),
),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
fullEditorFeaturesEnabled() ? indentOnInput() : [],
fullEditorFeaturesEnabled() ? syntaxHighlighting(defaultHighlightStyle, { fallback: true }) : [],
crosshairCursor(),
activeLineHighlighter,
// Vim must be mounted before DBX/default keymaps so normal-mode keys are handled first.
@@ -1870,6 +1887,14 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])),
initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()),
initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()),
fullEditorFeaturesEnabled()
? createSqlUnknownObjectHighlights({
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
load: loadSqlUnknownObjectSpans,
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
})
: [],
tooltips({ parent: tooltipParent }),
initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()),
sqlCompletionTheme(EditorView),
@@ -1896,15 +1921,17 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
return true;
}),
),
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
fullEditorFeaturesEnabled() ? hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)) : [],
initializedRuntime.sqlSignatureComp.of(sqlExtensions.buildSqlSignatureExtension()),
initializedRuntime.diagnosticComp.of(sqlExtensions.buildSqlDiagnosticExtension()),
createInsertValueHintsExtension({
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns,
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
}),
fullEditorFeaturesEnabled()
? createInsertValueHintsExtension({
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns,
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
})
: [],
initializedRuntime.previewRangeComp.of(sqlExtensions.buildPreviewRangeExtension()),
sqlExtensions.buildResultSourceRangeExtension(),
Prec.highest(
@@ -2493,6 +2520,7 @@ function resumeQueryEditorBackgroundWork() {
editorIsActive = true;
registerTableReferenceDropListener();
scheduleSemanticDiagnostics();
view.value?.dispatch({ effects: [refreshSqlUnknownObjectHighlights.of(null)] });
// Warm the database driver/pool while the user is still reading or typing, so
// the first Run does not pay pool creation or external-driver startup.
warmActiveTabConnection();
@@ -2614,7 +2642,15 @@ defineExpose({
</script>
<template>
<div class="h-full w-full overflow-hidden relative" @wheel="recordExecutionViewportInteraction" @pointerdown="recordExecutionViewportInteraction" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd">
<div
class="h-full w-full overflow-hidden relative"
:data-large-document-mode="largeDocumentMode || undefined"
@wheel="recordExecutionViewportInteraction"
@pointerdown="recordExecutionViewportInteraction"
@gesturestart="onEditorGestureStart"
@gesturechange="onEditorGestureChange"
@gestureend="onEditorGestureEnd"
>
<QueryEditorContextMenu :get-state="getContextMenuState" :actions="contextMenuActions" @close="contextMenuOpen = false" v-slot="{ onContextMenu }">
<div
ref="editorRef"
@@ -2,7 +2,7 @@
import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { CalendarClock, Check, Copy, Database, ListFilter, LoaderCircle, Minus, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
import { CalendarClock, Check, Copy, Database, ListFilter, LoaderCircle, Maximize2, Minimize2, Minus, RefreshCw, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
@@ -17,6 +17,7 @@ import { hasHistoryDateRange, historyDateRangeIsValid, type HistoryDateRange } f
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/history/historyVirtualList";
import { historyConnectionHasSelectedDatabase } from "@/lib/history/historySearch";
import { historyEntrySource } from "@/lib/history/historyEntrySource";
import { formatQueryDuration } from "@/lib/format/duration";
import type { HistoryConnectionFilter, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest } from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
@@ -33,18 +34,24 @@ const connectionStore = useConnectionStore();
const props = defineProps<{
currentConnectionId?: string;
currentDatabase?: string;
maximized?: boolean;
}>();
const emit = defineEmits<{
restore: [sql: string, entry: HistoryEntry];
analyzeAi: [entry: HistoryEntry];
close: [];
toggleMaximize: [];
}>();
type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed";
type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed" | "mcp_success";
type HistorySource = "sql" | "mcp";
const searchText = ref("");
const mcpToolName = ref("");
const mcpToolOptions = ref<string[]>([]);
const activeFilter = ref<HistoryFilter>("all");
const activeSource = ref<HistorySource>("sql");
const dateRange = ref<HistoryDateRange>({ startDate: "", endDate: "" });
const dateRangeDraft = ref<HistoryDateRange>({ startDate: "", endDate: "" });
const dateRangeOpen = ref(false);
@@ -70,7 +77,9 @@ const filtersScrollable = ref(false);
let filterScrollResizeObserver: ResizeObserver | null = null;
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const filters: HistoryFilter[] = ["all", "query", "data_change", "schema_change", "failed"];
const filters = computed<HistoryFilter[]>(() => (activeSource.value === "mcp" ? ["all", "failed", "mcp_success"] : ["all", "query", "data_change", "schema_change", "failed"]));
const mcpQuickTools = computed(() => mcpToolOptions.value);
const canClearHistory = computed(() => store.total > 0);
const hasDateFilter = computed(() => hasHistoryDateRange(dateRange.value));
const dateRangeDraftValid = computed(() => historyDateRangeIsValid(dateRangeDraft.value));
const dateRangeSummary = computed(() => {
@@ -94,6 +103,10 @@ function activityKind(entry: HistoryEntry) {
return resolveHistoryActivityKind(entry);
}
function isMcpEntry(entry: HistoryEntry) {
return entry.source === "mcp" || historyEntrySource(entry) === "MCP";
}
function restore(entry: HistoryEntry) {
emit("restore", entry.sql, entry);
selectedEntry.value = null;
@@ -122,13 +135,13 @@ function executeDelete() {
}
function confirmClearHistory() {
if (store.total > 0 || store.connectionOptions.length > 0) {
if (canClearHistory.value) {
showClearConfirm.value = true;
}
}
function executeClear() {
store.clear();
void store.clear(activeSource.value);
showClearConfirm.value = false;
}
@@ -148,11 +161,31 @@ function truncateSql(sql: string): string {
return line.length > 120 ? line.slice(0, 120) + "..." : line;
}
function formatHistoryJson(value?: string | null): string {
if (!value) return "-";
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
function isLegacyResponse(value?: string | null): boolean {
try {
const parsed = JSON.parse(value || "null");
return !!parsed && typeof parsed.content_blocks === "number" && !("content" in parsed);
} catch {
return false;
}
}
function entryTitle(entry: HistoryEntry) {
if (isMcpEntry(entry)) return entry.mcp_tool_name || entry.operation || "MCP call";
return entry.target || entry.operation || truncateSql(entry.sql);
}
function entrySubtitle(entry: HistoryEntry) {
if (isMcpEntry(entry)) return truncateSql(entry.mcp_request_json || entry.target || entry.operation || "-");
if (activityKind(entry) === "query") return truncateSql(entry.sql);
return truncateSql(entry.sql || entry.target || entry.operation || "");
}
@@ -265,11 +298,13 @@ function buildHistorySearchRequest(): HistorySearchRequest {
search_text: searchText.value,
connections: selectedConnections.value.map((connection) => ({ ...connection })),
databases: selectedDatabases.value.map((database) => ({ ...database })),
activity_kind: activeFilter.value !== "all" && activeFilter.value !== "failed" ? activeFilter.value : undefined,
success: activeFilter.value === "failed" ? false : undefined,
activity_kind: activeFilter.value !== "all" && activeFilter.value !== "failed" && activeFilter.value !== "mcp_success" ? activeFilter.value : undefined,
success: activeFilter.value === "failed" ? false : activeFilter.value === "mcp_success" ? true : undefined,
started_at: localDateBoundary(dateRange.value.startDate, false),
ended_at: localDateBoundary(dateRange.value.endDate, true),
limit: 100,
source: activeSource.value,
mcp_tool_name: activeSource.value === "mcp" ? mcpToolName.value.trim() || undefined : undefined,
};
}
@@ -281,6 +316,10 @@ async function runHistorySearch() {
}
}
function refreshHistory() {
void runHistorySearch();
}
// Debounce all filter changes through one timer to avoid redundant history scans.
function scheduleHistorySearch() {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
@@ -356,11 +395,12 @@ function detailsRows(entry: HistoryEntry) {
[t("history.detail.database"), entry.database || "-"],
[t("history.detail.target"), entry.target || "-"],
[t("history.detail.time"), formatFullTime(entry.executed_at)],
[t("history.detail.duration"), `${entry.execution_time_ms}ms`],
[t("history.detail.duration"), formatQueryDuration(entry.execution_time_ms)],
[t("history.detail.affectedRows"), entry.affected_rows ?? "-"],
[t("history.detail.rollback"), canRollbackHistoryEntry(entry) ? t("history.rollbackAvailable") : t("history.rollbackUnavailable")],
[t("history.detail.status"), entry.success ? t("history.success") : t("history.failed")],
];
if (entry.source === "mcp" || historyEntrySource(entry) === "MCP") rows.splice(1, 0, [t("history.detail.mcpTool"), entry.mcp_tool_name || entry.operation || "-"]);
if (entry.error) rows.push([t("history.detail.error"), entry.error]);
return rows;
}
@@ -476,6 +516,7 @@ async function closeHistory() {
}
function getHistoryMenuItems(entry: HistoryEntry): ContextMenuItem[] {
const isMcp = isMcpEntry(entry);
return [
{
label: t("history.viewDetails"),
@@ -483,22 +524,35 @@ function getHistoryMenuItems(entry: HistoryEntry): ContextMenuItem[] {
selectedEntry.value = entry;
},
},
{ label: t("history.restore"), action: () => restore(entry) },
{ label: t("history.analyzeWithAi"), action: () => emit("analyzeAi", entry), icon: Sparkles },
{ label: t("history.copy"), action: () => copyText(entry.sql) },
...(canRollbackHistoryEntry(entry) ? [{ label: t("history.rollback"), action: () => rollback(entry) }] : []),
...(isMcp
? []
: [
{ label: t("history.restore"), action: () => restore(entry) },
{ label: t("history.analyzeWithAi"), action: () => emit("analyzeAi", entry), icon: Sparkles },
{ label: t("history.copy"), action: () => copyText(entry.sql) },
...(canRollbackHistoryEntry(entry) ? [{ label: t("history.rollback"), action: () => rollback(entry) }] : []),
]),
{ label: t("history.delete"), action: () => confirmDeleteEntry(entry.id), variant: "destructive" as const },
];
}
watch(activeSource, () => {
void nextTick(updateFilterScrollability);
});
watch(
() => filters.map((filter) => filterLabel(filter)).join("\0"),
() => {
void nextTick(updateFilterScrollability);
() => store.entries,
(entries) => {
const known = new Set(mcpToolOptions.value);
for (const entry of entries) {
if (isMcpEntry(entry) && entry.mcp_tool_name) known.add(entry.mcp_tool_name);
}
mcpToolOptions.value = [...known].sort().slice(0, 50);
},
{ deep: true },
);
watch([searchText, activeFilter, () => dateRange.value.startDate, () => dateRange.value.endDate, () => selectedConnections.value.map(connectionKey).join("\0"), () => selectedDatabases.value.map(databaseKey).join("\0")], scheduleHistorySearch);
watch([searchText, mcpToolName, activeSource, activeFilter, () => dateRange.value.startDate, () => dateRange.value.endDate, () => selectedConnections.value.map(connectionKey).join("\0"), () => selectedDatabases.value.map(databaseKey).join("\0")], scheduleHistorySearch);
onMounted(() => {
store.setHistoryPanelActive(true);
@@ -529,9 +583,16 @@ onBeforeUnmount(() => {
<span class="text-xs font-medium">{{ t("history.title") }}</span>
<span v-if="store.total > 0" class="text-[10px] text-muted-foreground">{{ store.total }}</span>
<span class="flex-1" />
<Button v-if="store.total > 0 || store.connectionOptions.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
<Button v-if="canClearHistory" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
<Trash2 class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="store.loading" :title="t('history.refresh')" :aria-label="t('history.refresh')" @click="refreshHistory">
<RefreshCw class="h-3 w-3" :class="{ 'animate-spin': store.loading }" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t(maximized ? 'diff.restore' : 'diff.maximize')" :aria-label="t(maximized ? 'diff.restore' : 'diff.maximize')" :aria-pressed="!!maximized" @click="emit('toggleMaximize')">
<Minimize2 v-if="maximized" class="h-3 w-3" />
<Maximize2 v-else class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" :aria-label="t('common.close')" :disabled="resolvingTransaction || (isRollingBack && !!pendingRollback)" @click="closeHistory">
<X class="h-3 w-3" />
</Button>
@@ -544,14 +605,42 @@ onBeforeUnmount(() => {
</div>
<div class="border-b shrink-0">
<div class="flex gap-1 px-2 pt-2">
<button
v-for="source in ['sql', 'mcp'] as HistorySource[]"
:key="source"
type="button"
class="h-7 rounded border px-3 text-xs"
:class="activeSource === source ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'"
@click="
activeSource = source;
activeFilter = 'all';
"
>
{{ t(`history.sources.${source}`) }}
</button>
</div>
<div ref="filterScrollRef" class="history-filter-scroll flex gap-1 px-2 pt-2" :class="{ 'history-filter-scroll--scrollable': filtersScrollable }">
<button v-for="filter in filters" :key="filter" type="button" class="h-6 shrink-0 rounded border px-2 text-xs" :class="activeFilter === filter ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'" @click="activeFilter = filter">
{{ filterLabel(filter) }}
</button>
</div>
<div class="relative flex items-center px-2 py-1">
<Search class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
<input data-history-search v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground" :placeholder="t('history.search')" />
<select v-if="activeSource === 'mcp'" data-mcp-tool-search v-model="mcpToolName" class="mr-1 h-5 max-w-52 rounded border bg-background px-1 text-xs outline-none" :title="t('history.mcp.toolSearch')">
<option value="">{{ t("history.mcp.allTools") }}</option>
<option v-for="tool in mcpQuickTools" :key="tool" :value="tool">{{ tool }}</option>
</select>
<Search v-if="activeSource !== 'mcp'" class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
<input
data-history-search
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="flex-1 h-5 text-xs bg-transparent border rounded pr-1 outline-none placeholder:text-muted-foreground"
:class="activeSource === 'mcp' ? 'pl-1' : 'pl-5'"
:placeholder="t('history.search')"
/>
<Popover :open="dateRangeOpen" @update:open="setDateRangeOpen">
<PopoverTrigger as-child>
<button
@@ -687,28 +776,30 @@ onBeforeUnmount(() => {
<RecycleScroller v-if="shouldVirtualizeHistory(store.entries.length)" class="min-h-0 flex-1" :items="store.entries" :item-size="HISTORY_ROW_HEIGHT" :buffer="HISTORY_SCROLL_BUFFER" :skip-hover="true" key-field="id">
<template #default="{ item: entry }">
<CustomContextMenu :items="getHistoryMenuItems(entry)" v-slot="{ onContextMenu, isOpen }">
<div class="h-[72px] cursor-pointer select-none border-b border-border/50 px-3 py-2 text-xs" :class="isOpen || selectedEntry?.id === entry.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'" @click="selectedEntry = entry" @contextmenu="onContextMenu">
<div class="mb-0.5 flex items-center gap-1">
<span class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground">
{{ kindShortLabel(entry) }}
</span>
<span class="truncate font-medium">{{ entryTitle(entry) }}</span>
<span v-if="historyEntrySource(entry)" class="inline-flex h-5 shrink-0 items-center rounded border border-primary/30 bg-primary/5 px-1 text-[10px] font-medium text-primary">
{{ historyEntrySource(entry) }}
</span>
<span class="ml-auto shrink-0 text-muted-foreground">{{ formatTime(entry.executed_at) }}</span>
</div>
<div class="truncate font-mono text-muted-foreground">{{ entrySubtitle(entry) }}</div>
<div class="mt-0.5 flex items-center gap-2">
<span class="inline-flex min-w-0 items-center gap-1 text-muted-foreground">
<Database class="h-3 w-3 shrink-0" />
<span class="truncate">
{{ entry.connection_name }}<template v-if="entry.database"> / {{ entry.database }}</template>
<div class="h-[80px] cursor-pointer select-none border-b border-border/50 px-2 py-1.5 text-xs" :class="isOpen || selectedEntry?.id === entry.id ? 'bg-accent text-accent-foreground' : ''" @click="selectedEntry = entry" @contextmenu="onContextMenu">
<div class="h-full rounded-md border bg-card px-2 py-1.5 shadow-sm transition-colors hover:border-primary/30 hover:bg-accent/40">
<div class="mb-0.5 flex items-center gap-1">
<span class="inline-flex h-5 w-9 shrink-0 items-center justify-center overflow-hidden rounded border px-1 text-[10px] leading-none text-muted-foreground">
{{ isMcpEntry(entry) ? "MCP" : kindShortLabel(entry) }}
</span>
</span>
<span class="ml-auto shrink-0" :class="entry.success ? 'text-green-500' : 'text-red-500'">
{{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
</span>
<span class="truncate font-medium">{{ entryTitle(entry) }}</span>
<span v-if="historyEntrySource(entry) && !isMcpEntry(entry)" class="inline-flex h-5 shrink-0 items-center rounded border border-primary/30 bg-primary/5 px-1 text-[10px] font-medium text-primary">
{{ historyEntrySource(entry) }}
</span>
<span class="ml-auto shrink-0 text-muted-foreground">{{ formatTime(entry.executed_at) }}</span>
</div>
<div class="truncate font-mono text-muted-foreground">{{ entrySubtitle(entry) }}</div>
<div class="mt-0.5 flex items-center gap-2">
<span class="inline-flex min-w-0 items-center gap-1 text-muted-foreground">
<Database class="h-3 w-3 shrink-0" />
<span class="truncate">
{{ entry.connection_name || entry.target || t("history.scope.unknownConnection") }}<template v-if="entry.database"> / {{ entry.database }}</template>
</span>
</span>
<span class="ml-auto shrink-0" :class="entry.success ? 'text-green-500' : 'text-red-500'">
{{ entry.success ? formatQueryDuration(entry.execution_time_ms) : t("history.failed") }}
</span>
</div>
</div>
</div>
</CustomContextMenu>
@@ -743,7 +834,24 @@ onBeforeUnmount(() => {
<div class="min-w-0 break-words">{{ value }}</div>
</template>
</div>
<div>
<div v-if="selectedEntry.source === 'mcp' || historyEntrySource(selectedEntry) === 'MCP'" class="space-y-2">
<div class="mb-1 flex items-center justify-between">
<div class="text-sm font-medium">{{ t("history.mcp.request") }}</div>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('history.copy')" :aria-label="t('history.copy')" @click="copyText(selectedEntry.mcp_request_json || selectedEntry.sql || selectedEntry.details_json || '-')">
<Copy class="h-3.5 w-3.5" />
</Button>
</div>
<pre class="max-h-64 overflow-auto rounded border bg-muted/30 p-3 text-xs">{{ formatHistoryJson(selectedEntry.mcp_request_json || selectedEntry.sql || selectedEntry.details_json) }}</pre>
<div class="mb-1 flex items-center justify-between">
<div class="text-sm font-medium">{{ t("history.mcp.response") }}</div>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('history.copy')" :aria-label="t('history.copy')" @click="copyText(selectedEntry.mcp_response_json || '-')">
<Copy class="h-3.5 w-3.5" />
</Button>
</div>
<p v-if="isLegacyResponse(selectedEntry.mcp_response_json)" class="text-xs text-muted-foreground">{{ t("history.mcp.legacyResponse") }}</p>
<pre class="max-h-[min(50vh,32rem)] overflow-auto rounded border bg-muted/30 p-3 text-xs">{{ formatHistoryJson(selectedEntry.mcp_response_json) }}</pre>
</div>
<div v-else>
<div class="mb-1 flex items-center justify-between">
<div class="text-sm font-medium">SQL</div>
<Button variant="ghost" size="sm" class="h-7" @click="copyText(selectedEntry.sql)">
@@ -765,11 +873,11 @@ onBeforeUnmount(() => {
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="selectedEntry && emit('analyzeAi', selectedEntry)">
<Button v-if="selectedEntry && selectedEntry.source !== 'mcp' && historyEntrySource(selectedEntry) !== 'MCP'" variant="outline" @click="selectedEntry && emit('analyzeAi', selectedEntry)">
<Sparkles class="h-4 w-4" />
{{ t("history.analyzeWithAi") }}
</Button>
<Button variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
<Button v-if="selectedEntry && selectedEntry.source !== 'mcp' && historyEntrySource(selectedEntry) !== 'MCP'" variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
<label v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry) && canUseManualRollback" class="flex items-center gap-2 text-xs" :title="t('history.manualRollbackHint')">
<input v-model="manualRollback" type="checkbox" :disabled="rollbackLocked" />
{{ t("toolbar.manualTransaction") }}
@@ -800,7 +908,7 @@ onBeforeUnmount(() => {
<DialogHeader>
<DialogTitle>{{ t("history.clear") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">{{ t("history.confirmClear") }}</p>
<p class="text-sm text-muted-foreground">{{ t("history.confirmClearSource", { source: t(`history.sources.${activeSource}`) }) }}</p>
<DialogFooter>
<Button variant="outline" @click="showClearConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" @click="executeClear">{{ t("dangerDialog.confirm") }}</Button>
@@ -150,7 +150,7 @@ async function copyResolvedSql() {
<template>
<Dialog v-model:open="open">
<DialogContent class="max-h-[86vh] border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[720px]">
<DialogContent class="max-h-[86vh] border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[720px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Braces class="h-5 w-5 text-primary" />
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useTheme } from "@/composables/useTheme";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import { copyToClipboard, isPlainClipboardShortcut } from "@/lib/common/clipboard";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { createShikiSqlHighlighter, type SqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -47,6 +47,26 @@ const displaySql = computed(() => {
const hasSql = computed(() => props.sql.trim().length > 0);
const contentRef = ref<HTMLPreElement | null>(null);
function selectPreviewContent() {
const el = contentRef.value;
if (!el) return false;
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
return true;
}
function onContentKeydown(event: KeyboardEvent) {
if (!isPlainClipboardShortcut(event, "a")) return;
// Claim the shortcut before it reaches the surrounding data grid, then select
// the preview text instead of the grid cells.
if (selectPreviewContent()) event.preventDefault();
}
async function initHighlighter() {
if (highlighter) return;
try {
@@ -193,10 +213,10 @@ onMounted(() => {
</div>
<!-- Shiki highlighted SQL -->
<pre v-else-if="highlightedHtml" data-native-clipboard class="m-0 p-3 text-xs font-mono leading-relaxed whitespace-pre-wrap break-words select-text" v-html="highlightedHtml"></pre>
<pre v-else-if="highlightedHtml" ref="contentRef" data-native-clipboard tabindex="0" class="m-0 p-3 text-xs font-mono leading-relaxed whitespace-pre-wrap break-words select-text outline-none" v-html="highlightedHtml" @keydown="onContentKeydown"></pre>
<!-- Plain text fallback -->
<pre v-else data-native-clipboard class="p-3 text-xs font-mono whitespace-pre-wrap select-text">{{ displaySql }}</pre>
<pre v-else ref="contentRef" data-native-clipboard tabindex="0" class="p-3 text-xs font-mono whitespace-pre-wrap select-text outline-none" @keydown="onContentKeydown">{{ displaySql }}</pre>
</div>
</div>
</template>
@@ -31,6 +31,34 @@ function bodyOf(fnSignature: string): string {
}
describe("AI conversation owns its connection binding (#9902)", () => {
it("renders plugin recommendation chips and sends the selected snapshot immediately", () => {
expect(source).toContain("pluginRecommendations?: PluginAiRecommendationHostUpdate");
expect(source).toContain('v-for="recommendation in pluginRecommendations.items"');
expect(source).toContain('@click="sendPluginRecommendation(recommendation)"');
expect(source).toContain("`history:${recommendation.id}`");
const recommendationStart = source.indexOf("function sendPluginRecommendation(");
expect(source.slice(recommendationStart, recommendationStart + 900)).toContain("send: true");
expect(source.slice(recommendationStart, recommendationStart + 900)).toContain("context: update.context");
});
it("keeps the connection selector available after a plugin recommendation is selected", () => {
const contextStart = source.lastIndexOf("data-ai-composer-context-row");
const contextEnd = source.indexOf("data-ai-composer-actions", contextStart);
const contextMarkup = source.slice(contextStart, contextEnd);
expect(contextMarkup).toContain('v-if="pluginContext && !connectionStore.connections.length"');
expect(contextMarkup).toContain("data-ai-plugin-context");
expect(contextMarkup).toContain('v-if="connectionStore.connections.length"');
expect(contextMarkup).toContain("<ConnectionTreeSelect");
expect(contextMarkup).toContain('@update:model-value="(v) => changeConnection(v)"');
});
it("does not expose Agent mode for a plugin conversation without a connection", () => {
expect(source).toContain("function canUsePluginAgentMode");
expect(source).toContain('if (mode === "agent" && !canUsePluginAgentMode()) return;');
expect(source).toContain('request.mode === "agent" && !pluginConnection ? "ask"');
});
it("rebinding the conversation never rewrites the editor tab or the global active connection", () => {
const body = bodyOf("async function changeConnection(connectionId: string)");
@@ -0,0 +1,158 @@
// @vitest-environment happy-dom
//
// Mounted smoke test for the AI panel. The app loads the AI config at startup,
// so the panel normally mounts with `isAiConfigLoaded` already true and its
// `immediate` default-selection watcher runs during setup. That path once read
// `boundConnection` before it was declared, and the TDZ ReferenceError kept the
// panel from opening at all.
import { createApp, h } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPinia } from "pinia";
import i18n from "@/i18n";
import { TooltipProvider } from "@/components/ui/tooltip";
import AiAssistant from "@/components/editor/AiAssistant.vue";
import { useSettingsStore } from "@/stores/settingsStore";
import { useConnectionStore } from "@/stores/connectionStore";
import type { ConnectionConfig } from "@/types/database";
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
vi.mock("@/lib/backend/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const empty = () => Promise.resolve([]);
return {
...actual,
loadAiConversations: empty,
loadAiRuns: empty,
readUserSkills: empty,
loadAiConfigs: empty,
listPlugins: empty,
loadPromptTemplates: empty,
getAiGlobalCustomInstructions: () => Promise.resolve(""),
saveAiChatSelection: () => Promise.resolve(),
loadAiChatSelection: () => Promise.resolve(null),
};
});
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length) cleanups.pop()?.();
});
async function mountPanel(aiConfigLoaded: boolean, connection?: ConnectionConfig, configureSettings?: (settings: ReturnType<typeof useSettingsStore>) => void, pluginRecommendations?: PluginAiRecommendationHostUpdate) {
const pinia = createPinia();
const errors: unknown[] = [];
const app = createApp({ render: () => h(TooltipProvider, () => h(AiAssistant, { connection, pluginRecommendations })) });
app.use(pinia);
app.use(i18n);
app.config.errorHandler = (error) => errors.push(error);
app.config.warnHandler = () => {};
const settings = useSettingsStore(pinia);
settings.isAiConfigLoaded = aiConfigLoaded;
configureSettings?.(settings);
if (connection) useConnectionStore(pinia).connections = [connection];
const container = document.createElement("div");
document.body.append(container);
app.mount(container);
cleanups.push(() => {
app.unmount();
container.remove();
});
// Let mount-time loads settle so their failures surface here too.
await new Promise((resolve) => setTimeout(resolve, 20));
return { errors, container };
}
describe("AiAssistant mount", () => {
it("keeps the real Agent mode and an interactive picker after clicking a plugin recommendation", async () => {
const { container, errors } = await mountPanel(
true,
{ id: "plugin-connection", name: "Sample", db_type: "plugin", plugin_id: "sample.plugin", host: "localhost", port: 22, username: "", password: "" },
(settings) => {
settings.defaultAiMode = "agent";
},
{
pluginId: "sample.plugin",
pluginName: "Sample",
contributionId: "workbench",
workbenchId: "cluster",
context: { connectionId: "plugin-connection" },
items: [{ id: "overview", label: "Inspect cluster", prompt: "Inspect cluster" }],
},
);
expect(container.querySelector(".ai-mode-action-trigger")?.textContent).toContain(i18n.global.t("ai.modes.agent"));
container.querySelector<HTMLButtonElement>('button[title="Inspect cluster"]')!.click();
await new Promise((resolve) => setTimeout(resolve, 20));
const trigger = container.querySelector<HTMLButtonElement>(".ai-mode-action-trigger");
expect(trigger).not.toBeNull();
expect(trigger?.textContent).toContain(i18n.global.t("ai.modes.agent"));
expect(container.querySelector(".ai-mode-static-trigger")).toBeNull();
trigger!.click();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
const askButton = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')).find((button) => button.textContent?.trim() === i18n.global.t("ai.modes.ask"));
expect(askButton).toBeDefined();
askButton!.click();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(trigger?.textContent).toContain(i18n.global.t("ai.modes.ask"));
expect(errors.map(String)).toEqual([]);
});
it("opens when the AI config was loaded before the panel mounted", async () => {
expect((await mountPanel(true)).errors.map(String)).toEqual([]);
});
it("opens while the AI config is still loading", async () => {
expect((await mountPanel(false)).errors.map(String)).toEqual([]);
});
it("renders compactable composer controls with accessible labels", async () => {
const { errors, container } = await mountPanel(true, undefined, (settings) => {
settings.aiConfigs = [
{
id: "deepseek-default",
name: "DeepSeek",
provider: "deepseek",
apiKey: "test-key",
authMethod: "api-key",
endpoint: "https://api.deepseek.com",
model: "deepseek-chat",
apiStyle: "completions",
isDefault: true,
},
];
settings.activeModel = { configId: "deepseek-default", modelId: "deepseek-chat" };
});
expect(errors.map(String)).toEqual([]);
expect(container.querySelector(".ai-prompt-context-container")).not.toBeNull();
expect(container.querySelector("[data-ai-composer-actions]")).not.toBeNull();
expect(container.querySelector<HTMLButtonElement>(".ai-template-selector-trigger")?.getAttribute("aria-label")).toBeTruthy();
expect(container.querySelector<HTMLButtonElement>(".ai-skills-selector-trigger")?.getAttribute("aria-label")).toBe(i18n.global.t("ai.skillsEntry"));
const modeTrigger = container.querySelector<HTMLButtonElement>(".ai-mode-action-trigger");
expect(modeTrigger?.getAttribute("aria-label")).toBeTruthy();
expect(modeTrigger?.getAttribute("title")).toBe(modeTrigger?.getAttribute("aria-label"));
const modelTrigger = container.querySelector<HTMLButtonElement>(".ai-model-selector-trigger");
expect(modelTrigger?.getAttribute("aria-label")).toBe("deepseek-chat");
expect(modelTrigger?.getAttribute("title")).toBe("deepseek-chat");
});
it.each(["plugin", "etcd"] as const)("hides database and schema selectors for %s connections", async (dbType) => {
const { errors, container } = await mountPanel(true, { id: "connection", name: "Connection", db_type: dbType, plugin_id: dbType === "plugin" ? "sample.plugin" : undefined, host: "localhost", port: 22, username: "", password: "" });
expect(errors.map(String)).toEqual([]);
const row = container.querySelector("[data-ai-composer-context-row]");
expect(row?.textContent).toContain("Connection");
expect(row?.textContent).not.toContain(i18n.global.t("editor.selectDatabase"));
expect(row?.classList.contains("ai-prompt-context-row--schema")).toBe(false);
});
it("keeps database and schema selectors for PostgreSQL", async () => {
const { errors, container } = await mountPanel(true, { id: "postgres", name: "PostgreSQL", db_type: "postgres", host: "localhost", port: 5432, username: "", password: "" });
expect(errors.map(String)).toEqual([]);
const row = container.querySelector("[data-ai-composer-context-row]");
expect(row?.textContent).toContain(i18n.global.t("editor.selectDatabase"));
expect(row?.classList.contains("ai-prompt-context-row--schema")).toBe(true);
});
});
@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { createApp, nextTick, reactive } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import AiToolApprovalCard from "../AiToolApprovalCard.vue";
import type { AiAgentStepApproval } from "@/lib/ai/aiAgentStepPresentation";
const cleanups: Array<() => void> = [];
function mountCard() {
const approval = reactive<AiAgentStepApproval>({ approvalId: "approval", sessionId: "session", pluginName: "Plugin", pluginTool: "write", connectionName: "Connection", args: { target: "orders" }, expiresAtMs: Date.now() + 1000, status: "pending" });
const onResolve = vi.fn();
const container = document.createElement("div");
document.body.append(container);
const app = createApp(AiToolApprovalCard, { approval, onResolve });
app.use(i18n);
app.mount(container);
cleanups.push(() => {
app.unmount();
container.remove();
});
return { approval, onResolve, container };
}
afterEach(() => {
while (cleanups.length) cleanups.pop()?.();
vi.useRealTimers();
});
describe("AiToolApprovalCard", () => {
it("expires without another backend event and removes its answer buttons", async () => {
vi.useFakeTimers();
const { container, onResolve } = mountCard();
expect(container.querySelectorAll("button")).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1001);
await nextTick();
expect(container.querySelectorAll("button")).toHaveLength(0);
expect(container.textContent).toContain(i18n.global.t("ai.toolApproval.status.expired"));
expect(onResolve).not.toHaveBeenCalled();
});
it("forwards one-time approval and disables both buttons while submitting", async () => {
const { container, approval, onResolve } = mountCard();
container.querySelector("button")?.click();
expect(onResolve).toHaveBeenCalledWith(true);
approval.status = "submitting";
await nextTick();
expect([...container.querySelectorAll("button")].every((button) => button.disabled)).toBe(true);
approval.status = "denied";
await nextTick();
expect(container.querySelectorAll("button")).toHaveLength(0);
expect(container.textContent).toContain(i18n.global.t("ai.toolApproval.status.denied"));
});
});
@@ -0,0 +1,36 @@
// Guards the settings-search "reveal" contract for the debug logging switch
// (#10308): searching 日志 and picking the result must land on the tab that
// actually renders the switch, because the dialog switches tabs from the search
// entry's category. The entry used to declare "appearance" while the control
// lives in the About section, so the highlight never appeared. Checked against
// the real dialog source instead of mounting the multi-thousand-line dialog.
import { describe, expect, it } from "vitest";
import { SETTINGS_SEARCH_DEFINITIONS } from "@/lib/settings/settingsSearch";
import dialogSource from "../EditorSettingsDialog.vue?raw";
const DEBUG_LOGGING_SWITCH_ID = 'id="debug-logging-enabled"';
function tabRenderingSnippet(snippet: string, marker: string): string | null {
const sectionPattern = /activeSettingsTab === '([a-z-]+)'/g;
const sections: { tab: string; index: number }[] = [];
let match: RegExpExecArray | null;
while ((match = sectionPattern.exec(snippet))) sections.push({ tab: match[1], index: match.index });
const markerIndex = snippet.indexOf(marker);
if (markerIndex < 0) return null;
let owningTab: string | null = null;
for (const section of sections) {
if (section.index < markerIndex) owningTab = section.tab;
}
return owningTab;
}
describe("EditorSettingsDialog settings search targets", () => {
it("renders the debug logging switch in the tab its search entry points at", () => {
const owningTab = tabRenderingSnippet(dialogSource, DEBUG_LOGGING_SWITCH_ID);
expect(owningTab).toBe("about");
const entry = SETTINGS_SEARCH_DEFINITIONS.find((definition) => definition.id === "about-debug-logs");
expect(entry?.category).toBe(owningTab);
expect(dialogSource).toContain(`data-settings-search-id="${entry?.targetId}"`);
});
});
@@ -111,6 +111,12 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
hoisted.saves.push(limit);
hoisted.limit = limit;
},
loadMcpHistoryRetentionLimit: async () => hoisted.limit,
saveMcpHistoryRetentionLimit: async (limit: number) => {
hoisted.saves.push(limit);
hoisted.limit = limit;
},
cleanupMcpHistoryRetention: async () => 0,
};
});
@@ -0,0 +1,140 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, ref, shallowRef, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { language } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH } from "@/lib/editor/queryEditorLargeDocument";
import type { QueryEditorProps } from "../queryEditorTypes";
const analysisProbe = vi.hoisted(() => ({
documentLengths: [] as number[],
failAtLength: Number.POSITIVE_INFINITY,
}));
vi.mock("@/lib/sql/executableStatementRangeCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/sql/executableStatementRangeCache")>();
return {
...actual,
executableStatementRangeCacheForDoc: (...args: Parameters<typeof actual.executableStatementRangeCacheForDoc>) => {
analysisProbe.documentLengths.push(args[1].length);
if (args[1].length >= analysisProbe.failAtLength) throw new Error("unbounded statement analysis");
return actual.executableStatementRangeCacheForDoc(...args);
},
};
});
import QueryEditor from "../QueryEditor.vue";
function oraclePackage(procedureCount: number): string {
const procedures = Array.from(
{ length: procedureCount },
(_, index) => ` PROCEDURE process_order_${index}(p_order_id NUMBER) IS
l_status VARCHAR2(30);
BEGIN
SELECT status INTO l_status FROM orders WHERE id = p_order_id;
IF l_status = 'PENDING' THEN
UPDATE orders SET status = 'PROCESSED' WHERE id = p_order_id;
END IF;
EXCEPTION
WHEN NO_DATA_FOUND THEN NULL;
END process_order_${index};
`,
).join("");
return `CREATE OR REPLACE PACKAGE BODY huge_pkg AS\n${procedures}END huge_pkg;\n/\n`;
}
const cleanups: Array<() => void> = [];
beforeEach(() => {
analysisProbe.documentLengths = [];
analysisProbe.failAtLength = Number.POSITIVE_INFINITY;
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
});
async function mountEditor(modelValue: string, overrides: Partial<QueryEditorProps> = {}) {
const pinia = createPinia();
setActivePinia(pinia);
const host = document.createElement("div");
document.body.append(host);
const editor = shallowRef<InstanceType<typeof QueryEditor>>();
const source = ref(modelValue);
const onUpdateModelValue = vi.fn((value: string) => {
source.value = value;
});
const app: App = createApp({
render: () =>
h(QueryEditor, {
modelValue: source.value,
tabId: "oracle-source",
databaseType: "oracle",
dialect: "postgres",
autoFocus: false,
ref: editor,
"onUpdate:modelValue": onUpdateModelValue,
...overrides,
}),
});
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanups.push(() => {
app.unmount();
host.remove();
});
await vi.waitFor(() => expect(host.querySelector(".cm-editor")).not.toBeNull(), { timeout: 5000 });
const view = EditorView.findFromDOM(host.querySelector(".cm-editor") as HTMLElement)!;
return { editor: editor.value!, host, onUpdateModelValue, view };
}
describe("QueryEditor large document mode", () => {
it("keeps the full editor feature path for ordinary documents", async () => {
const source = oraclePackage(8);
const { host, view } = await mountEditor(source);
expect(host.firstElementChild?.hasAttribute("data-large-document-mode")).toBe(false);
expect(analysisProbe.documentLengths).toContain(source.length);
expect(view.state.facet(language)).not.toBeNull();
expect(view.state.doc.toString()).toBe(source);
});
it("keeps a realistically large package editable and searchable without full-document analysis", async () => {
const source = oraclePackage(4_000);
expect(source.length).toBeGreaterThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH);
analysisProbe.failAtLength = QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH + 1;
const { editor, host, onUpdateModelValue, view } = await mountEditor(source);
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
expect(analysisProbe.documentLengths).not.toContain(source.length);
expect(view.state.facet(language)).toBeNull();
expect(view.state.doc.toString()).toBe(source);
expect(view.state.readOnly).toBe(false);
expect(view.state.sliceDoc(0, 24)).toBe(source.slice(0, 24));
expect(editor.openSearch()).toBe(true);
await nextTick();
expect(host.querySelector(".editor-search-panel input")).not.toBeNull();
view.dispatch({ changes: { from: source.length, insert: "-- edited\n" }, userEvent: "input" });
await nextTick();
expect(onUpdateModelValue).toHaveBeenLastCalledWith(`${source}-- edited\n`);
});
it("preserves read-only state when full-document analysis would fail", async () => {
const source = oraclePackage(4_000);
analysisProbe.failAtLength = QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH + 1;
const { host, view } = await mountEditor(source, { readOnly: true });
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
expect(analysisProbe.documentLengths).not.toContain(source.length);
expect(view.state.doc.toString()).toBe(source);
expect(view.state.readOnly).toBe(true);
expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
});
});
@@ -5,6 +5,7 @@ import QueryHistory from "../QueryHistory.vue";
const mocks = vi.hoisted(() => ({
add: vi.fn(),
search: vi.fn(),
beginManualTransaction: vi.fn(),
executeInManualTransaction: vi.fn(),
commitManualTransaction: vi.fn(),
@@ -40,7 +41,7 @@ vi.mock("@/stores/historyStore", () => ({
nextCursor: null,
setHistoryPanelActive: vi.fn(),
loadConnectionOptions: vi.fn().mockResolvedValue(undefined),
search: vi.fn().mockResolvedValue(undefined),
search: mocks.search,
add: mocks.add,
}),
}));
@@ -229,3 +230,22 @@ describe("QueryHistory manual rollback SQL", () => {
expect(mocks.beginManualTransaction).not.toHaveBeenCalled();
});
});
describe("QueryHistory source filters", () => {
it("searches MCP independently and resets success when switching back to SQL", async () => {
const root = document.createElement("div");
document.body.append(root);
app = createApp(QueryHistory);
app.mount(root);
await nextTick();
button("history.sources.mcp").click();
await vi.waitFor(() => expect(mocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ source: "mcp", activity_kind: undefined, success: undefined })));
expect(document.body.textContent).not.toContain("history.filters.schema_change");
button("history.filters.mcp_success").click();
await vi.waitFor(() => expect(mocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ source: "mcp", success: true, activity_kind: undefined })));
button("history.sources.sql").click();
await vi.waitFor(() => expect(mocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ source: "sql", success: undefined, activity_kind: undefined })));
expect(document.body.textContent).toContain("history.filters.schema_change");
expect(document.body.textContent).not.toContain("history.sources.all");
});
});
@@ -193,6 +193,17 @@ describe("SqlParameterDialog raw parameter action", () => {
});
});
describe("SqlParameterDialog surface", () => {
it("keeps the dialog background opaque while a wallpaper is active", async () => {
await mountDialog();
const content = document.body.querySelector('[data-slot="dialog-content"]');
expect(content).not.toBeNull();
const classes = content!.className;
expect(classes).toContain("bg-background-solid");
expect(classes).not.toMatch(/(^|\s)!bg-background(\s|$)/);
});
});
describe("SqlParameterDialog ignore parameter action", () => {
it("closes the dialog and emits the original SQL without remembering or mutating values", async () => {
const { state, onExecute } = await mountDialog();
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
highlight: vi.fn<(content: string) => string>(() => ""),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { sqlFormatter: {} } }) }));
vi.mock("@/lib/sql/sqlHighlighter", () => ({
createShikiSqlHighlighter: async () => (content: string) => mocks.highlight(content),
}));
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h("button", attrs, slots.default?.());
},
}),
};
});
vi.mock("@/components/ui/tooltip", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = () =>
defineComponent({
setup:
(_, { slots }) =>
() =>
h("div", slots.default?.()),
});
return { Tooltip: passthrough(), TooltipTrigger: passthrough(), TooltipContent: passthrough() };
});
import SqlPreviewPanel from "@/components/editor/SqlPreviewPanel.vue";
const SQL = "UPDATE users SET name = 'Ada' WHERE id = 1;";
let app: App<Element> | null = null;
let host: HTMLDivElement | null = null;
async function flushUi() {
for (let index = 0; index < 4; index += 1) {
await Promise.resolve();
await nextTick();
}
}
async function mountPanel(sql = SQL) {
host = document.createElement("div");
document.body.append(host);
app = createApp(SqlPreviewPanel, { sql });
app.mount(host);
await flushUi();
return host;
}
function previewText(host: HTMLElement): HTMLPreElement {
const pre = host.querySelector("pre");
if (!pre) throw new Error("SQL preview text not found");
return pre;
}
function dispatchSelectAll(target: HTMLElement, modifiers: KeyboardEventInit = { ctrlKey: true }) {
const event = new KeyboardEvent("keydown", { key: "a", bubbles: true, cancelable: true, ...modifiers });
target.dispatchEvent(event);
return event;
}
beforeEach(() => {
mocks.highlight.mockImplementation(() => "");
});
afterEach(() => {
window.getSelection()?.removeAllRanges();
app?.unmount();
app = null;
host?.remove();
host = null;
mocks.highlight.mockClear();
});
describe("SqlPreviewPanel select all", () => {
it("selects the preview text on Ctrl+A instead of leaving the shortcut to the data grid", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
expect(pre.getAttribute("data-native-clipboard")).toBe("");
expect(pre.tabIndex).toBe(0);
pre.focus();
expect(document.activeElement).toBe(pre);
const event = dispatchSelectAll(pre);
const selection = window.getSelection();
expect(event.defaultPrevented).toBe(true);
expect(selection?.rangeCount).toBe(1);
expect(selection?.toString()).toBe(SQL);
expect(selection?.anchorNode && pre.contains(selection.anchorNode)).toBe(true);
});
it("supports Cmd+A and selects the highlighted SQL", async () => {
mocks.highlight.mockImplementation((content) => `<span class="tok">${content}</span>`);
const mounted = await mountPanel();
const pre = previewText(mounted);
expect(pre.innerHTML).toContain('class="tok"');
const event = dispatchSelectAll(pre, { metaKey: true });
const selection = window.getSelection();
expect(event.defaultPrevented).toBe(true);
expect(selection?.rangeCount).toBe(1);
expect(selection?.toString()).toBe(SQL);
});
it("keeps the shortcut untouched for other modifiers and keys", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
expect(dispatchSelectAll(pre, { ctrlKey: true, shiftKey: true }).defaultPrevented).toBe(false);
expect(dispatchSelectAll(pre, { ctrlKey: true, altKey: true }).defaultPrevented).toBe(false);
expect(dispatchSelectAll(pre, { ctrlKey: false }).defaultPrevented).toBe(false);
const other = new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true, ctrlKey: true });
pre.dispatchEvent(other);
expect(other.defaultPrevented).toBe(false);
expect(window.getSelection()?.rangeCount ?? 0).toBe(0);
});
it("lets the event keep bubbling so the surrounding grid can observe it", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
const bubbled = vi.fn();
mounted.addEventListener("keydown", bubbled);
dispatchSelectAll(pre);
expect(bubbled).toHaveBeenCalledOnce();
});
});
@@ -91,8 +91,23 @@ describe("QueryEditor batch selection ownership", () => {
batch.toggleAllBatchColumnSelection(currentView, session.key);
expect(next.selectedKeys.size).toBe(0);
expect(next.completionOptions.size).toBe(0);
batch.prepareBatchColumnSelectionSession([], next.document, next.from, next.to);
expect(batch.expandedRendering).toBe(false);
});
it("keeps the checked fields when a sibling result carries no column candidates (dbx#9973)", () => {
const { batch, session, items, currentView, row } = createHarness();
batch.toggleAllBatchColumnSelection(currentView, session.key);
// One completion request builds several result sets and only some of them
// carry column candidates. The empty one must not drop the session that the
// rendered checkbox rows still point at.
expect(batch.prepareBatchColumnSelectionSession([], session.document, session.from, session.to)).toBeNull();
expect(batch.expandedRendering).toBe(true);
expect(batch.prepareBatchColumnSelectionSession(items, session.document, session.from, session.to)).toBe(session);
expect(session.selectedKeys).toEqual(new Set(session.candidates.map((candidate) => candidate.key)));
const { checkbox } = row(0);
expect(checkbox.checked).toBe(true);
checkbox.dispatchEvent(new PointerEvent("pointerdown", { button: 0, buttons: 1, isPrimary: true, pointerId: 5, bubbles: true }));
expect(session.selectedKeys.has(session.candidates[0].key)).toBe(false);
document.body.style.userSelect = "";
});
it("guards label clicks and applies checked fields together, never an individual row", () => {

Some files were not shown because too many files have changed in this diff Show More