diff --git a/docker/.env.example b/docker/.env.example index d3e34c34..8b14d3a2 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -5,6 +5,8 @@ POSTGRES_PORT=5432 MYSQL_PORT=3306 REDIS_PORT=6379 MONGO_PORT=27017 +CLICKHOUSE_HTTP_PORT=8123 +CLICKHOUSE_NATIVE_PORT=9000 POSTGRES_USER=querya POSTGRES_PASSWORD=querya @@ -17,3 +19,7 @@ MYSQL_PASSWORD=querya MONGO_INITDB_ROOT_USERNAME=querya MONGO_INITDB_ROOT_PASSWORD=querya + +CLICKHOUSE_DB=querya +CLICKHOUSE_USER=querya +CLICKHOUSE_PASSWORD=querya diff --git a/docker/clickhouse/init/01_seed.sql b/docker/clickhouse/init/01_seed.sql new file mode 100644 index 00000000..24c06eec --- /dev/null +++ b/docker/clickhouse/init/01_seed.sql @@ -0,0 +1,112 @@ +-- Demo OLAP data for Querya ClickHouse extension testing. +-- Runs once on first container start via /docker-entrypoint-initdb.d + +CREATE DATABASE IF NOT EXISTS querya; + +CREATE TABLE IF NOT EXISTS querya.customers +( + id UInt32, + name String, + email String, + city LowCardinality(String), + created_at DateTime +) +ENGINE = MergeTree +ORDER BY id; + +CREATE TABLE IF NOT EXISTS querya.products +( + id UInt32, + sku String, + title String, + category LowCardinality(String), + price Decimal(10, 2) +) +ENGINE = MergeTree +ORDER BY id; + +CREATE TABLE IF NOT EXISTS querya.orders +( + id UInt64, + customer_id UInt32, + status LowCardinality(String), + total Decimal(12, 2), + placed_at DateTime +) +ENGINE = MergeTree +ORDER BY (placed_at, id); + +CREATE TABLE IF NOT EXISTS querya.order_lines +( + order_id UInt64, + product_id UInt32, + qty UInt16, + unit_price Decimal(10, 2) +) +ENGINE = MergeTree +ORDER BY (order_id, product_id); + +CREATE TABLE IF NOT EXISTS querya.events +( + event_id UUID, + event_time DateTime, + user_id UInt32, + event_type LowCardinality(String), + path String, + country LowCardinality(String), + revenue Decimal(12, 4) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(event_time) +ORDER BY (event_time, user_id); + +-- 500 customers +INSERT INTO querya.customers +SELECT + toUInt32(number + 1) AS id, + concat('Customer ', toString(number + 1)) AS name, + concat('user', toString(number + 1), '@example.com') AS email, + ['Berlin', 'London', 'Madrid', 'Paris', 'Tokyo', 'New York', 'São Paulo', 'Cairo'][number % 8 + 1] AS city, + now() - toIntervalDay(number % 365) AS created_at +FROM numbers(500); + +-- 80 products +INSERT INTO querya.products +SELECT + toUInt32(number + 1) AS id, + concat('SKU-', leftPad(toString(number + 1), 4, '0')) AS sku, + concat('Product ', toString(number + 1)) AS title, + ['Electronics', 'Books', 'Home', 'Sports', 'Fashion'][number % 5 + 1] AS category, + toDecimal64(round(4.99 + (number % 200) * 1.37, 2), 2) AS price +FROM numbers(80); + +-- 2_000 orders +INSERT INTO querya.orders +SELECT + toUInt64(number + 1) AS id, + toUInt32((number % 500) + 1) AS customer_id, + ['new', 'paid', 'shipped', 'cancelled', 'refunded'][number % 5 + 1] AS status, + toDecimal64(round(9.99 + (number % 150) * 2.41, 2), 2) AS total, + now() - toIntervalHour(number % (24 * 120)) AS placed_at +FROM numbers(2000); + +-- ~6_000 order lines (1–4 lines per order) +INSERT INTO querya.order_lines +SELECT + toUInt64((number % 2000) + 1) AS order_id, + toUInt32((number % 80) + 1) AS product_id, + toUInt16((number % 5) + 1) AS qty, + toDecimal64(round(4.99 + (number % 200) * 1.37, 2), 2) AS unit_price +FROM numbers(6000); + +-- 50_000 analytics events +INSERT INTO querya.events +SELECT + generateUUIDv4() AS event_id, + now() - toIntervalSecond(number % (86400 * 30)) AS event_time, + toUInt32((number % 500) + 1) AS user_id, + ['page_view', 'add_to_cart', 'purchase', 'search', 'login'][number % 5 + 1] AS event_type, + concat('/app/', ['home', 'catalog', 'product', 'checkout', 'account'][number % 5 + 1]) AS path, + ['DE', 'GB', 'ES', 'FR', 'JP', 'US', 'BR', 'EG'][number % 8 + 1] AS country, + if(number % 5 = 2, toDecimal64(round((number % 100) * 1.25, 4), 4), toDecimal64(0, 4)) AS revenue +FROM numbers(50000); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 508b9fc2..13d1d6f7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,8 @@ # Redis port 6379 no auth keys prefix querya:* # MongoDB port 27017 db querya user querya password querya # auth source: admin collections: users, products, orders +# ClickHouse HTTP 8123 / native 9000 db querya user querya password querya +# tables: customers, products, orders, order_lines, events # SQLite local file ./sqlite/data/querya.db # tables: users, products, orders # ───────────────────────────────────────────────────────────────────────── @@ -137,6 +139,36 @@ services: retries: 20 start_period: 30s + clickhouse: + image: clickhouse/clickhouse-server:24.8 + container_name: querya-clickhouse + restart: unless-stopped + environment: + CLICKHOUSE_DB: ${CLICKHOUSE_DB:-querya} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-querya} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-querya} + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" + ports: + - "${CLICKHOUSE_HTTP_PORT:-8123}:8123" + - "${CLICKHOUSE_NATIVE_PORT:-9000}:9000" + volumes: + - clickhouse_data:/var/lib/clickhouse + - ./clickhouse/init:/docker-entrypoint-initdb.d:ro + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + test: + [ + "CMD-SHELL", + "clickhouse-client --user $${CLICKHOUSE_USER:-querya} --password $${CLICKHOUSE_PASSWORD:-querya} --query 'SELECT 1'", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 40s + sqlite-seed: image: alpine:latest container_name: querya-sqlite-seed @@ -152,3 +184,4 @@ volumes: mysql_data: redis_data: mongo_data: + clickhouse_data: diff --git a/docs/getting-started.md b/docs/getting-started.md index 191c3c69..a89d52fd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -77,7 +77,7 @@ day-to-day usage, and [Security](security.md) for how credentials are stored. ## Local dev databases (optional) The repo includes a Docker Compose stack under [`docker/`](../docker/) with -PostgreSQL, MySQL, MongoDB, and Redis plus seed data: +PostgreSQL, MySQL, MongoDB, Redis, ClickHouse, and SQLite seed data: ```bash cp docker/.env.example docker/.env # optional overrides @@ -85,4 +85,5 @@ cd docker && docker compose up -d ``` Default credentials: user/password **`querya`**, database **`querya`** -(MongoDB auth source: **`admin`**). Stop with `docker compose down`. +(MongoDB auth source: **`admin`**; ClickHouse HTTP **`8123`**, native **`9000`**). +Stop with `docker compose down`. diff --git a/lib/app/app_shutdown.dart b/lib/app/app_shutdown.dart index d7cc802f..95481687 100644 --- a/lib/app/app_shutdown.dart +++ b/lib/app/app_shutdown.dart @@ -3,14 +3,15 @@ import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; /// Disconnects all pooled / cached client connections (PostgreSQL pool, MySQL, -/// Mongo, Redis, SQLite). Safe to call when no connections exist. +/// Mongo, Redis, SQLite, extension drivers). Safe to call when no connections exist. Future disconnectAllExternalServices() async { await PostgresService.instance.disconnectAll(); await MysqlService.instance.disconnectAll(); await MongoService.instance.disconnectAll(); await RedisService.instance.disconnectAll(); await SqliteService.instance.disconnectAll(); + await ExtensionDriverSession.instance.disconnectAll(); } - diff --git a/lib/core/actions/sql_connection_types.dart b/lib/core/actions/sql_connection_types.dart index e7c5f422..aabd0c9c 100644 --- a/lib/core/actions/sql_connection_types.dart +++ b/lib/core/actions/sql_connection_types.dart @@ -1,6 +1,10 @@ +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; const kSqlCapableConnectionTypes = {'postgresql', 'mysql', 'sqlite'}; -bool isSqlCapableConnection(ConnectionRow? connection) => - connection != null && kSqlCapableConnectionTypes.contains(connection.type); +bool isSqlCapableConnection(ConnectionRow? connection) { + if (connection == null) return false; + if (kSqlCapableConnectionTypes.contains(connection.type)) return true; + return ExtensionDriverCatalog.isExtensionDriverConnection(connection); +} diff --git a/lib/core/actions/sql_editor_global_actions.dart b/lib/core/actions/sql_editor_global_actions.dart index 39760d49..d90ae80b 100644 --- a/lib/core/actions/sql_editor_global_actions.dart +++ b/lib/core/actions/sql_editor_global_actions.dart @@ -20,7 +20,7 @@ class SqlEditorGlobalActions extends StatelessWidget { final Widget child; static const _noSqlConnectionMessage = - 'Select a PostgreSQL, MySQL, or SQLite connection to edit SQL files.'; + 'Select a SQL-capable connection (PostgreSQL, MySQL, SQLite, or an installed driver) to edit SQL files.'; @override Widget build(BuildContext context) { diff --git a/lib/core/extensions/extension_driver_catalog.dart b/lib/core/extensions/extension_driver_catalog.dart new file mode 100644 index 00000000..a240fe89 --- /dev/null +++ b/lib/core/extensions/extension_driver_catalog.dart @@ -0,0 +1,99 @@ +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; + +/// Built-in + installed extension drivers for New Connection / Driver Manager. +class ExtensionDriverCatalog { + ExtensionDriverCatalog._(); + + static const builtInChoices = [ + BuiltInConnectionType(ConnectionType.postgresql), + BuiltInConnectionType(ConnectionType.mysql), + BuiltInConnectionType(ConnectionType.sqlite), + BuiltInConnectionType(ConnectionType.redis), + BuiltInConnectionType(ConnectionType.mongodb), + ]; + + static const sqlBuiltIns = [ + BuiltInConnectionType(ConnectionType.postgresql), + BuiltInConnectionType(ConnectionType.mysql), + BuiltInConnectionType(ConnectionType.sqlite), + ]; + + static const noSqlBuiltIns = [ + BuiltInConnectionType(ConnectionType.redis), + BuiltInConnectionType(ConnectionType.mongodb), + ]; + + /// Extension drivers currently loaded in [LocalExtensionRegistry]. + static List extensionChoices([ + LocalExtensionRegistry? registry, + ]) { + final manifests = (registry ?? LocalExtensionRegistry.instance).manifests; + final out = []; + for (final manifest in manifests) { + if (manifest.type != ExtensionType.databaseDriver) continue; + for (final driver in manifest.contributedDrivers) { + if (driver.driverId.trim().isEmpty) continue; + out.add(ExtensionDriverChoice(manifest: manifest, driver: driver)); + } + } + out.sort((a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase())); + return out; + } + + /// All choices for the "All databases" category. + static List allChoices([ + LocalExtensionRegistry? registry, + ]) => + [...builtInChoices, ...extensionChoices(registry)]; + + static List sqlChoices([ + LocalExtensionRegistry? registry, + ]) => + [...sqlBuiltIns, ...extensionChoices(registry)]; + + static List noSqlChoices() => noSqlBuiltIns; + + /// True when [row] is backed by an installed extension driver package. + static bool isExtensionDriverConnection(ConnectionRow row) { + final extId = row.extensionId?.trim(); + if (extId != null && extId.isNotEmpty) return true; + return manifestForConnection(row) != null; + } + + /// Resolves the installed manifest for a saved connection row. + static ExtensionManifest? manifestForConnection( + ConnectionRow row, [ + LocalExtensionRegistry? registry, + ]) { + final reg = registry ?? LocalExtensionRegistry.instance; + final extId = row.extensionId?.trim(); + if (extId != null && extId.isNotEmpty) { + for (final manifest in reg.manifests) { + if (manifest.id == extId) return manifest; + } + } + final type = row.type.trim().toLowerCase(); + if (type.isEmpty) return null; + for (final manifest in reg.manifests) { + if (manifest.type != ExtensionType.databaseDriver) continue; + for (final driver in manifest.contributedDrivers) { + if (driver.driverId.trim().toLowerCase() == type) { + return manifest; + } + } + } + return null; + } + + /// Packaged icon path for a saved connection, when available on disk. + static String? iconFileForConnection( + ConnectionRow row, [ + LocalExtensionRegistry? registry, + ]) => + manifestForConnection(row, registry)?.resolvedIconPath; +} diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart new file mode 100644 index 00000000..ae868e8c --- /dev/null +++ b/lib/core/extensions/extension_driver_session.dart @@ -0,0 +1,427 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/extension_support.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Owns [PluginRpcBridge] sessions for extension-backed connections. +class ExtensionDriverSession { + ExtensionDriverSession._(); + static final ExtensionDriverSession instance = ExtensionDriverSession._(); + + final Map _bridges = {}; + final Map _manifests = {}; + + /// Test/DI override for bridge creation. + PluginRpcBridge Function()? bridgeFactory; + + bool isConnected(int connectionId) => + _bridges[connectionId]?.isStarted == true; + + /// Starts the plugin (if needed), injects credentials, and calls `db.connect`. + Future ensureConnected(ConnectionRow row) async { + final id = row.id; + if (id == null) { + throw StateError('ConnectionRow.id is required for extension drivers'); + } + if (!ExtensionDriverCatalog.isExtensionDriverConnection(row)) { + throw StateError( + 'Connection "${row.name}" is not backed by an installed extension driver', + ); + } + + final existing = _bridges[id]; + if (existing != null && existing.isStarted) { + return existing; + } + + final manifest = await _resolveManifestForRow(row); + final hydrated = await _hydrateSecrets(row); + final bridge = await _startBridge(manifest); + + try { + await _injectAndConnect(bridge, connectionId: id, row: hydrated); + } catch (e) { + try { + await bridge.shutdown(); + } catch (_) {} + rethrow; + } + + _bridges[id] = bridge; + _manifests[id] = manifest; + return bridge; + } + + /// One-shot connectivity check: spawns a temporary plugin process, + /// connects, and tears everything down. Returns the reported server version. + Future testConnection({ + required ExtensionManifest manifest, + required ConnectionRow row, + }) async { + // Ephemeral positive id — never stored, only used for this RPC round-trip. + final tempId = + DateTime.now().millisecondsSinceEpoch & 0x7fffffff | 0x40000000; + final bridge = await _startBridge(manifest); + try { + final result = await _injectAndConnect( + bridge, + connectionId: tempId, + row: row, + ); + String version = ''; + if (result is Map) { + version = '${result['serverVersion'] ?? ''}'; + } + try { + await bridge.sendRequest('db.disconnect', {'connectionId': tempId}); + } catch (_) {} + return version; + } finally { + try { + await bridge.shutdown(); + } catch (_) {} + } + } + + Future _startBridge(ExtensionManifest manifest) async { + final root = manifest.installPath; + if (root == null || root.isEmpty) { + throw StateError('Extension "${manifest.id}" has no install path'); + } + final main = manifest.main?.trim(); + if (main == null || main.isEmpty) { + throw StateError('Extension "${manifest.id}" is missing main entry'); + } + final executable = p.join(root, main); + final entryFile = File(executable); + if (!entryFile.existsSync()) { + throw StateError('Driver entry not found: $executable'); + } + final canExecute = await entryFile + .stat() + .then((s) => s.mode & 0x111 != 0, onError: (_) => true); + if (!canExecute) { + try { + await ExtensionSupport.markExecutableIfExists(entryFile); + } catch (e) { + throw StateError( + 'Driver entry is not executable: $executable. ' + 'Reinstall the extension package ($e).', + ); + } + } + + final bridge = bridgeFactory?.call() ?? PluginRpcBridge(); + await bridge.start( + manifest: manifest, + pluginExecutable: executable, + extensionRoot: root, + handshakeParams: { + 'queryaVersion': '2.0.0', + 'pluginId': manifest.id, + }, + ); + return bridge; + } + + Future _injectAndConnect( + PluginRpcBridge bridge, { + required int connectionId, + required ConnectionRow row, + }) async { + final options = _decodeOptions(row.driverOptions); + final safeMode = options.remove('safe_mode') ?? options.remove('safeMode'); + options.remove('sslMode'); + + await bridge.injectCredentials({ + 'connectionId': connectionId, + if (row.password != null && row.password!.isNotEmpty) + 'password': row.password, + }); + + return bridge.connect( + buildExtensionConnectParams( + connectionId: connectionId, + row: row, + options: options, + safeMode: safeMode, + ), + ); + } + + /// Builds `db.connect` params including HTTPS when [ConnectionRow.useSSL] is set. + static Map buildExtensionConnectParams({ + required int connectionId, + required ConnectionRow row, + Map options = const {}, + Object? safeMode, + }) { + final host = row.host?.trim(); + final port = row.port ?? 8123; + final database = row.databaseName?.trim().isNotEmpty == true + ? row.databaseName!.trim() + : 'default'; + + final params = { + 'connectionId': connectionId, + if (row.username != null && row.username!.isNotEmpty) 'user': row.username, + 'database': database, + ...options, + if (safeMode != null) 'safeMode': safeMode, + }; + + if (host != null && host.isNotEmpty) { + final scheme = row.useSSL ? 'https' : 'http'; + params['connectionString'] = '$scheme://$host:$port/$database'; + } else { + if (row.port != null) params['port'] = row.port; + if (host != null && host.isNotEmpty) params['host'] = host; + } + + return params; + } + + Future _hydrateSecrets(ConnectionRow row) async { + final id = row.id; + if (id == null) return row; + if ((row.password != null && row.password!.isNotEmpty) || + (row.connectionString != null && row.connectionString!.isNotEmpty)) { + return row; + } + final secrets = await ConnectionSecretsStore.readForConnection(id); + if (secrets.password == null && secrets.connectionString == null) { + return row; + } + return ConnectionRow( + id: row.id, + type: row.type, + name: row.name, + host: row.host, + port: row.port, + username: row.username, + password: secrets.password ?? row.password, + databaseName: row.databaseName, + authSource: row.authSource, + useSSL: row.useSSL, + connectionString: secrets.connectionString ?? row.connectionString, + extensionId: row.extensionId, + driverOptions: row.driverOptions, + folderId: row.folderId, + sortOrder: row.sortOrder, + createdAt: row.createdAt, + ); + } + + Future _resolveManifestForRow(ConnectionRow row) async { + await LocalExtensionRegistry.instance.load(); + final manifest = ExtensionDriverCatalog.manifestForConnection(row); + if (manifest != null) return manifest; + final extId = row.extensionId?.trim(); + if (extId != null && extId.isNotEmpty) { + throw StateError( + 'Extension "$extId" is not installed. Reinstall the package.', + ); + } + throw StateError( + 'No extension driver is installed for connection type "${row.type}".', + ); + } + + /// Executes SQL through the plugin (`db.query`) and returns the raw result. + Future query( + ConnectionRow row, + String sql, { + int? limit, + }) async { + final bridge = await ensureConnected(row); + final result = await bridge.sendRequest('db.query', { + 'connectionId': row.id, + 'sql': sql, + if (limit != null) 'limit': limit, + }); + return ExtensionQueryResult.fromRpc(result); + } + + Future getSchemaTree(ConnectionRow row) async { + final bridge = await ensureConnected(row); + final result = await bridge.sendRequest('db.getSchemaTree', { + 'connectionId': row.id, + }); + return _treeSchemaFromResult(result); + } + + Future> expandTreeNode( + ConnectionRow row, + String nodeId, + ) async { + final bridge = await ensureConnected(row); + final result = await bridge.sendRequest('db.expandTreeNode', { + 'connectionId': row.id, + 'nodeId': nodeId, + }); + return _nodesFromResult(result); + } + + Future disconnect(int connectionId) async { + final bridge = _bridges.remove(connectionId); + _manifests.remove(connectionId); + if (bridge == null) return; + try { + await bridge.sendRequest('db.disconnect', { + 'connectionId': connectionId, + }); + } catch (e) { + debugPrint('ExtensionDriverSession db.disconnect: $e'); + } + try { + await bridge.shutdown(); + } catch (e) { + debugPrint('ExtensionDriverSession shutdown: $e'); + } + } + + Future disconnectAll() async { + final ids = _bridges.keys.toList(); + for (final id in ids) { + await disconnect(id); + } + } + + Map _decodeOptions(String? raw) { + if (raw == null || raw.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return Map.from(decoded); + } + if (decoded is Map) { + return decoded.map((k, v) => MapEntry('$k', v)); + } + } catch (e) { + debugPrint('ExtensionDriverSession: bad driver_options: $e'); + } + return {}; + } + + SduiTreeSchema _treeSchemaFromResult(Object? result) { + if (result is Map) { + return SduiTreeSchema.fromJson(result); + } + if (result is Map) { + return SduiTreeSchema.fromJson(Map.from(result)); + } + if (result is List) { + return SduiTreeSchema.fromJson({'nodes': result}); + } + return const SduiTreeSchema(); + } + + List _nodesFromResult(Object? result) { + if (result is Map) { + final map = result is Map + ? result + : Map.from(result); + final schema = SduiTreeSchema.fromJson(map); + if (schema.roots.isNotEmpty) return schema.roots; + final children = map['children'] ?? map['nodes']; + if (children is List) { + return [ + for (final item in children) + if (item is Map) + SduiTreeNode.fromJson(item) + else if (item is Map) + SduiTreeNode.fromJson(Map.from(item)), + ]; + } + } + if (result is List) { + return [ + for (final item in result) + if (item is Map) + SduiTreeNode.fromJson(item) + else if (item is Map) + SduiTreeNode.fromJson(Map.from(item)), + ]; + } + return const []; + } +} + +/// Normalized tabular result of `db.query` from an extension driver. +class ExtensionQueryResult { + const ExtensionQueryResult({ + this.columns = const [], + this.rows = const [], + this.message, + this.elapsedMs, + this.queryId, + }); + + /// Column names in order. + final List columns; + + /// Row values converted to display strings (`NULL` for null). + final List> rows; + + /// Status message for non-tabular commands. + final String? message; + final int? elapsedMs; + final String? queryId; + + factory ExtensionQueryResult.fromRpc(Object? raw) { + if (raw is! Map) return const ExtensionQueryResult(); + final map = raw is Map + ? raw + : Map.from(raw); + + final columns = []; + final columnsRaw = map['columns']; + if (columnsRaw is List) { + for (final col in columnsRaw) { + if (col is Map) { + columns.add('${col['name'] ?? col['label'] ?? ''}'); + } else if (col != null) { + columns.add('$col'); + } + } + } + + final rows = >[]; + final rowsRaw = map['rows']; + if (rowsRaw is List) { + for (final row in rowsRaw) { + if (row is List) { + rows.add([ + for (final cell in row) cell == null ? 'NULL' : '$cell', + ]); + } + } + } + + int? elapsedMs; + final stats = map['statistics']; + if (stats is Map) { + final elapsed = stats['elapsedMs'] ?? stats['elapsed_ms']; + if (elapsed is num) elapsedMs = elapsed.toInt(); + } + final execTime = map['executionTimeMs']; + if (elapsedMs == null && execTime is num) elapsedMs = execTime.toInt(); + + return ExtensionQueryResult( + columns: columns, + rows: rows, + message: map['message'] as String?, + elapsedMs: elapsedMs, + queryId: map['queryId']?.toString(), + ); + } +} diff --git a/lib/core/extensions/extension_support.dart b/lib/core/extensions/extension_support.dart index 00ab95d7..cd8336c3 100644 --- a/lib/core/extensions/extension_support.dart +++ b/lib/core/extensions/extension_support.dart @@ -18,9 +18,9 @@ class ExtensionSupport { static const databaseDriverPreviewNotice = 'Database drivers in the Marketplace are preview listings only until they ' 'declare a policy-compliant OS process sandbox. ' - 'Querya connects using built-in Dart drivers (PostgreSQL, MySQL, SQLite, ' - 'Redis, MongoDB). Sandboxed external drivers install when ' - '`sandbox.engine` is `process` and passes SandboxPolicy.'; + 'Installed sandboxed drivers (`sandbox.engine: process`) appear in New ' + 'Connection after Registration. Built-in Dart drivers (PostgreSQL, MySQL, ' + 'SQLite, Redis, MongoDB) remain available without an extension.'; static const databaseDriverMissingEntryMessage = 'Driver package is missing its main entry file. Installation aborted.'; @@ -63,4 +63,51 @@ class ExtensionSupport { ); } } + + /// Ensures the driver main entry (and other files under `bin/`) are executable. + /// + /// Zip extraction does not preserve Unix mode bits; without this, sandbox + /// launch fails with exit code 1 / "Permission denied". + static Future ensureDriverExecutables({ + required ExtensionManifest manifest, + required Directory installDir, + }) async { + if (manifest.type != ExtensionType.databaseDriver) return; + + final main = manifest.main?.trim(); + if (main != null && main.isNotEmpty) { + await _markExecutableIfExists(File(p.join(installDir.path, main))); + } + + final binDir = Directory(p.join(installDir.path, 'bin')); + if (await binDir.exists()) { + await for (final entity in binDir.list()) { + if (entity is File) { + await _markExecutableIfExists(entity); + } + } + } + } + + static Future _markExecutableIfExists(File file) async { + if (!await file.exists()) return; + if (Platform.isWindows) return; + try { + final result = await Process.run('chmod', ['+x', file.path]); + if (result.exitCode != 0) { + throw MarketplaceException( + 'Failed to mark "${file.path}" as executable: ${result.stderr}', + ); + } + } on Object catch (e) { + if (e is MarketplaceException) rethrow; + throw MarketplaceException( + 'Failed to mark "${file.path}" as executable: $e', + ); + } + } + + /// Ensures a single driver entry is executable (no-op on Windows). + static Future markExecutableIfExists(File file) => + _markExecutableIfExists(file); } diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index 257ff363..557ede90 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -115,6 +115,10 @@ class LocalExtensionInstaller { manifest: manifest, installDir: extDir, ); + await ExtensionSupport.ensureDriverExecutables( + manifest: manifest, + installDir: extDir, + ); // Ensure canonical manifest on disk (pretty-printed, with install metadata). final manifestFile = File(p.join(extDir.path, 'manifest.json')); diff --git a/lib/core/extensions/models/extension_contributions.dart b/lib/core/extensions/models/extension_contributions.dart new file mode 100644 index 00000000..3531c1f0 --- /dev/null +++ b/lib/core/extensions/models/extension_contributions.dart @@ -0,0 +1,116 @@ +/// Capability flags declared by an extension (`capabilities` in manifest.json). +class ExtensionCapabilities { + const ExtensionCapabilities({ + this.databaseDriver = false, + this.sduiForms = false, + this.extra = const {}, + }); + + final bool databaseDriver; + final bool sduiForms; + + /// Additional boolean flags preserved for round-trip. + final Map extra; + + factory ExtensionCapabilities.fromJson(Map json) { + final known = {'databaseDriver', 'sduiForms'}; + final extra = {}; + for (final entry in json.entries) { + if (known.contains(entry.key)) continue; + if (entry.value is bool) { + extra[entry.key] = entry.value as bool; + } + } + return ExtensionCapabilities( + databaseDriver: json['databaseDriver'] == true, + sduiForms: json['sduiForms'] == true, + extra: extra, + ); + } + + Map toJson() => { + if (databaseDriver) 'databaseDriver': true, + if (sduiForms) 'sduiForms': true, + ...extra, + }; + + bool get isEmpty => !databaseDriver && !sduiForms && extra.isEmpty; +} + +/// A single database driver contribution under `contributions.drivers`. +class DriverContribution { + const DriverContribution({ + required this.driverId, + required this.displayName, + this.defaultPort, + this.connectionFormSchema, + this.icon, + }); + + final String driverId; + final String displayName; + final int? defaultPort; + + /// Relative path to an SDUI connection form JSON (from extension root). + final String? connectionFormSchema; + final String? icon; + + factory DriverContribution.fromJson(Map json) { + final portRaw = json['defaultPort']; + int? port; + if (portRaw is int) { + port = portRaw; + } else if (portRaw is num) { + port = portRaw.toInt(); + } else if (portRaw != null) { + port = int.tryParse('$portRaw'); + } + return DriverContribution( + driverId: '${json['driverId'] ?? ''}', + displayName: '${json['displayName'] ?? json['driverId'] ?? ''}', + defaultPort: port, + connectionFormSchema: json['connectionFormSchema'] as String?, + icon: json['icon'] as String?, + ); + } + + Map toJson() => { + 'driverId': driverId, + 'displayName': displayName, + if (defaultPort != null) 'defaultPort': defaultPort, + if (connectionFormSchema != null) + 'connectionFormSchema': connectionFormSchema, + if (icon != null) 'icon': icon, + }; +} + +/// `contributions` block from an extension manifest. +class ExtensionContributions { + const ExtensionContributions({this.drivers = const []}); + + final List drivers; + + factory ExtensionContributions.fromJson(Map json) { + final driversRaw = json['drivers']; + final drivers = []; + if (driversRaw is List) { + for (final item in driversRaw) { + if (item is Map) { + drivers.add(DriverContribution.fromJson(item)); + } else if (item is Map) { + drivers.add( + DriverContribution.fromJson(Map.from(item)), + ); + } + } + } + return ExtensionContributions(drivers: drivers); + } + + Map toJson() => { + if (drivers.isNotEmpty) + 'drivers': drivers.map((d) => d.toJson()).toList(), + }; + + bool get isEmpty => drivers.isEmpty; +} diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index d5a060c6..9a030cd6 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -1,4 +1,9 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + import '../../theme/theme_definition.dart'; +import 'extension_contributions.dart'; import 'extension_type.dart'; import 'sandbox_capabilities.dart'; @@ -27,6 +32,12 @@ class ExtensionManifest { /// manifest has no `sandbox` block (e.g. plain themes). final SandboxCapabilities? sandbox; + /// Capability flags from `capabilities` in manifest.json. + final ExtensionCapabilities? capabilities; + + /// Extension points from `contributions` in manifest.json. + final ExtensionContributions? contributions; + const ExtensionManifest({ required this.id, required this.name, @@ -46,8 +57,25 @@ class ExtensionManifest { this.preview, this.tags = const [], this.sandbox, + this.capabilities, + this.contributions, }); + /// Drivers contributed by this package (empty when none). + Iterable get contributedDrivers => + contributions?.drivers ?? const []; + + /// Absolute path to the packaged icon, or null when missing on disk. + String? get resolvedIconPath { + final rel = icon?.trim(); + final root = installPath; + if (rel == null || rel.isEmpty || root == null || root.isEmpty) { + return null; + } + final path = p.join(root, rel); + return File(path).existsSync() ? path : null; + } + /// Maps a registry [ThemeDefinition] into marketplace field names. factory ExtensionManifest.fromThemeDefinition( ThemeDefinition definition, { @@ -76,12 +104,15 @@ class ExtensionManifest { ); } - factory ExtensionManifest.fromJson(Map json, {String? installPath}) { + factory ExtensionManifest.fromJson(Map json, + {String? installPath}) { return ExtensionManifest( id: json['id'] as String, name: json['name'] as String, version: json['version'] as String? ?? '0.0.0', - publisher: json['publisher'] as String? ?? json['author'] as String? ?? 'Unknown', + publisher: json['publisher'] as String? ?? + json['author'] as String? ?? + 'Unknown', type: ExtensionType.fromString(json['type'] as String? ?? ''), engines: Map.from(json['engines'] as Map? ?? {}), main: json['main'] as String?, @@ -89,18 +120,45 @@ class ExtensionManifest { description: json['description'] as String?, installPath: installPath, downloadUrl: json['downloadUrl'] as String?, - sha256Checksum: json['sha256Checksum'] as String? ?? json['sha256'] as String?, + sha256Checksum: + json['sha256Checksum'] as String? ?? json['sha256'] as String?, author: json['author'] as String?, homepage: json['homepage'] as String?, license: json['license'] as String?, preview: json['preview'] as String?, tags: List.from(json['tags'] as List? ?? []), sandbox: json['sandbox'] is Map - ? SandboxCapabilities.fromJson(json['sandbox'] as Map) - : null, + ? SandboxCapabilities.fromJson( + json['sandbox'] as Map) + : (json['sandbox'] is Map + ? SandboxCapabilities.fromJson( + Map.from(json['sandbox'] as Map)) + : null), + capabilities: _parseCapabilities(json['capabilities']), + contributions: _parseContributions(json['contributions']), ); } + static ExtensionCapabilities? _parseCapabilities(Object? raw) { + if (raw is Map) { + return ExtensionCapabilities.fromJson(raw); + } + if (raw is Map) { + return ExtensionCapabilities.fromJson(Map.from(raw)); + } + return null; + } + + static ExtensionContributions? _parseContributions(Object? raw) { + if (raw is Map) { + return ExtensionContributions.fromJson(raw); + } + if (raw is Map) { + return ExtensionContributions.fromJson(Map.from(raw)); + } + return null; + } + Map toJson() { return { 'id': id, @@ -120,6 +178,10 @@ class ExtensionManifest { if (preview != null) 'preview': preview, if (tags.isNotEmpty) 'tags': tags, if (sandbox != null) 'sandbox': sandbox!.toJson(), + if (capabilities != null && !capabilities!.isEmpty) + 'capabilities': capabilities!.toJson(), + if (contributions != null && !contributions!.isEmpty) + 'contributions': contributions!.toJson(), }; } } diff --git a/lib/core/extensions/sandbox/sandbox_process_runner.dart b/lib/core/extensions/sandbox/sandbox_process_runner.dart index 92ed3a0e..09d86146 100644 --- a/lib/core/extensions/sandbox/sandbox_process_runner.dart +++ b/lib/core/extensions/sandbox/sandbox_process_runner.dart @@ -120,7 +120,14 @@ class SandboxProcessRunner { baseDirectory: scratchBaseDirectory, ); - final usesBwrap = bwrapAvailable ?? await _detectBwrap(); + final detected = bwrapAvailable ?? await detectBwrapAvailability(); + final usesBwrap = detected; + if (bwrapAvailable == null && !usesBwrap) { + debugPrint( + 'SandboxProcessRunner: bubblewrap unavailable or cannot set up user ' + 'namespaces on this system; launching $pluginId without OS sandbox.', + ); + } final command = SandboxLaunchCommand.build( pluginExecutable: pluginExecutable, pluginArguments: pluginArguments, @@ -170,12 +177,30 @@ class SandboxProcessRunner { } } - static Future _detectBwrap() async { + /// Whether [bwrap] is installed and can run a trivial command on this host. + /// + /// Some kernels/sessions deny user-namespace uid maps even when `which bwrap` + /// succeeds; in that case we fall back to direct plugin execution. + static Future detectBwrapAvailability() async { if (!Platform.isLinux) return false; try { - final result = await Process.run('which', ['bwrap']); - return result.exitCode == 0; - } catch (_) { + final which = await Process.run('which', ['bwrap']); + if (which.exitCode != 0) return false; + + final probe = await Process.run( + 'bwrap', + const ['--ro-bind', '/', '/', '/bin/true'], + ); + if (probe.exitCode != 0) { + final detail = '${probe.stderr}'.trim(); + if (detail.isNotEmpty) { + debugPrint('SandboxProcessRunner: bwrap probe failed: $detail'); + } + return false; + } + return true; + } catch (e) { + debugPrint('SandboxProcessRunner: bwrap probe error: $e'); return false; } } diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 59f47314..1d1a5caf 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -176,6 +176,10 @@ class HttpMarketplaceRepository implements MarketplaceRepository { manifest: manifest, installDir: extDir, ); + await ExtensionSupport.ensureDriverExecutables( + manifest: manifest, + installDir: extDir, + ); // Step 4: Write/Update manifest.json in the extension directory final manifestFile = File(p.join(extDir.path, 'manifest.json')); diff --git a/lib/core/sdui/sdui_form_schema.dart b/lib/core/sdui/sdui_form_schema.dart index bb157d18..ed6e14ed 100644 --- a/lib/core/sdui/sdui_form_schema.dart +++ b/lib/core/sdui/sdui_form_schema.dart @@ -11,6 +11,7 @@ enum SduiFieldType { final String value; static SduiFieldType fromString(String? value) { + if (value == 'boolean') return SduiFieldType.checkbox; return SduiFieldType.values.firstWhere( (t) => t.value == value, orElse: () => SduiFieldType.text, @@ -66,10 +67,11 @@ class SduiFormField { } } + final fieldId = '${json['id'] ?? json['key'] ?? json['name'] ?? ''}'; return SduiFormField( - id: '${json['id'] ?? json['name'] ?? ''}', + id: fieldId, type: SduiFieldType.fromString(json['type'] as String?), - label: '${json['label'] ?? json['id'] ?? ''}', + label: '${json['label'] ?? fieldId}', required: json['required'] == true, placeholder: json['placeholder'] as String?, defaultValue: json['default'] ?? json['defaultValue'], diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 37f551e9..0073c10d 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -9,12 +9,16 @@ class SduiTreeBuilder extends material.StatefulWidget { required this.schema, this.fetchChildren, this.onNodeSelected, + this.maxHeight, }); final SduiTreeSchema schema; final SduiFetchTreeChildren? fetchChildren; final void Function(SduiTreeNode node)? onNodeSelected; + /// When set, the tree scrolls inside a height cap (sidebar use). + final double? maxHeight; + @override material.State createState() => SduiTreeBuilderState(); } @@ -24,6 +28,7 @@ class SduiTreeBuilderState extends material.State { final Set _loading = {}; final Set _loaded = {}; final Set _expanded = {}; + final Map _expandErrors = {}; @override void initState() { @@ -39,11 +44,15 @@ class SduiTreeBuilderState extends material.State { _loading.clear(); _loaded.clear(); _expanded.clear(); + _expandErrors.clear(); } } Future _onExpand(SduiTreeNode node) async { - setState(() => _expanded.add(node.id)); + setState(() { + _expanded.add(node.id); + _expandErrors.remove(node.id); + }); if (!node.expandable || _loaded.contains(node.id) || node.hasChildren) { return; } @@ -55,13 +64,23 @@ class SduiTreeBuilderState extends material.State { final children = await fetch(node.id); if (!mounted) return; setState(() { - _roots = _replaceNode(_roots, node.id, (n) => n.copyWith(children: children)); + _roots = _replaceNode( + _roots, + node.id, + (n) => n.copyWith(children: children), + ); _loaded.add(node.id); _loading.remove(node.id); + if (children.isEmpty) { + _expandErrors[node.id] = 'No child objects found.'; + } }); - } catch (_) { + } catch (e) { if (!mounted) return; - setState(() => _loading.remove(node.id)); + setState(() { + _loading.remove(node.id); + _expandErrors[node.id] = e.toString(); + }); } } @@ -87,30 +106,44 @@ class SduiTreeBuilderState extends material.State { @override material.Widget build(material.BuildContext context) { - return material.ListView( - shrinkWrap: true, + final tree = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, children: [ for (final root in _roots) _buildNode(root, depth: 0), ], ); + + if (widget.maxHeight == null) return tree; + + return material.ConstrainedBox( + constraints: material.BoxConstraints(maxHeight: widget.maxHeight!), + child: material.SingleChildScrollView( + physics: const material.ClampingScrollPhysics(), + child: tree, + ), + ); } material.Widget _buildNode(SduiTreeNode node, {required int depth}) { final canExpand = node.expandable || node.hasChildren; final isExpanded = _expanded.contains(node.id); final isLoading = _loading.contains(node.id); + final expandError = _expandErrors[node.id]; + final nodeKind = _resolveNodeKind(node); + final isBrowsable = nodeKind == 'table' || nodeKind == 'view'; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ material.InkWell( - onTap: () => widget.onNodeSelected?.call(node), + onTap: isBrowsable ? () => widget.onNodeSelected?.call(node) : null, child: material.Padding( padding: material.EdgeInsets.only( left: 8.0 + depth * 16.0, right: 8, - top: 6, - bottom: 6, + top: 4, + bottom: 4, ), child: material.Row( children: [ @@ -149,25 +182,63 @@ class SduiTreeBuilderState extends material.State { size: 16, ), const Gap(8), - material.Expanded(child: Text(node.label).small()), + material.Expanded( + child: material.Text( + node.label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + fontWeight: + isBrowsable ? material.FontWeight.w600 : null, + ), + ), + ), ], ), ), ), + if (isExpanded && expandError != null) + material.Padding( + padding: material.EdgeInsets.only(left: 36.0 + depth * 16.0), + child: Text(expandError).muted().xSmall(), + ), if (isExpanded) - for (final child in node.children) _buildNode(child, depth: depth + 1), + for (final child in node.children) + _buildNode(child, depth: depth + 1), ], ); } + String _resolveNodeKind(SduiTreeNode node) { + final fromMeta = + '${node.meta['nodeType'] ?? node.meta['node_type'] ?? ''}'.trim(); + if (fromMeta.isNotEmpty) return fromMeta; + final parts = node.id.split('.'); + return parts.isNotEmpty ? parts.first : ''; + } + material.IconData _iconFor(SduiTreeNode node) { switch (node.icon) { case 'database': return material.Icons.storage_outlined; case 'table': return material.Icons.table_chart_outlined; + case 'view': + case 'eye': + return material.Icons.visibility_outlined; case 'folder': + case 'folder-table': return material.Icons.folder_outlined; + case 'folder-eye': + return material.Icons.folder_special_outlined; + case 'folder-book': + case 'book': + return material.Icons.menu_book_outlined; + case 'columns': + return material.Icons.view_column_outlined; + case 'archive': + return material.Icons.inventory_2_outlined; default: return node.expandable ? material.Icons.folder_outlined diff --git a/lib/core/sdui/sdui_tree_schema.dart b/lib/core/sdui/sdui_tree_schema.dart index 4e766df2..d530654a 100644 --- a/lib/core/sdui/sdui_tree_schema.dart +++ b/lib/core/sdui/sdui_tree_schema.dart @@ -43,16 +43,25 @@ class SduiTreeNode { } } } - final metaRaw = json['meta']; + final metaRaw = json['meta'] ?? json['metadata']; final meta = {}; if (metaRaw is Map) { meta.addAll(metaRaw.map((k, v) => MapEntry('$k', v))); } + if (json['nodeType'] != null && !meta.containsKey('nodeType')) { + meta['nodeType'] = json['nodeType']; + } + if (json['node_type'] != null && !meta.containsKey('nodeType')) { + meta['nodeType'] = json['node_type']; + } return SduiTreeNode( id: '${json['id'] ?? ''}', label: '${json['label'] ?? json['name'] ?? json['id'] ?? ''}', - expandable: json['expandable'] == true || json['lazy'] == true, + expandable: json['expandable'] == true || + json['lazy'] == true || + json['hasChildren'] == true || + json['has_children'] == true, children: children, icon: json['icon'] as String?, meta: meta, @@ -67,7 +76,8 @@ class SduiTreeSchema { final List roots; factory SduiTreeSchema.fromJson(Map json) { - final rootsRaw = json['roots'] ?? json['children'] ?? json['nodes']; + final rootsRaw = + json['roots'] ?? json['rootNodes'] ?? json['children'] ?? json['nodes']; final roots = []; if (rootsRaw is List) { for (final item in rootsRaw) { diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 588e33d3..235fe714 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -6,7 +6,7 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; -const _dbVersion = 6; +const _dbVersion = 7; /// Fallback when [recordSqlQueryHistory] is called without `maxEntries`. /// Keep in sync with [kDefaultSqlHistoryMaxEntries] in `app_settings.dart`. @@ -84,6 +84,8 @@ class LocalDb { auth_source TEXT, use_ssl INTEGER NOT NULL DEFAULT 0, connection_string TEXT, + extension_id TEXT, + driver_options TEXT, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL @@ -189,6 +191,10 @@ class LocalDb { ON sql_query_history (connection_id, recorded_at DESC) '''); } + if (oldVersion < 7) { + await db.execute('ALTER TABLE connections ADD COLUMN extension_id TEXT'); + await db.execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); + } } Future getAppSetting(String key) async { @@ -500,6 +506,8 @@ class ConnectionRow { this.authSource, this.useSSL = false, this.connectionString, + this.extensionId, + this.driverOptions, this.folderId, this.sortOrder = 0, required this.createdAt, @@ -516,10 +524,21 @@ class ConnectionRow { final String? authSource; final bool useSSL; final String? connectionString; + + /// Package id of an installed extension driver (null for built-ins). + final String? extensionId; + + /// Non-secret driver-specific form values as JSON text. + final String? driverOptions; + final int? folderId; final int sortOrder; final String createdAt; + /// True when this row is backed by an installed extension driver. + bool get isExtensionDriver => + extensionId != null && extensionId!.trim().isNotEmpty; + Map toMap() => { 'type': type, 'name': name, @@ -531,6 +550,8 @@ class ConnectionRow { 'auth_source': authSource, 'use_ssl': useSSL ? 1 : 0, 'connection_string': connectionString, + 'extension_id': extensionId, + 'driver_options': driverOptions, 'folder_id': folderId, 'sort_order': sortOrder, 'created_at': createdAt, @@ -548,6 +569,8 @@ class ConnectionRow { 'auth_source': authSource, 'use_ssl': useSSL ? 1 : 0, 'connection_string': null, + 'extension_id': extensionId, + 'driver_options': driverOptions, 'folder_id': folderId, 'sort_order': sortOrder, 'created_at': createdAt, @@ -565,6 +588,8 @@ class ConnectionRow { authSource: m['auth_source'] as String?, useSSL: _sqliteInt(m['use_ssl']) == 1, connectionString: m['connection_string'] as String?, + extensionId: m['extension_id'] as String?, + driverOptions: m['driver_options'] as String?, folderId: _sqliteInt(m['folder_id']), sortOrder: _sqliteInt(m['sort_order']) ?? 0, createdAt: m['created_at'] as String, diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index c515bcf3..31e15cab 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/extension_connection_form.dart'; import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; import 'package:querya_desktop/features/mongodb/mongodb_connection_form.dart'; @@ -23,20 +25,39 @@ Future promptCreateConnection( int? folderId, }) async { final dialogContext = _dialogAnchorContext(context); - final type = await showNewConnectionDialog(dialogContext); - if (type == null) return null; + final choice = await showNewConnectionDialog(dialogContext); + if (choice == null) return null; if (!dialogContext.mounted) return null; - switch (type) { - case ConnectionType.postgresql: - return await showPostgresConnectionForm(dialogContext, - folderId: folderId); - case ConnectionType.mysql: - return await showMysqlConnectionForm(dialogContext, folderId: folderId); - case ConnectionType.mongodb: - return await showMongoConnectionForm(dialogContext, folderId: folderId); - case ConnectionType.redis: - return await showRedisConnectionForm(dialogContext, folderId: folderId); - case ConnectionType.sqlite: - return await showSqliteConnectionForm(dialogContext, folderId: folderId); - } + + return switch (choice) { + BuiltInConnectionType(:final type) => switch (type) { + ConnectionType.postgresql => dialogContext.mounted + ? await showPostgresConnectionForm( + dialogContext, + folderId: folderId, + ) + : null, + ConnectionType.mysql => dialogContext.mounted + ? await showMysqlConnectionForm(dialogContext, folderId: folderId) + : null, + ConnectionType.mongodb => dialogContext.mounted + ? await showMongoConnectionForm(dialogContext, folderId: folderId) + : null, + ConnectionType.redis => dialogContext.mounted + ? await showRedisConnectionForm(dialogContext, folderId: folderId) + : null, + ConnectionType.sqlite => dialogContext.mounted + ? await showSqliteConnectionForm(dialogContext, folderId: folderId) + : null, + }, + ExtensionDriverChoice(:final manifest, :final driver) => + dialogContext.mounted + ? await showExtensionConnectionForm( + dialogContext, + manifest: manifest, + driver: driver, + folderId: folderId, + ) + : null, + }; } diff --git a/lib/features/connections/connection_type_choice.dart b/lib/features/connections/connection_type_choice.dart new file mode 100644 index 00000000..3a16c06a --- /dev/null +++ b/lib/features/connections/connection_type_choice.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; + +/// Result of the New Connection type picker (built-in or extension driver). +sealed class ConnectionTypeChoice { + const ConnectionTypeChoice(); + + String get label; + material.IconData get icon; + String? get iconAsset; + + /// Absolute path to an icon file shipped by an extension package. + String? get iconFile => null; +} + +/// One of the five built-in Dart drivers. +final class BuiltInConnectionType extends ConnectionTypeChoice { + const BuiltInConnectionType(this.type); + + final ConnectionType type; + + @override + String get label => type.label; + + @override + material.IconData get icon => type.icon; + + @override + String? get iconAsset => type.iconAsset; + + @override + bool operator ==(Object other) => + other is BuiltInConnectionType && other.type == type; + + @override + int get hashCode => type.hashCode; +} + +/// A driver contributed by an installed `database_driver` extension. +final class ExtensionDriverChoice extends ConnectionTypeChoice { + const ExtensionDriverChoice({ + required this.manifest, + required this.driver, + }); + + final ExtensionManifest manifest; + final DriverContribution driver; + + @override + String get label => + driver.displayName.isNotEmpty ? driver.displayName : manifest.name; + + @override + material.IconData get icon => material.Icons.extension_rounded; + + @override + String? get iconAsset => null; + + @override + String? get iconFile => manifest.resolvedIconPath; + + @override + bool operator ==(Object other) => + other is ExtensionDriverChoice && + other.manifest.id == manifest.id && + other.driver.driverId == driver.driverId; + + @override + int get hashCode => Object.hash(manifest.id, driver.driverId); +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 1a679885..d7f6d105 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -59,6 +59,11 @@ import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_builder.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; @@ -66,6 +71,7 @@ import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/app/app_shutdown.dart'; @@ -82,6 +88,7 @@ part 'connections_panel_postgres_connection.dart'; part 'connections_panel_mysql.dart'; part 'connections_panel_pg_tree.dart'; part 'connections_panel_sqlite.dart'; +part 'connections_panel_extension.dart'; /// Opens the PostgreSQL SQL tab; optional tree fields seed the editor for the /// row that was right-clicked (left-click is not required). @@ -155,6 +162,7 @@ class ConnectionsPanel extends StatefulWidget { this.onMysqlOpenSqlWorkspace, this.onSqliteObjectSelected, this.onSqliteOpenSqlWorkspace, + this.onExtensionObjectSelected, /// When true, [initState] does not call [_loadData]. Widget tests that seed /// SQLite in setUp should call [ConnectionsPanelState.reloadConnectionsFromDb] @@ -210,6 +218,13 @@ class ConnectionsPanel extends StatefulWidget { /// Opens the SQLite workspace home and switches to the SQL tab. final void Function(ConnectionRow connection)? onSqliteOpenSqlWorkspace; + /// Fires when a table/view node is clicked in an extension driver tree. + final void Function( + ConnectionRow connection, + String database, + String name, + )? onExtensionObjectSelected; + final bool skipInitialDbLoadForTest; @override @@ -303,6 +318,7 @@ class ConnectionsPanelState extends State { Future _removeConnection(int id) async { await MongoService.instance.disconnectByConnectionId(id); + await ExtensionDriverSession.instance.disconnect(id); SqliteService.instance.interrupt( ConnectionRow(id: id, type: 'sqlite', name: '', createdAt: ''), mode: SqliteSessionMode.readOnly, @@ -346,6 +362,9 @@ class ConnectionsPanelState extends State { } else if (conn.type == 'mongodb') { await MongoService.instance.disconnectByConnectionId(id); } + if (ExtensionDriverCatalog.isExtensionDriverConnection(conn)) { + await ExtensionDriverSession.instance.disconnect(id); + } } Future disconnectAll() async { @@ -384,7 +403,7 @@ class ConnectionsPanelState extends State { 'mysql' => material.Icons.table_chart_rounded, 'redis' => material.Icons.memory_rounded, 'sqlite' => material.Icons.folder_open_rounded, - _ => material.Icons.settings_ethernet_rounded, + _ => material.Icons.extension_rounded, }; } @@ -476,6 +495,18 @@ class ConnectionsPanelState extends State { isExpanded: isExpanded, onExpandedChanged: handleExpandedChanged, ); + } else if (ExtensionDriverCatalog.isExtensionDriverConnection(conn)) { + return _ExtensionConnectionTile( + connection: conn, + isSelected: isSelected, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onObjectSelected: widget.onExtensionObjectSelected, + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, + ); } return _ConnectionTile( connection: conn, diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart new file mode 100644 index 00000000..7d5a660d --- /dev/null +++ b/lib/features/connections/connections_panel_extension.dart @@ -0,0 +1,332 @@ +part of 'package:querya_desktop/features/connections/connections_panel.dart'; + +/// Expandable sidebar tile for an installed extension database driver. +class _ExtensionConnectionTile extends StatefulWidget { + const _ExtensionConnectionTile({ + required this.connection, + this.isSelected = false, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onObjectSelected, + this.isExpanded = false, + this.onExpandedChanged, + }); + + final ConnectionRow connection; + final bool isSelected; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + + /// Fires when a table/view node is clicked in the schema tree. + final void Function( + ConnectionRow connection, + String database, + String name, + )? onObjectSelected; + final bool isExpanded; + final ValueChanged? onExpandedChanged; + + @override + State<_ExtensionConnectionTile> createState() => + _ExtensionConnectionTileState(); +} + +class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { + bool _loading = false; + String? _error; + SduiTreeSchema? _schema; + String? _iconFilePath; + + @override + void initState() { + super.initState(); + _resolveIconFile(); + if (widget.isExpanded) { + _loadTree(); + } + } + + Future _resolveIconFile() async { + await LocalExtensionRegistry.instance.load(); + if (!mounted) return; + final path = + ExtensionDriverCatalog.iconFileForConnection(widget.connection); + if (path != null) { + setState(() => _iconFilePath = path); + } + } + + @override + void didUpdateWidget(_ExtensionConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.connection.extensionId != oldWidget.connection.extensionId || + widget.connection.type != oldWidget.connection.type) { + _resolveIconFile(); + } + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_schema == null && !_loading) { + _loadTree(); + } + } + } + + void _toggle() { + final next = !widget.isExpanded; + widget.onExpandedChanged?.call(next); + if (next) { + // Opening the schema tree should activate this connection in the workspace + // (SQL editor / table view), same as clicking the connection row. + widget.onTap?.call(); + } + } + + Future _loadTree() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final schema = + await ExtensionDriverSession.instance.getSchemaTree(widget.connection); + if (!mounted) return; + setState(() { + _schema = schema; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + Future> _fetchChildren(String nodeId) { + return ExtensionDriverSession.instance + .expandTreeNode(widget.connection, nodeId); + } + + /// Node ids follow `..` (e.g. `table.analytics.events`). + void _onNodeSelected(SduiTreeNode node) { + final callback = widget.onObjectSelected; + if (callback == null) return; + final parts = node.id.split('.'); + if (parts.length < 3) return; + final kind = parts[0]; + if (kind != 'table' && kind != 'view') return; + final database = parts[1]; + final name = parts.sublist(2).join('.'); + if (database.isEmpty || name.isEmpty) return; + callback(widget.connection, database, name); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final material.Widget iconWidget; + if (_iconFilePath != null) { + iconWidget = DriverIconImage( + path: _iconFilePath!, + size: 16, + fallbackIcon: widget.icon, + ); + } else if (widget.iconAsset != null) { + iconWidget = material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ); + } else { + iconWidget = material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ); + } + + return material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: widget.isExpanded ? 0.25 : 0, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + fontWeight: widget.isSelected + ? material.FontWeight.w600 + : material.FontWeight.w500, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + material.Tooltip( + message: 'Remove', + child: material.InkWell( + onTap: widget.onRemove, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + material.Icons.close_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + QueryaAnimatedExpand( + expanded: widget.isExpanded, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (_loading) + material.Padding( + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 4, + ), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator( + strokeWidth: 1.5, + ), + ), + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ) + else if (_error != null) + material.Padding( + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 8, + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SelectableText( + _error!, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.destructive, + ), + ), + const material.SizedBox(height: 6), + GhostButton( + onPressed: _loadTree, + child: const Text('Retry'), + ), + ], + ), + ) + else if (_schema != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: _schema!.roots.isEmpty + ? material.Padding( + padding: + const material.EdgeInsets.fromLTRB(0, 8, 8, 8), + child: const Text( + 'No databases found on this server.', + ).muted().small(), + ) + : SduiTreeBuilder( + schema: _schema!, + fetchChildren: _fetchChildren, + onNodeSelected: _onNodeSelected, + maxHeight: kConnectionTreeMaxVisibleRows * + kConnectionTreeRowExtent, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/connections/driver_icon.dart b/lib/features/connections/driver_icon.dart new file mode 100644 index 00000000..eda1b145 --- /dev/null +++ b/lib/features/connections/driver_icon.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Renders an extension driver icon from a file on disk (SVG or bitmap). +/// Falls back to [fallbackIcon] when the file is missing or unreadable. +class DriverIconImage extends StatelessWidget { + const DriverIconImage({ + super.key, + required this.path, + required this.size, + this.fallbackIcon = material.Icons.extension_rounded, + }); + + final String path; + final double size; + final material.IconData fallbackIcon; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final fallback = material.Icon( + fallbackIcon, + size: size, + color: theme.colorScheme.primary, + ); + + final file = File(path); + if (!file.existsSync()) return fallback; + + if (path.toLowerCase().endsWith('.svg')) { + return SvgPicture.file( + file, + width: size, + height: size, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => fallback, + ); + } + return material.Image.file( + file, + width: size, + height: size, + fit: material.BoxFit.contain, + filterQuality: material.FilterQuality.medium, + errorBuilder: (_, __, ___) => fallback, + ); + } +} diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index d74c0e62..df3cd8d4 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -1,43 +1,27 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/driver_icon.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'new_connection_dialog.dart'; - /// One row in the driver list. typedef _DriverInfo = ({ - ConnectionType type, + String label, + material.IconData icon, + String? iconAsset, + String? iconFile, String description, + String badge, }); -/// Built-in drivers (Dart packages). No separate JDBC/JAR install is required to connect. -final _driverInfoList = <_DriverInfo>[ - ( - type: ConnectionType.postgresql, - description: - 'PostgreSQL — built-in Dart driver (`postgres`). Use Connection → New Database Connection.', - ), - ( - type: ConnectionType.mysql, - description: 'MySQL / MariaDB — built-in Dart driver (`mysql_client`).', - ), - ( - type: ConnectionType.sqlite, - description: 'SQLite — built-in Dart driver (`sqflite_common_ffi`).', - ), - ( - type: ConnectionType.redis, - description: 'Redis — built-in Dart client (`redis`).', - ), - ( - type: ConnectionType.mongodb, - description: 'MongoDB — built-in Dart driver (`mongo_dart`).', - ), -]; - -/// Shows built-in database drivers shipped with the app. -void showDriverManagerDialog(BuildContext context) { - showAppDialog( +/// Shows built-in and installed extension database drivers. +Future showDriverManagerDialog(material.BuildContext context) async { + await LocalExtensionRegistry.instance.load(); + if (!context.mounted) return; + return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, @@ -47,6 +31,44 @@ void showDriverManagerDialog(BuildContext context) { ); } +List<_DriverInfo> _buildDriverList() { + final list = <_DriverInfo>[ + for (final choice in ExtensionDriverCatalog.builtInChoices) + if (choice is BuiltInConnectionType) + ( + label: choice.label, + icon: choice.icon, + iconAsset: choice.iconAsset, + iconFile: null, + description: switch (choice.type) { + ConnectionType.postgresql => + 'PostgreSQL — built-in Dart driver (`postgres`).', + ConnectionType.mysql => + 'MySQL / MariaDB — built-in Dart driver (`mysql_client`).', + ConnectionType.sqlite => + 'SQLite — built-in Dart driver (`sqflite_common_ffi`).', + ConnectionType.redis => 'Redis — built-in Dart client (`redis`).', + ConnectionType.mongodb => + 'MongoDB — built-in Dart driver (`mongo_dart`).', + }, + badge: 'Built-in', + ), + ]; + + for (final choice in ExtensionDriverCatalog.extensionChoices()) { + list.add(( + label: choice.label, + icon: choice.icon, + iconAsset: null, + iconFile: choice.iconFile, + description: + 'Extension · ${choice.manifest.id} · driverId=${choice.driver.driverId}', + badge: 'Extension', + )); + } + return list; +} + class _DriverManagerDialogContent extends material.StatelessWidget { const _DriverManagerDialogContent(); @@ -54,6 +76,7 @@ class _DriverManagerDialogContent extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; + final drivers = _buildDriverList(); return material.Container( constraints: WindowLayout.dialogConstraints( context, @@ -79,7 +102,8 @@ class _DriverManagerDialogContent extends material.StatelessWidget { const Text('Driver Manager').large().semiBold(), const material.SizedBox(height: 6), const Text( - 'Querya connects using built-in Dart drivers. Add a server under Connection → New Database Connection.', + 'Built-in Dart drivers and installed sandboxed extension drivers. ' + 'Add a server under Connection → New Database Connection.', ).muted().small(), ], ), @@ -97,18 +121,14 @@ class _DriverManagerDialogContent extends material.StatelessWidget { child: material.ListView.separated( shrinkWrap: true, padding: const material.EdgeInsets.symmetric(vertical: 8), - itemCount: _driverInfoList.length, + itemCount: drivers.length, separatorBuilder: (_, __) => material.Divider( height: 1, color: theme.border.withValues(alpha: 0.3), ), itemBuilder: (context, index) { - final info = _driverInfoList[index]; - return _DriverRow( - type: info.type, - description: info.description, - theme: theme, - ); + final info = drivers[index]; + return _DriverRow(info: info, theme: theme); }, ), ), @@ -141,13 +161,11 @@ class _DriverManagerDialogContent extends material.StatelessWidget { class _DriverRow extends material.StatelessWidget { const _DriverRow({ - required this.type, - required this.description, + required this.info, required this.theme, }); - final ConnectionType type; - final String description; + final _DriverInfo info; final ColorScheme theme; @override @@ -161,13 +179,19 @@ class _DriverRow extends material.StatelessWidget { material.SizedBox( width: 40, height: 40, - child: type.iconAsset != null - ? material.Image.asset( - type.iconAsset!, - fit: material.BoxFit.contain, - filterQuality: material.FilterQuality.medium, + child: info.iconFile != null + ? DriverIconImage( + path: info.iconFile!, + size: 40, + fallbackIcon: info.icon, ) - : material.Icon(type.icon, size: 40, color: theme.primary), + : info.iconAsset != null + ? material.Image.asset( + info.iconAsset!, + fit: material.BoxFit.contain, + filterQuality: material.FilterQuality.medium, + ) + : material.Icon(info.icon, size: 40, color: theme.primary), ), const material.SizedBox(width: 16), material.Expanded( @@ -175,9 +199,9 @@ class _DriverRow extends material.StatelessWidget { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - Text(type.label).semiBold().small(), + Text(info.label).semiBold().small(), const material.SizedBox(height: 2), - Text(description).muted().xSmall(), + Text(info.description).muted().xSmall(), ], ), ), @@ -193,7 +217,7 @@ class _DriverRow extends material.StatelessWidget { ), ), child: Text( - 'Built-in', + info.badge, style: material.TextStyle( fontSize: 11, fontWeight: material.FontWeight.w600, diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart new file mode 100644 index 00000000..da6d8fd3 --- /dev/null +++ b/lib/features/connections/extension_connection_form.dart @@ -0,0 +1,374 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows an SDUI connection form for an installed extension driver. +Future showExtensionConnectionForm( + material.BuildContext context, { + required ExtensionManifest manifest, + required DriverContribution driver, + int? folderId, +}) { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: _ExtensionConnectionFormContent( + manifest: manifest, + driver: driver, + folderId: folderId, + ), + ), + ); +} + +class _ExtensionConnectionFormContent extends material.StatefulWidget { + const _ExtensionConnectionFormContent({ + required this.manifest, + required this.driver, + this.folderId, + }); + + final ExtensionManifest manifest; + final DriverContribution driver; + final int? folderId; + + @override + material.State<_ExtensionConnectionFormContent> createState() => + _ExtensionConnectionFormContentState(); +} + +class _ExtensionConnectionFormContentState + extends material.State<_ExtensionConnectionFormContent> { + final _nameController = material.TextEditingController(); + final _formKey = material.GlobalKey(); + SduiFormSchema? _schema; + String? _loadError; + var _loading = true; + var _testing = false; + String? _testMessage; + bool _testSucceeded = false; + + @override + void initState() { + super.initState(); + _nameController.text = widget.driver.displayName; + _loadSchema(); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _loadSchema() async { + try { + final schema = await loadDriverConnectionFormSchema( + manifest: widget.manifest, + driver: widget.driver, + ); + if (!mounted) return; + setState(() { + _schema = schema; + _loading = false; + _loadError = schema == null + ? 'Connection form schema not found for this driver.' + : null; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _loadError = 'Failed to load connection form: $e'; + }); + } + } + + void _save() { + final schema = _schema; + if (schema == null) return; + final values = _formKey.currentState?.collectValues(); + if (values == null) return; + + final name = _nameController.text.trim(); + if (name.isEmpty) return; + + final row = connectionRowFromExtensionForm( + manifest: widget.manifest, + driver: widget.driver, + name: name, + values: values, + folderId: widget.folderId, + ); + material.Navigator.of(context).pop(row); + } + + Future _testConnection() async { + final schema = _schema; + if (schema == null) return; + + final formState = _formKey.currentState; + if (formState == null) return; + + final values = formState.collectValues(); + if (values == null) { + if (!mounted) return; + setState(() { + _testMessage = 'Fill in all required fields before testing.'; + _testSucceeded = false; + }); + return; + } + + setState(() { + _testing = true; + _testMessage = null; + _testSucceeded = false; + }); + + try { + final row = connectionRowFromExtensionForm( + manifest: widget.manifest, + driver: widget.driver, + name: 'connection-test', + values: values, + ); + final version = await ExtensionDriverSession.instance.testConnection( + manifest: widget.manifest, + row: row, + ); + if (!mounted) return; + setState(() { + _testing = false; + _testSucceeded = true; + _testMessage = version.isEmpty + ? 'Connection successful.' + : 'Connection successful — server version $version.'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _testing = false; + _testSucceeded = false; + _testMessage = 'Connection failed: $e'; + }); + } + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 400, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text(widget.driver.displayName).large().semiBold(), + const material.SizedBox(height: 6), + Text( + 'Extension driver · ${widget.manifest.id}', + ).muted().small(), + ], + ), + ), + material.Flexible( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection name').small().muted(), + const material.SizedBox(height: 4), + TextField( + controller: _nameController, + placeholder: const Text('My ClickHouse'), + ), + const material.SizedBox(height: 16), + if (_loading) + const material.Padding( + padding: material.EdgeInsets.all(24), + child: material.Center( + child: material.CircularProgressIndicator(), + ), + ) + else if (_loadError != null) + Text(_loadError!).muted().small() + else if (_schema != null) + SduiFormBuilder(key: _formKey, schema: _schema!), + if (_testMessage != null) ...[ + const material.SizedBox(height: 12), + material.SelectableText( + _testMessage!, + style: material.TextStyle( + fontSize: 12, + color: _testSucceeded + ? material.Colors.green + : theme.destructive, + ), + ), + ], + ], + ), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Row( + children: [ + OutlineButton( + onPressed: + _schema == null || _testing ? null : _testConnection, + leading: _testing + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const material.Icon( + material.Icons.bolt_rounded, + size: 16, + ), + child: const Text('Test Connection'), + ), + const Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _schema == null ? null : _save, + child: const Text('Save'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Loads SDUI form schema from the extension package (file path preferred). +Future loadDriverConnectionFormSchema({ + required ExtensionManifest manifest, + required DriverContribution driver, +}) async { + final rel = driver.connectionFormSchema?.trim(); + final root = manifest.installPath; + if (rel != null && rel.isNotEmpty && root != null && root.isNotEmpty) { + final file = File(p.join(root, rel)); + if (await file.exists()) { + final raw = jsonDecode(await file.readAsString()); + if (raw is Map) { + return SduiFormSchema.fromJson(raw); + } + if (raw is Map) { + return SduiFormSchema.fromJson(Map.from(raw)); + } + } + } + return null; +} + +/// Maps SDUI form values into a [ConnectionRow] for an extension driver. +ConnectionRow connectionRowFromExtensionForm({ + required ExtensionManifest manifest, + required DriverContribution driver, + required String name, + required Map values, + int? folderId, +}) { + final known = { + 'host', + 'port', + 'username', + 'password', + 'database', + 'databaseName', + 'sslMode', + }; + final host = values['host']?.toString().trim(); + final portRaw = values['port']; + int? port; + if (portRaw is int) { + port = portRaw; + } else if (portRaw != null) { + port = int.tryParse('$portRaw'); + } + port ??= driver.defaultPort; + + final username = values['username']?.toString(); + final password = values['password']?.toString(); + final database = (values['database'] ?? values['databaseName'])?.toString(); + + final sslMode = values['sslMode']?.toString().toLowerCase(); + final useSsl = sslMode != null + ? sslMode != 'disable' && sslMode != 'false' && sslMode != '0' + : values['ssl'] == true || values['useSSL'] == true; + + final options = {}; + for (final entry in values.entries) { + if (known.contains(entry.key)) continue; + if (entry.key == 'password') continue; + options[entry.key] = entry.value; + } + + return ConnectionRow( + type: driver.driverId, + name: name, + host: (host == null || host.isEmpty) ? null : host, + port: port, + username: username, + password: (password == null || password.isEmpty) ? null : password, + databaseName: database, + useSSL: useSsl, + extensionId: manifest.id, + driverOptions: options.isEmpty ? null : jsonEncode(options), + folderId: folderId, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); +} diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 8a35f778..69dd07aa 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -1,12 +1,16 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Database type for new connection. +/// Database type for built-in new connections. enum ConnectionType { postgresql, mysql, @@ -40,25 +44,20 @@ extension ConnectionTypeX on ConnectionType { ConnectionType.sqlite => null, }; bool get isSql => - this == ConnectionType.postgresql || this == ConnectionType.mysql || this == ConnectionType.sqlite; + this == ConnectionType.postgresql || + this == ConnectionType.mysql || + this == ConnectionType.sqlite; } -const _sqlTypes = [ConnectionType.postgresql, ConnectionType.mysql, ConnectionType.sqlite]; -const _noSqlTypes = [ConnectionType.redis, ConnectionType.mongodb]; -const _allTypes = [ - ConnectionType.postgresql, - ConnectionType.mysql, - ConnectionType.sqlite, - ConnectionType.redis, - ConnectionType.mongodb -]; - enum _Category { all, sql, nosql } -/// Shows a dialog to choose database type (PostgreSQL, MySQL, Redis, MongoDB). -/// Returns the selected type or null if cancelled. -Future showNewConnectionDialog(BuildContext context) { - return showAppDialog( +/// Shows a dialog to choose database type (built-in + installed extension drivers). +Future showNewConnectionDialog( + material.BuildContext context, +) async { + await LocalExtensionRegistry.instance.load(); + if (!context.mounted) return null; + return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, @@ -79,7 +78,7 @@ class _NewConnectionDialogContent extends material.StatefulWidget { class _NewConnectionDialogContentState extends material.State<_NewConnectionDialogContent> { _Category _category = _Category.all; - ConnectionType? _selectedType; + ConnectionTypeChoice? _selected; final _searchController = material.TextEditingController(); String _searchQuery = ''; @@ -89,13 +88,13 @@ class _NewConnectionDialogContentState super.dispose(); } - List get _categoryTypes => switch (_category) { - _Category.all => _allTypes, - _Category.sql => _sqlTypes, - _Category.nosql => _noSqlTypes, + List get _categoryTypes => switch (_category) { + _Category.all => ExtensionDriverCatalog.allChoices(), + _Category.sql => ExtensionDriverCatalog.sqlChoices(), + _Category.nosql => ExtensionDriverCatalog.noSqlChoices(), }; - List get _filteredTypes { + List get _filteredTypes { if (_searchQuery.trim().isEmpty) return _categoryTypes; final q = _searchQuery.trim().toLowerCase(); return _categoryTypes @@ -103,6 +102,18 @@ class _NewConnectionDialogContentState .toList(); } + bool _sameChoice(ConnectionTypeChoice? a, ConnectionTypeChoice? b) { + if (identical(a, b)) return true; + if (a == null || b == null) return false; + return switch ((a, b)) { + (BuiltInConnectionType a, BuiltInConnectionType b) => a.type == b.type, + (ExtensionDriverChoice a, ExtensionDriverChoice b) => + a.manifest.id == b.manifest.id && + a.driver.driverId == b.driver.driverId, + _ => false, + }; + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; @@ -167,9 +178,10 @@ class _NewConnectionDialogContentState placeholder: const Text('Search...'), onChanged: (v) => setState(() { _searchQuery = v; - if (_selectedType != null && - !_filteredTypes.contains(_selectedType)) { - _selectedType = null; + if (_selected != null && + !_filteredTypes.any( + (t) => _sameChoice(t, _selected))) { + _selected = null; } }), ), @@ -181,19 +193,20 @@ class _NewConnectionDialogContentState _FilterDropdowns( stackVertically: stackFilters, category: _category, - selectedType: _selectedType, + selected: _selected, filteredTypes: _filteredTypes, onCategoryChanged: (category) { setState(() { _category = category; - if (_selectedType != null && - !_categoryTypes.contains(_selectedType)) { - _selectedType = null; + if (_selected != null && + !_categoryTypes.any( + (t) => _sameChoice(t, _selected))) { + _selected = null; } }); }, onTypeChanged: (type) => - setState(() => _selectedType = type), + setState(() => _selected = type), ), ], ), @@ -234,11 +247,11 @@ class _NewConnectionDialogContentState children: [ for (final t in _filteredTypes) _DbTypeCard( - type: t, + choice: t, theme: theme, - selected: _selectedType == t, + selected: _sameChoice(_selected, t), onTap: () => - setState(() => _selectedType = t), + setState(() => _selected = t), ), ], ), @@ -266,10 +279,10 @@ class _NewConnectionDialogContentState ), const material.SizedBox(width: 12), PrimaryButton( - onPressed: _selectedType == null + onPressed: _selected == null ? null : () => - material.Navigator.of(context).pop(_selectedType), + material.Navigator.of(context).pop(_selected), child: const Text('Next'), ), ], @@ -287,7 +300,7 @@ class _FilterDropdowns extends StatelessWidget { const _FilterDropdowns({ required this.stackVertically, required this.category, - required this.selectedType, + required this.selected, required this.filteredTypes, required this.onCategoryChanged, required this.onTypeChanged, @@ -295,10 +308,10 @@ class _FilterDropdowns extends StatelessWidget { final bool stackVertically; final _Category category; - final ConnectionType? selectedType; - final List filteredTypes; + final ConnectionTypeChoice? selected; + final List filteredTypes; final void Function(_Category category) onCategoryChanged; - final void Function(ConnectionType? type) onTypeChanged; + final void Function(ConnectionTypeChoice? type) onTypeChanged; static const _categoryItems = [ QueryaDropdownItem( @@ -341,17 +354,30 @@ class _FilterDropdowns extends StatelessWidget { children: [ const Text('Database type').small().muted(), const material.SizedBox(height: 4), - QueryaDropdown( - value: selectedType, + QueryaDropdown( + value: selected, hint: filteredTypes.isEmpty ? 'No matches' : 'Select database…', enabled: filteredTypes.isNotEmpty, expandToParent: true, items: [ for (final type in filteredTypes) - QueryaDropdownItem( + QueryaDropdownItem( value: type, label: type.label, - leading: material.Icon(type.icon, size: 18), + leading: type.iconFile != null + ? DriverIconImage( + path: type.iconFile!, + size: 18, + fallbackIcon: type.icon, + ) + : type.iconAsset != null + ? material.Image.asset( + type.iconAsset!, + width: 18, + height: 18, + fit: material.BoxFit.contain, + ) + : material.Icon(type.icon, size: 18), ), ], onSelected: onTypeChanged, @@ -383,13 +409,13 @@ class _FilterDropdowns extends StatelessWidget { class _DbTypeCard extends material.StatefulWidget { const _DbTypeCard({ - required this.type, + required this.choice, required this.theme, required this.selected, required this.onTap, }); - final ConnectionType type; + final ConnectionTypeChoice choice; final ColorScheme theme; final bool selected; final VoidCallback onTap; @@ -436,14 +462,20 @@ class _DbTypeCardState extends material.State<_DbTypeCard> { child: material.SizedBox( width: 52, height: 52, - child: widget.type.iconAsset != null - ? material.Image.asset( - widget.type.iconAsset!, - fit: material.BoxFit.contain, - filterQuality: material.FilterQuality.medium, + child: widget.choice.iconFile != null + ? DriverIconImage( + path: widget.choice.iconFile!, + size: 52, + fallbackIcon: widget.choice.icon, ) - : material.Icon(widget.type.icon, - size: 52, color: t.primary), + : widget.choice.iconAsset != null + ? material.Image.asset( + widget.choice.iconAsset!, + fit: material.BoxFit.contain, + filterQuality: material.FilterQuality.medium, + ) + : material.Icon(widget.choice.icon, + size: 52, color: t.primary), ), ), ), @@ -460,7 +492,7 @@ class _DbTypeCardState extends material.State<_DbTypeCard> { maxWidth: math.max(48.0, lc.maxWidth), ), child: material.Text( - widget.type.label, + widget.choice.label, textAlign: material.TextAlign.center, maxLines: 2, overflow: material.TextOverflow.ellipsis, diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart new file mode 100644 index 00000000..a1dce00b --- /dev/null +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -0,0 +1,354 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; +import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Table/view selected in the sidebar tree of an extension connection. +typedef ExtensionSelectedObject = ({String database, String name}); + +/// Ad-hoc SQL editor + results for extension database drivers (Block D). +/// +/// Executes queries through [ExtensionDriverSession] (`db.query` JSON-RPC). +/// When [selectedObject] is set, seeds and auto-runs a preview query so +/// clicking a table in the sidebar opens its data. +class ExtensionSqlWorkspace extends material.StatefulWidget { + const ExtensionSqlWorkspace({ + super.key, + required this.connectionRow, + this.selectedObject, + }); + + final ConnectionRow connectionRow; + final ExtensionSelectedObject? selectedObject; + + @override + material.State createState() => + _ExtensionSqlWorkspaceState(); +} + +class _ExtensionSqlWorkspaceState + extends material.State { + final _sqlController = material.TextEditingController(); + final ValueNotifier _topFraction = ValueNotifier(0.6); + + bool _running = false; + String? _error; + List _columns = []; + List> _rows = []; + String? _statusLine; + + int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; + double _editorFontSize = kDefaultSqlEditorFontSize; + + static const _previewRowLimit = 200; + + @override + void initState() { + super.initState(); + material.WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_loadWorkspaceSettings()); + _applySelectedObject(); + }); + } + + @override + void didUpdateWidget(covariant ExtensionSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + final obj = widget.selectedObject; + final old = oldWidget.selectedObject; + final changed = obj != null && + (old == null || old.database != obj.database || old.name != obj.name); + if (changed) { + _applySelectedObject(); + } + } + + void _applySelectedObject() { + final obj = widget.selectedObject; + if (obj == null) return; + final sql = + 'SELECT * FROM `${obj.database}`.`${obj.name}` LIMIT $_previewRowLimit'; + _sqlController.value = material.TextEditingValue( + text: sql, + selection: material.TextSelection.collapsed(offset: sql.length), + ); + unawaited(_execute()); + } + + Future _loadWorkspaceSettings() async { + final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); + final font = await AppSettings.instance.getSqlEditorFontSize(); + if (!mounted) return; + setState(() { + _historyMaxEntries = hist; + _editorFontSize = font; + }); + } + + @override + void dispose() { + _topFraction.dispose(); + _sqlController.dispose(); + super.dispose(); + } + + Future _execute() async { + if (_running) return; + final selection = _sqlController.selection; + String userSql; + if (selection.isValid && !selection.isCollapsed) { + userSql = selection.textInside(_sqlController.text).trim(); + } else { + userSql = _sqlController.text.trim(); + } + if (userSql.isEmpty) return; + + setState(() { + _running = true; + _error = null; + _columns = []; + _rows = []; + _statusLine = null; + }); + + try { + final result = await ExtensionDriverSession.instance + .query(widget.connectionRow, userSql); + if (!mounted) return; + + setState(() { + _columns = result.columns; + _rows = result.rows; + if (result.columns.isEmpty && result.rows.isEmpty) { + _statusLine = result.message ?? 'Command completed.'; + } else { + final elapsed = + result.elapsedMs != null ? ' in ${result.elapsedMs}ms' : ''; + _statusLine = '${result.rows.length} row(s)$elapsed.'; + } + _running = false; + }); + + final cid = widget.connectionRow.id; + if (cid != null) { + unawaited( + LocalDb.instance.recordSqlQueryHistory( + connectionId: cid, + databaseName: widget.connectionRow.databaseName, + sqlText: userSql, + maxEntries: _historyMaxEntries, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _running = false; + }); + } + } + } + + Future _openSqlFile() async { + try { + final file = await openFile( + acceptedTypeGroups: const [ + XTypeGroup(label: 'SQL query', extensions: ['sql']), + ], + ); + if (file == null) return; + final text = await file.readAsString(); + if (!mounted) return; + _sqlController.value = material.TextEditingValue( + text: text, + selection: material.TextSelection.collapsed(offset: text.length), + ); + } catch (_) {} + } + + Future _saveSqlFile() async { + try { + final name = + 'query_${DateTime.now().toIso8601String().replaceAll(':', '-')}.sql'; + final location = await getSaveLocation( + acceptedTypeGroups: const [ + XTypeGroup(label: 'SQL', extensions: ['sql']), + ], + suggestedName: name, + ); + final path = location?.path; + if (path == null || path.isEmpty) return; + await File(path).writeAsString(_sqlController.text); + } catch (_) {} + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_running) { + unawaited(_execute()); + } + }, + }, + child: material.Focus( + autofocus: true, + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _ExtensionSqlToolbar( + connectionName: widget.connectionRow.name, + onExecute: _running ? null : _execute, + running: _running, + onOpenSqlFile: () => unawaited(_openSqlFile()), + onSaveSqlFile: () => unawaited(_saveSqlFile()), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, + ), + const Divider(height: 1), + material.Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), + ), + ], + ), + bottom: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + constraints: const material.BoxConstraints(minHeight: 44), + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: const Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + material.Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + statusLine: _statusLine, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ExtensionSqlToolbar extends material.StatelessWidget { + const _ExtensionSqlToolbar({ + required this.connectionName, + required this.onExecute, + required this.running, + required this.onOpenSqlFile, + required this.onSaveSqlFile, + this.onOpenHistory, + }); + + final String connectionName; + final Future Function()? onExecute; + final bool running; + final VoidCallback onOpenSqlFile; + final VoidCallback onSaveSqlFile; + final VoidCallback? onOpenHistory; + + @override + material.Widget build(material.BuildContext context) { + final accent = context.workbench.accent; + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: SqlEditorChrome.sqlToolbarDecoration(context), + child: material.Row( + children: [ + material.Flexible( + child: Text('Query · $connectionName').semiBold().small(), + ), + const Spacer(), + IconButton.ghost( + onPressed: running ? null : onOpenSqlFile, + icon: material.Icon( + material.Icons.folder_open_rounded, + size: 18, + color: accent, + ), + ), + const Gap(4), + IconButton.ghost( + onPressed: onSaveSqlFile, + icon: material.Icon( + material.Icons.save_outlined, + size: 18, + color: accent, + ), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: onOpenHistory, + leading: material.Icon( + material.Icons.history_rounded, + size: 16, + color: accent, + ), + child: const Text('History'), + ), + const Gap(8), + OutlineButton( + onPressed: onExecute, + leading: running + ? material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: accent, + ), + ) + : material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + color: accent, + ), + child: const Text('Execute (F5)'), + ), + ], + ), + ); + } +} diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart new file mode 100644 index 00000000..9a840926 --- /dev/null +++ b/lib/features/extensions/extension_table_view.dart @@ -0,0 +1,190 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _defaultPageSize = 200; + +/// Paginated data browser for extension driver tables and views. +class ExtensionTableView extends material.StatefulWidget { + const ExtensionTableView({ + super.key, + required this.connectionRow, + required this.database, + required this.tableName, + this.isView = false, + this.pageSize = _defaultPageSize, + }); + + final ConnectionRow connectionRow; + final String database; + final String tableName; + final bool isView; + final int pageSize; + + @override + material.State createState() => _ExtensionTableViewState(); +} + +class _ExtensionTableViewState extends material.State { + bool _loading = true; + String? _error; + List _columns = []; + List> _rows = []; + int _offset = 0; + int? _totalRows; + String? _statusLine; + + String get _qualifiedName => + '`${widget.database}`.`${widget.tableName}`'; + + @override + void initState() { + super.initState(); + unawaited(_loadPage()); + } + + @override + void didUpdateWidget(covariant ExtensionTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database || + oldWidget.tableName != widget.tableName) { + _offset = 0; + unawaited(_loadPage()); + } + } + + Future _loadPage({bool refreshCount = false}) async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + + try { + if (refreshCount || _totalRows == null) { + final countResult = await ExtensionDriverSession.instance.query( + widget.connectionRow, + 'SELECT count() AS cnt FROM $_qualifiedName', + ); + if (countResult.rows.isNotEmpty && countResult.rows.first.isNotEmpty) { + _totalRows = int.tryParse(countResult.rows.first.first); + } + } + + final dataResult = await ExtensionDriverSession.instance.query( + widget.connectionRow, + 'SELECT * FROM $_qualifiedName LIMIT ${widget.pageSize} OFFSET $_offset', + ); + + if (!mounted) return; + setState(() { + _columns = dataResult.columns; + _rows = dataResult.rows; + _loading = false; + final total = _totalRows; + final shownFrom = _rows.isEmpty ? 0 : _offset + 1; + final shownTo = _offset + _rows.length; + _statusLine = total == null + ? 'Showing $shownTo row(s).' + : 'Rows $shownFrom–$shownTo of $total.'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + bool get _canGoBack => _offset > 0; + + bool get _canGoForward { + final total = _totalRows; + if (total == null) return _rows.length >= widget.pageSize; + return _offset + widget.pageSize < total; + } + + void _previousPage() { + if (!_canGoBack || _loading) return; + _offset = (_offset - widget.pageSize).clamp(0, 1 << 30); + unawaited(_loadPage()); + } + + void _nextPage() { + if (!_canGoForward || _loading) return; + _offset += widget.pageSize; + unawaited(_loadPage()); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final kind = widget.isView ? 'View' : 'Table'; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.5), + border: material.Border( + bottom: material.BorderSide( + color: theme.colorScheme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Row( + children: [ + material.Expanded( + child: Text('$kind · ${widget.database}.${widget.tableName}') + .semiBold() + .small(), + ), + OutlineButton( + size: ButtonSize.small, + onPressed: _loading ? null : () => _loadPage(refreshCount: true), + child: const Text('Refresh'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _canGoBack && !_loading ? _previousPage : null, + child: const Text('Previous'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _canGoForward && !_loading ? _nextPage : null, + child: const Text('Next'), + ), + ], + ), + ), + if (_statusLine != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(12, 8, 12, 0), + child: Text(_statusLine!).muted().xSmall(), + ), + const Divider(height: 1), + material.Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _loading, + ), + ), + ], + ); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 6c9b4616..8e0c52d9 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart' as material Widget, RepaintBoundary; import 'package:querya_desktop/core/actions/sql_editor_global_actions.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -124,6 +125,18 @@ class _MainScreenState extends State { _workspace.value = _workspace.value.openSqliteSqlWorkspace(connection); } + void _onExtensionObjectSelected( + ConnectionRow connection, + String database, + String name, + ) { + _workspace.value = _workspace.value.selectExtensionObject( + connection, + database, + name, + ); + } + void _openSqlWorkspaceForConnection(ConnectionRow connection) { switch (connection.type) { case 'postgresql': @@ -132,6 +145,10 @@ class _MainScreenState extends State { _onMysqlOpenSqlWorkspace(connection); case 'sqlite': _onSqliteOpenSqlWorkspace(connection); + default: + if (ExtensionDriverCatalog.isExtensionDriverConnection(connection)) { + _workspace.value = _workspace.value.selectConnection(connection); + } } } @@ -222,6 +239,7 @@ class _MainScreenState extends State { onPostgresObjectSelected: _onPostgresObjectSelected, onMysqlObjectSelected: _onMysqlObjectSelected, onSqliteObjectSelected: _onSqliteObjectSelected, + onExtensionObjectSelected: _onExtensionObjectSelected, onRedisDatabaseSelected: _onRedisDatabaseSelected, onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, @@ -249,6 +267,7 @@ class _MainContentSplit extends StatefulWidget { required this.onPostgresObjectSelected, required this.onMysqlObjectSelected, required this.onSqliteObjectSelected, + required this.onExtensionObjectSelected, required this.onRedisDatabaseSelected, required this.onMongoDBDatabaseSelected, required this.onPostgresOpenSqlWorkspace, @@ -278,6 +297,11 @@ class _MainContentSplit extends StatefulWidget { String name, SqliteObjectKind kind, ) onSqliteObjectSelected; + final void Function( + ConnectionRow, + String database, + String name, + ) onExtensionObjectSelected; final void Function(ConnectionRow, int) onRedisDatabaseSelected; final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; @@ -334,6 +358,7 @@ class _MainContentSplitState extends State<_MainContentSplit> { onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, + onExtensionObjectSelected: widget.onExtensionObjectSelected, ), ), ), @@ -367,6 +392,7 @@ class _MainContentSplitState extends State<_MainContentSplit> { mysqlSqlTabRequestToken: ws.mysqlSqlTabRequestToken, selectedSqliteObject: ws.selectedSqliteObject, sqliteSqlTabRequestToken: ws.sqliteSqlTabRequestToken, + selectedExtensionObject: ws.selectedExtensionObject, isReadOnly: ws.isReadOnly, onRequestNewConnection: widget.onRequestNewConnection, ); @@ -395,6 +421,7 @@ class _ConnectionsPanelSlot extends StatefulWidget { required this.onPostgresOpenSqlWorkspace, required this.onMysqlOpenSqlWorkspace, required this.onSqliteOpenSqlWorkspace, + required this.onExtensionObjectSelected, }); final GlobalKey connectionsPanelKey; @@ -423,6 +450,11 @@ class _ConnectionsPanelSlot extends StatefulWidget { final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; final void Function(ConnectionRow) onSqliteOpenSqlWorkspace; + final void Function( + ConnectionRow, + String database, + String name, + ) onExtensionObjectSelected; @override State<_ConnectionsPanelSlot> createState() => _ConnectionsPanelSlotState(); @@ -465,6 +497,7 @@ class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, + onExtensionObjectSelected: widget.onExtensionObjectSelected, ); } } diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index a973d4a8..70ecc3df 100644 --- a/lib/features/main_screen/main_screen_workspace_state.dart +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -19,6 +19,7 @@ class MainScreenWorkspaceState { this.mysqlSqlTabRequestToken = 0, this.selectedSqliteObject, this.sqliteSqlTabRequestToken = 0, + this.selectedExtensionObject, this.isReadOnly = false, }); @@ -54,6 +55,12 @@ class MainScreenWorkspaceState { SqliteObjectKind kind })? selectedSqliteObject; final int sqliteSqlTabRequestToken; + + /// Table/view selected in the sidebar tree of an extension driver connection. + final ({ + String database, + String name, + })? selectedExtensionObject; final bool isReadOnly; static const empty = MainScreenWorkspaceState(); @@ -71,6 +78,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: selectedSqliteObject, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + selectedExtensionObject: selectedExtensionObject, isReadOnly: !isReadOnly, ); } @@ -88,6 +96,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + selectedExtensionObject: null, isReadOnly: false, ); } @@ -170,6 +179,31 @@ class MainScreenWorkspaceState { ); } + MainScreenWorkspaceState selectExtensionObject( + ConnectionRow connection, + String database, + String name, + ) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: null, + activeMongoDB: null, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: null, + postgresSqlEditorContextToken: 0, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + selectedSqliteObject: null, + sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + selectedExtensionObject: ( + database: database, + name: name, + ), + isReadOnly: isReadOnly, + ); + } + MainScreenWorkspaceState selectRedisDb(ConnectionRow connection, int db) { return MainScreenWorkspaceState( activeConnection: connection, @@ -313,6 +347,8 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken == other.mysqlSqlTabRequestToken && _sqliteEquals(selectedSqliteObject, other.selectedSqliteObject) && sqliteSqlTabRequestToken == other.sqliteSqlTabRequestToken && + _extensionEquals( + selectedExtensionObject, other.selectedExtensionObject) && isReadOnly == other.isReadOnly; } @@ -354,10 +390,25 @@ class MainScreenWorkspaceState { selectedSqliteObject!.kind, ), sqliteSqlTabRequestToken, + selectedExtensionObject == null + ? 0 + : Object.hash( + selectedExtensionObject!.database, + selectedExtensionObject!.name, + ), isReadOnly, ); } +bool _extensionEquals( + ({String database, String name})? a, + ({String database, String name})? b, +) { + if (identical(a, b)) return true; + if (a == null || b == null) return false; + return a.database == b.database && a.name == b.name; +} + bool _pgEquals( ({String database, String schema, String name, PostgresObjectKind kind})? a, ({String database, String schema, String name, PostgresObjectKind kind})? b, diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 64bff397..89bba87f 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -28,6 +28,7 @@ import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -42,6 +43,8 @@ import 'package:querya_desktop/features/postgresql/postgres_workspace_home.dart' import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart' show SqliteObjectKind; +import 'package:querya_desktop/features/extensions/extension_sql_workspace.dart'; +import 'package:querya_desktop/features/extensions/extension_table_view.dart'; import 'package:querya_desktop/features/sqlite/sqlite_table_view.dart'; import 'package:querya_desktop/features/sqlite/sqlite_workspace_home.dart'; import 'query_editor_tab.dart'; @@ -63,6 +66,7 @@ class WorkspacePanel extends StatefulWidget { this.mysqlSqlTabRequestToken = 0, this.selectedSqliteObject, this.sqliteSqlTabRequestToken = 0, + this.selectedExtensionObject, this.isReadOnly = false, this.onRequestNewConnection, }); @@ -119,6 +123,12 @@ class WorkspacePanel extends StatefulWidget { /// Incremented by [MainScreen] to switch the SQLite home view to the SQL tab. final int sqliteSqlTabRequestToken; + /// When set, the user selected a table/view in an extension driver tree. + final ({ + String database, + String name, + })? selectedExtensionObject; + /// Empty-state hero: primary CTA to add a connection. final void Function()? onRequestNewConnection; @@ -234,6 +244,24 @@ class _WorkspacePanelState extends State { isView: sq.kind == SqliteObjectKind.view, ); break; + default: + if (ExtensionDriverCatalog.isExtensionDriverConnection(activeConn)) { + final obj = widget.selectedExtensionObject; + driverWorkspace = obj == null + ? ExtensionSqlWorkspace( + key: ValueKey('ext_sql_${activeConn.id}'), + connectionRow: activeConn, + ) + : ExtensionTableView( + key: ValueKey( + 'ext_table_${activeConn.id}_${obj.database}_${obj.name}', + ), + connectionRow: activeConn, + database: obj.database, + tableName: obj.name, + ); + } + break; } if (driverWorkspace != null) { @@ -481,3 +509,4 @@ class _ComingSoonTab extends StatelessWidget { ); } } + diff --git a/pubspec.yaml b/pubspec.yaml index 771e5f7b..b5d795c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: archive: ^4.0.9 url_launcher: ^6.3.1 package_info_plus: ^8.3.0 + flutter_svg: ^2.3.0 dev_dependencies: flutter_test: diff --git a/test/core/actions/sql_editor_global_actions_test.dart b/test/core/actions/sql_editor_global_actions_test.dart index 1a8b6b71..4f9e8ff5 100644 --- a/test/core/actions/sql_editor_global_actions_test.dart +++ b/test/core/actions/sql_editor_global_actions_test.dart @@ -109,7 +109,7 @@ void main() { expect( find.text( - 'Select a PostgreSQL, MySQL, or SQLite connection to edit SQL files.', + 'Select a SQL-capable connection (PostgreSQL, MySQL, SQLite, or an installed driver) to edit SQL files.', ), findsOneWidget, ); diff --git a/test/core/extensions/extension_driver_catalog_test.dart b/test/core/extensions/extension_driver_catalog_test.dart new file mode 100644 index 00000000..695ef800 --- /dev/null +++ b/test/core/extensions/extension_driver_catalog_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/extension_connection_form.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; + +void main() { + group('ExtensionDriverCatalog', () { + test('built-ins cover five drivers', () { + expect(ExtensionDriverCatalog.builtInChoices, hasLength(5)); + expect( + ExtensionDriverCatalog.builtInChoices + .whereType() + .map((c) => c.type), + containsAll([ + ConnectionType.postgresql, + ConnectionType.mysql, + ConnectionType.sqlite, + ConnectionType.redis, + ConnectionType.mongodb, + ]), + ); + }); + }); + + group('connectionRowFromExtensionForm', () { + test('maps host/port/username and stores extras in driverOptions', () { + const manifest = ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: {}, + installPath: '/tmp/ext', + ); + const driver = DriverContribution( + driverId: 'clickhouse', + displayName: 'ClickHouse', + defaultPort: 8123, + ); + + final row = connectionRowFromExtensionForm( + manifest: manifest, + driver: driver, + name: 'Local CH', + values: { + 'host': '127.0.0.1', + 'port': 8123, + 'username': 'querya', + 'password': 'secret', + 'sslMode': 'prefer', + 'safe_mode': true, + }, + ); + + expect(row.type, 'clickhouse'); + expect(row.extensionId, 'queryahub.clickhouse-driver'); + expect(row.host, '127.0.0.1'); + expect(row.port, 8123); + expect(row.username, 'querya'); + expect(row.password, 'secret'); + expect(row.useSSL, isTrue); + expect(row.isExtensionDriver, isTrue); + expect(row.driverOptions, contains('safe_mode')); + expect(row.driverOptions, isNot(contains('password'))); + expect(row.driverOptions, isNot(contains('sslMode'))); + }); + }); + + group('ConnectionTypeChoice equality', () { + test('built-ins compare by enum', () { + expect( + const BuiltInConnectionType(ConnectionType.mysql), + const BuiltInConnectionType(ConnectionType.mysql), + ); + }); + + test('extension choices compare by package + driverId', () { + const m = ExtensionManifest( + id: 'pkg.a', + name: 'A', + version: '1', + publisher: 'p', + type: ExtensionType.databaseDriver, + engines: {}, + ); + const d = DriverContribution(driverId: 'x', displayName: 'X'); + expect( + const ExtensionDriverChoice(manifest: m, driver: d), + const ExtensionDriverChoice(manifest: m, driver: d), + ); + }); + }); +} diff --git a/test/core/extensions/extension_driver_session_test.dart b/test/core/extensions/extension_driver_session_test.dart new file mode 100644 index 00000000..6db7b7d7 --- /dev/null +++ b/test/core/extensions/extension_driver_session_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +void main() { + group('ExtensionDriverSession', () { + test('disconnect is a no-op when no session exists', () async { + await ExtensionDriverSession.instance.disconnect(424242); + }); + + test('disconnectAll is safe when empty', () async { + await ExtensionDriverSession.instance.disconnectAll(); + }); + + test('ConnectionRow.isExtensionDriver', () { + const withExt = ConnectionRow( + type: 'clickhouse', + name: 'CH', + extensionId: 'queryahub.clickhouse-driver', + createdAt: '2026-01-01T00:00:00Z', + ); + const builtIn = ConnectionRow( + type: 'postgresql', + name: 'PG', + createdAt: '2026-01-01T00:00:00Z', + ); + expect(withExt.isExtensionDriver, isTrue); + expect(builtIn.isExtensionDriver, isFalse); + }); + + test('buildExtensionConnectParams uses https when useSSL is true', () { + const row = ConnectionRow( + type: 'clickhouse', + name: 'CH', + host: 'db.local', + port: 8443, + username: 'default', + databaseName: 'analytics', + useSSL: true, + createdAt: '2026-01-01T00:00:00Z', + ); + + final params = ExtensionDriverSession.buildExtensionConnectParams( + connectionId: 42, + row: row, + safeMode: true, + ); + + expect(params['connectionString'], 'https://db.local:8443/analytics'); + expect(params['user'], 'default'); + expect(params['safeMode'], isTrue); + }); + }); +} diff --git a/test/core/extensions/local_extension_installer_test.dart b/test/core/extensions/local_extension_installer_test.dart index a5689ccd..10239dfe 100644 --- a/test/core/extensions/local_extension_installer_test.dart +++ b/test/core/extensions/local_extension_installer_test.dart @@ -186,5 +186,90 @@ void main() { ); expect(installed.id, 'test.sha-ok'); }); + + test('preserves contributions and capabilities in installed manifest', + () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.clickhouse', + 'name': 'CH', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'database_driver', + 'engines': {'querya_desktop': '*'}, + 'main': 'bin/driver', + 'capabilities': { + 'databaseDriver': true, + 'sduiForms': true, + }, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + 'contributions': { + 'drivers': [ + { + 'driverId': 'clickhouse', + 'displayName': 'ClickHouse', + 'defaultPort': 8123, + 'connectionFormSchema': 'assets/connection_form.json', + } + ] + }, + })) + ..addFile(ArchiveFile('bin/driver', 4, utf8.encode('stub'))); + + final zip = await _writeZip(tempDir, archive, 'ch.zip'); + final installed = + await LocalExtensionInstaller().installFromArchive(zip); + + expect(installed.contributedDrivers, hasLength(1)); + expect(installed.contributedDrivers.first.driverId, 'clickhouse'); + expect(installed.capabilities?.databaseDriver, isTrue); + + final onDisk = await File( + p.join(tempDir.path, 'test.clickhouse', 'manifest.json'), + ).readAsString(); + final decoded = jsonDecode(onDisk) as Map; + expect(decoded['contributions'], isA()); + expect(decoded['capabilities'], isA()); + expect( + (decoded['contributions'] as Map)['drivers'], + isA(), + ); + }); + + test('marks database driver bin entry executable after install', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.driver-exec', + 'name': 'Driver', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'database_driver', + 'engines': {'querya_desktop': '*'}, + 'main': 'bin/driver', + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + })) + ..addFile(ArchiveFile('bin/driver', 4, utf8.encode('stub'))); + + final zip = await _writeZip(tempDir, archive, 'driver-exec.zip'); + await LocalExtensionInstaller().installFromArchive(zip); + + final entry = File(p.join(tempDir.path, 'test.driver-exec', 'bin', 'driver')); + final mode = await entry.stat().then((s) => s.mode); + expect(mode & 0x111, isNot(0)); + }); }); } diff --git a/test/core/extensions/models/extension_manifest_test.dart b/test/core/extensions/models/extension_manifest_test.dart index e367a2bd..88f9dfe2 100644 --- a/test/core/extensions/models/extension_manifest_test.dart +++ b/test/core/extensions/models/extension_manifest_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; void main() { group('ExtensionManifest', () { @@ -11,9 +12,7 @@ void main() { 'version': '1.0.0', 'publisher': 'QueryaHub', 'type': 'database_driver', - 'engines': { - 'querya_desktop': '^0.5.0' - }, + 'engines': {'querya_desktop': '^0.5.0'}, 'main': 'bin/clickhouse_plugin', 'icon': 'assets/icon.svg', 'description': 'Full support for ClickHouse databases' @@ -39,9 +38,7 @@ void main() { 'version': '1.0.0', 'publisher': 'QueryaHub', 'type': 'theme', - 'engines': { - 'querya_desktop': '^0.5.0' - } + 'engines': {'querya_desktop': '^0.5.0'} }; final manifest = ExtensionManifest.fromJson(json); @@ -51,6 +48,95 @@ void main() { expect(manifest.main, isNull); expect(manifest.icon, isNull); expect(manifest.description, isNull); + expect(manifest.capabilities, isNull); + expect(manifest.contributions, isNull); + }); + + test('parses capabilities and contributions (ClickHouse-like)', () { + final json = { + 'id': 'queryahub.clickhouse-driver', + 'name': 'ClickHouse Database Driver (Analyst Edition)', + 'version': '1.0.0', + 'publisher': 'Querya Community', + 'type': 'database_driver', + 'engines': {'querya_desktop': '^2.0.0'}, + 'main': 'bin/clickhouse_rpc_server', + 'icon': 'assets/icon.svg', + 'description': 'ClickHouse driver', + 'capabilities': { + 'databaseDriver': true, + 'sduiForms': true, + }, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + 'contributions': { + 'drivers': [ + { + 'driverId': 'clickhouse', + 'displayName': 'ClickHouse (Analyst Edition)', + 'defaultPort': 8123, + 'connectionFormSchema': 'assets/connection_form.json', + } + ] + }, + }; + + final manifest = ExtensionManifest.fromJson(json); + + expect(manifest.capabilities?.databaseDriver, isTrue); + expect(manifest.capabilities?.sduiForms, isTrue); + expect(manifest.contributedDrivers, hasLength(1)); + final driver = manifest.contributedDrivers.first; + expect(driver.driverId, 'clickhouse'); + expect(driver.displayName, 'ClickHouse (Analyst Edition)'); + expect(driver.defaultPort, 8123); + expect(driver.connectionFormSchema, 'assets/connection_form.json'); + expect(manifest.sandbox?.engine, SandboxEngine.process); + }); + + test('toJson round-trips contributions and capabilities', () { + final original = { + 'id': 'queryahub.clickhouse-driver', + 'name': 'ClickHouse', + 'version': '1.0.0', + 'publisher': 'Querya Community', + 'type': 'database_driver', + 'engines': {'querya_desktop': '^2.0.0'}, + 'main': 'bin/clickhouse_rpc_server', + 'capabilities': { + 'databaseDriver': true, + 'sduiForms': true, + }, + 'contributions': { + 'drivers': [ + { + 'driverId': 'clickhouse', + 'displayName': 'ClickHouse', + 'defaultPort': 8123, + 'connectionFormSchema': 'assets/connection_form.json', + } + ] + }, + }; + + final manifest = ExtensionManifest.fromJson(original); + final encoded = manifest.toJson(); + expect(encoded['capabilities'], isA()); + expect(encoded['contributions'], isA()); + + final again = ExtensionManifest.fromJson(encoded); + expect(again.capabilities?.databaseDriver, isTrue); + expect(again.contributedDrivers.first.driverId, 'clickhouse'); + expect( + again.contributedDrivers.first.connectionFormSchema, + 'assets/connection_form.json', + ); }); test('falls back to unknown type for unrecognized extension types', () { @@ -69,9 +155,7 @@ void main() { }); test('throws type error on completely invalid json structure', () { - final json = { - 'id': 'missing_everything_else' - }; + final json = {'id': 'missing_everything_else'}; expect(() => ExtensionManifest.fromJson(json), throwsA(isA())); }); diff --git a/test/core/extensions/sandbox/sandbox_process_runner_test.dart b/test/core/extensions/sandbox/sandbox_process_runner_test.dart index ad3089d2..b2211b71 100644 --- a/test/core/extensions/sandbox/sandbox_process_runner_test.dart +++ b/test/core/extensions/sandbox/sandbox_process_runner_test.dart @@ -330,6 +330,14 @@ void main() { }); }); + group('SandboxProcessRunner.detectBwrapAvailability', () { + test('returns a bool without throwing on linux', () async { + if (!Platform.isLinux) return; + final available = await SandboxProcessRunner.detectBwrapAvailability(); + expect(available, isA()); + }); + }); + group('SandboxProcessRunner integration (bwrap)', () { test('linux bwrap launch path creates scratch and dispose cleans it', () async { diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index b2979463..3181f68e 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -45,6 +45,41 @@ void main() { expect(schema.fields[4].options, hasLength(2)); expect(schema.fields[5].type, SduiFieldType.filePicker); }); + + test('accepts extension-style key and boolean aliases', () { + final schema = SduiFormSchema.fromJson(const { + 'type': 'form', + 'id': 'clickhouse_connection_form', + 'fields': [ + { + 'key': 'host', + 'label': 'Host', + 'type': 'text', + 'required': true, + 'defaultValue': 'localhost', + }, + { + 'key': 'port', + 'label': 'Port', + 'type': 'number', + 'defaultValue': 8123, + }, + { + 'key': 'safe_mode', + 'label': 'Safe Mode', + 'type': 'boolean', + 'defaultValue': true, + }, + ], + }); + + expect(schema.fields, hasLength(3)); + expect(schema.fields[0].id, 'host'); + expect(schema.fields[0].defaultValue, 'localhost'); + expect(schema.fields[1].id, 'port'); + expect(schema.fields[2].id, 'safe_mode'); + expect(schema.fields[2].type, SduiFieldType.checkbox); + }); }); group('SduiFormBuilder', () { @@ -125,6 +160,17 @@ void main() { expect(schema.roots.single.id, 'databases'); expect(schema.roots.single.expandable, isTrue); }); + + test('maps node_type snake_case into meta nodeType', () { + final node = SduiTreeNode.fromJson(const { + 'id': 'table.default.customers', + 'label': 'customers', + 'node_type': 'table', + 'has_children': true, + }); + expect(node.meta['nodeType'], 'table'); + expect(node.expandable, isTrue); + }); }); group('SduiTreeBuilder', () { @@ -166,5 +212,35 @@ void main() { expect(fetches, 1); expect(find.text('analytics'), findsOneWidget); }); + + testWidgets('selects table nodes by id prefix when meta is empty', + (tester) async { + SduiTreeNode? selected; + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'table.default.customers', + 'label': 'customers', + 'has_children': true, + }, + ], + }); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + onNodeSelected: (node) => selected = node, + ), + ), + ), + ); + + await tester.tap(find.text('customers')); + await tester.pumpAndSettle(); + + expect(selected?.id, 'table.default.customers'); + }); }); } diff --git a/test/core/storage/connection_row_test.dart b/test/core/storage/connection_row_test.dart index 3d3233cb..5f7e281c 100644 --- a/test/core/storage/connection_row_test.dart +++ b/test/core/storage/connection_row_test.dart @@ -216,5 +216,35 @@ void main() { ); expect(row.toMap()['database_name'], 'appdb'); }); + + test('extension driver fields round-trip', () { + const row = ConnectionRow( + type: 'clickhouse', + name: 'CH Local', + host: 'localhost', + port: 8123, + username: 'default', + password: 'secret', + extensionId: 'queryahub.clickhouse-driver', + driverOptions: '{"sslMode":"prefer","safe_mode":true}', + useSSL: true, + createdAt: '2026-01-01T00:00:00Z', + ); + + expect(row.isExtensionDriver, isTrue); + final map = row.toPersistenceMap(); + expect(map['extension_id'], 'queryahub.clickhouse-driver'); + expect(map['driver_options'], contains('sslMode')); + expect(map['password'], isNull); + + final restored = ConnectionRow.fromMap({ + ...map, + 'id': 7, + 'password': 'secret', + }); + expect(restored.extensionId, 'queryahub.clickhouse-driver'); + expect(restored.type, 'clickhouse'); + expect(restored.isExtensionDriver, isTrue); + }); }); } diff --git a/test/features/main_screen/workspace_homes_and_preferences_test.dart b/test/features/main_screen/workspace_homes_and_preferences_test.dart index 67c2da66..27f4d17f 100644 --- a/test/features/main_screen/workspace_homes_and_preferences_test.dart +++ b/test/features/main_screen/workspace_homes_and_preferences_test.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; @@ -87,6 +89,22 @@ void main() { }); group('Driver Manager dialog', () { + late Directory extDir; + + setUp(() async { + extDir = await Directory.systemTemp.createTemp('querya_drv_mgr_'); + ExtensionPaths.mockExtensionsDirectory = extDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + }); + testWidgets('showDriverManagerDialog shows built-in drivers copy', (tester) async { await tester.binding.setSurfaceSize(const material.Size(900, 1200)); @@ -111,11 +129,10 @@ void main() { ); await tester.tap(find.text('open-drivers')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); expect(find.text('Driver Manager'), findsOneWidget); - expect(find.textContaining('built-in Dart'), findsWidgets); + expect(find.textContaining('Built-in Dart'), findsWidgets); expect(find.text('SQLite'), findsOneWidget); expect(find.textContaining('sqflite_common_ffi'), findsOneWidget); });