From 81181408d7da038fc030f2e33082cebce5a79e3d Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:21:47 +0300 Subject: [PATCH 01/23] Fix #211: Execute only selected SQL text and strip SQLite comments (#213) --- lib/core/database/sqlite_connection.dart | 6 +- .../extensions/models/extension_manifest.dart | 53 +++++++++++++ .../extensions/models/extension_type.dart | 15 ++++ lib/features/mysql/mysql_sql_workspace.dart | 8 +- .../postgresql/postgres_sql_workspace.dart | 8 +- lib/features/sqlite/sqlite_sql_workspace.dart | 8 +- .../models/extension_manifest_test.dart | 79 +++++++++++++++++++ 7 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 lib/core/extensions/models/extension_manifest.dart create mode 100644 lib/core/extensions/models/extension_type.dart create mode 100644 test/core/extensions/models/extension_manifest_test.dart diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index bfdcf9ac..d4585d4a 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -90,7 +90,11 @@ class SqliteConnection { if (!isConnected || _db == null) { throw StateError('Not connected to SQLite'); } - final sqlLower = sql.trim().toLowerCase(); + final sqlLower = sql + .replaceAll(RegExp(r'--.*$', multiLine: true), '') + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '') + .trim() + .toLowerCase(); // SQLite can execute PRAGMA, SELECT, EXPLAIN statements, which return data final isQuery = sqlLower.startsWith('select') || diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart new file mode 100644 index 00000000..dc15d7e5 --- /dev/null +++ b/lib/core/extensions/models/extension_manifest.dart @@ -0,0 +1,53 @@ +import 'extension_type.dart'; + +class ExtensionManifest { + final String id; + final String name; + final String version; + final String publisher; + final ExtensionType type; + final Map engines; + final String? main; + final String? icon; + final String? description; + + const ExtensionManifest({ + required this.id, + required this.name, + required this.version, + required this.publisher, + required this.type, + required this.engines, + this.main, + this.icon, + this.description, + }); + + factory ExtensionManifest.fromJson(Map json) { + return ExtensionManifest( + id: json['id'] as String, + name: json['name'] as String, + version: json['version'] as String, + publisher: json['publisher'] as String, + type: ExtensionType.fromString(json['type'] as String), + engines: Map.from(json['engines'] as Map? ?? {}), + main: json['main'] as String?, + icon: json['icon'] as String?, + description: json['description'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'version': version, + 'publisher': publisher, + 'type': type.value, + 'engines': engines, + if (main != null) 'main': main, + if (icon != null) 'icon': icon, + if (description != null) 'description': description, + }; + } +} diff --git a/lib/core/extensions/models/extension_type.dart b/lib/core/extensions/models/extension_type.dart new file mode 100644 index 00000000..dc6eeb0f --- /dev/null +++ b/lib/core/extensions/models/extension_type.dart @@ -0,0 +1,15 @@ +enum ExtensionType { + databaseDriver('database_driver'), + theme('theme'), + unknown('unknown'); + + final String value; + const ExtensionType(this.value); + + static ExtensionType fromString(String value) { + return ExtensionType.values.firstWhere( + (e) => e.value == value, + orElse: () => ExtensionType.unknown, + ); + } +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 23fd3f71..e2778fa2 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -122,7 +122,13 @@ class _MysqlSqlWorkspaceState extends material.State { } Future _execute() async { - final userSql = _sqlController.text.trim(); + 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(() { diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 3377009f..d72b79a9 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -265,7 +265,13 @@ class _PostgresSqlWorkspaceState extends material.State { } Future _execute() async { - final userSql = _sqlController.text.trim(); + 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; var sql = injectSqlLimit(userSql, _resultMaxRows); diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 39eba4b2..b2449b70 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -95,7 +95,13 @@ class _SqliteSqlWorkspaceState extends material.State { } Future _execute() async { - final userSql = _sqlController.text.trim(); + 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(() { diff --git a/test/core/extensions/models/extension_manifest_test.dart b/test/core/extensions/models/extension_manifest_test.dart new file mode 100644 index 00000000..e367a2bd --- /dev/null +++ b/test/core/extensions/models/extension_manifest_test.dart @@ -0,0 +1,79 @@ +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'; + +void main() { + group('ExtensionManifest', () { + test('parses valid manifest correctly', () { + final json = { + 'id': 'queryahub.clickhouse-driver', + 'name': 'ClickHouse Database Driver', + 'version': '1.0.0', + 'publisher': 'QueryaHub', + 'type': 'database_driver', + 'engines': { + 'querya_desktop': '^0.5.0' + }, + 'main': 'bin/clickhouse_plugin', + 'icon': 'assets/icon.svg', + 'description': 'Full support for ClickHouse databases' + }; + + final manifest = ExtensionManifest.fromJson(json); + + expect(manifest.id, 'queryahub.clickhouse-driver'); + expect(manifest.name, 'ClickHouse Database Driver'); + expect(manifest.version, '1.0.0'); + expect(manifest.publisher, 'QueryaHub'); + expect(manifest.type, ExtensionType.databaseDriver); + expect(manifest.engines, {'querya_desktop': '^0.5.0'}); + expect(manifest.main, 'bin/clickhouse_plugin'); + expect(manifest.icon, 'assets/icon.svg'); + expect(manifest.description, 'Full support for ClickHouse databases'); + }); + + test('handles missing optional fields', () { + final json = { + 'id': 'queryahub.my-theme', + 'name': 'My Theme', + 'version': '1.0.0', + 'publisher': 'QueryaHub', + 'type': 'theme', + 'engines': { + 'querya_desktop': '^0.5.0' + } + }; + + final manifest = ExtensionManifest.fromJson(json); + + expect(manifest.id, 'queryahub.my-theme'); + expect(manifest.type, ExtensionType.theme); + expect(manifest.main, isNull); + expect(manifest.icon, isNull); + expect(manifest.description, isNull); + }); + + test('falls back to unknown type for unrecognized extension types', () { + final json = { + 'id': 'queryahub.future-plugin', + 'name': 'Future Plugin', + 'version': '1.0.0', + 'publisher': 'QueryaHub', + 'type': 'future_formatter', + 'engines': {} + }; + + final manifest = ExtensionManifest.fromJson(json); + + expect(manifest.type, ExtensionType.unknown); + }); + + test('throws type error on completely invalid json structure', () { + final json = { + 'id': 'missing_everything_else' + }; + + expect(() => ExtensionManifest.fromJson(json), throwsA(isA())); + }); + }); +} From 7bb0e970229342574b888f93891e4954255a90c2 Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:31:08 +0300 Subject: [PATCH 02/23] feat(extensions): implement LocalExtensionRegistry (EXT-2) (#214) - Add ExtensionPaths for locating ~/.querya/extensions - Add LocalExtensionRegistry for parsing manifest.json from extensions dir - Add installPath to ExtensionManifest --- lib/core/extensions/extension_paths.dart | 29 +++++++++ .../extensions/local_extension_registry.dart | 61 +++++++++++++++++++ .../extensions/models/extension_manifest.dart | 5 +- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 lib/core/extensions/extension_paths.dart create mode 100644 lib/core/extensions/local_extension_registry.dart diff --git a/lib/core/extensions/extension_paths.dart b/lib/core/extensions/extension_paths.dart new file mode 100644 index 00000000..f05414f9 --- /dev/null +++ b/lib/core/extensions/extension_paths.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Centralizes extension file locations. +abstract final class ExtensionPaths { + static const _extensionsSegment = 'extensions'; + + /// Returns `~/.querya/extensions` on Linux/Mac, or equivalent `USERPROFILE\.querya\extensions` on Windows. + /// Falls back to application support directory if HOME is unavailable. + static Future extensionsDirectory() async { + final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home == null || home.isEmpty) { + final support = await getApplicationSupportDirectory(); + return Directory(p.join(support.path, _extensionsSegment)); + } + return Directory(p.join(home, '.querya', _extensionsSegment)); + } + + /// Creates the extensions directory if it doesn't exist. + static Future ensureExtensionsDirectory() async { + final dir = await extensionsDirectory(); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } +} diff --git a/lib/core/extensions/local_extension_registry.dart b/lib/core/extensions/local_extension_registry.dart new file mode 100644 index 00000000..ddf17203 --- /dev/null +++ b/lib/core/extensions/local_extension_registry.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'extension_paths.dart'; +import 'models/extension_manifest.dart'; + +/// Scans the local filesystem for extensions and loads their manifests. +class LocalExtensionRegistry { + LocalExtensionRegistry._(); + static final LocalExtensionRegistry instance = LocalExtensionRegistry._(); + + List _manifests = []; + bool _loaded = false; + + /// Returns an unmodifiable list of loaded manifests. + List get manifests => List.unmodifiable(_manifests); + + /// Reloads manifests from the disk. + Future reload() async { + _loaded = false; + await load(); + } + + /// Loads manifests from the extensions directory if not already loaded. + Future> load() async { + if (_loaded) return manifests; + + final dir = await ExtensionPaths.extensionsDirectory(); + final loadedManifests = []; + + if (await dir.exists()) { + // Use list() rather than listSync() to prevent blocking the UI + final entities = await dir.list().toList(); + for (final entity in entities) { + if (entity is Directory) { + final manifestFile = File(p.join(entity.path, 'manifest.json')); + if (await manifestFile.exists()) { + try { + final content = await manifestFile.readAsString(); + final json = jsonDecode(content) as Map; + final manifest = ExtensionManifest.fromJson( + json, + installPath: entity.path, + ); + loadedManifests.add(manifest); + } catch (e) { + // Log or ignore invalid manifests + // In the future, we could report these to an error logging service + } + } + } + } + } + + _manifests = loadedManifests; + _loaded = true; + return manifests; + } +} diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index dc15d7e5..cafa2e04 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -10,6 +10,7 @@ class ExtensionManifest { final String? main; final String? icon; final String? description; + final String? installPath; const ExtensionManifest({ required this.id, @@ -21,9 +22,10 @@ class ExtensionManifest { this.main, this.icon, this.description, + this.installPath, }); - factory ExtensionManifest.fromJson(Map json) { + factory ExtensionManifest.fromJson(Map json, {String? installPath}) { return ExtensionManifest( id: json['id'] as String, name: json['name'] as String, @@ -34,6 +36,7 @@ class ExtensionManifest { main: json['main'] as String?, icon: json['icon'] as String?, description: json['description'] as String?, + installPath: installPath, ); } From a2c81dca2cf14fdc8626e889bc54ea937b5c93a8 Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:51:19 +0300 Subject: [PATCH 03/23] Test: Unit Tests for Extension Registry (EXT-4) (#215) * test(extensions): add unit tests for LocalExtensionRegistry (EXT-4) - Mock ExtensionPaths.extensionsDirectory for testing - Verify registry parses valid manifests - Verify invalid manifests and plain files are ignored - Verify registry cache logic * fix(extensions): use flutter foundation for @visibleForTesting instead of meta to satisfy linter --- lib/core/extensions/extension_paths.dart | 7 ++ .../local_extension_registry_test.dart | 96 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 test/core/extensions/local_extension_registry_test.dart diff --git a/lib/core/extensions/extension_paths.dart b/lib/core/extensions/extension_paths.dart index f05414f9..d2ef4ef4 100644 --- a/lib/core/extensions/extension_paths.dart +++ b/lib/core/extensions/extension_paths.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -7,9 +8,15 @@ import 'package:path_provider/path_provider.dart'; abstract final class ExtensionPaths { static const _extensionsSegment = 'extensions'; + @visibleForTesting + static Directory? mockExtensionsDirectory; + /// Returns `~/.querya/extensions` on Linux/Mac, or equivalent `USERPROFILE\.querya\extensions` on Windows. /// Falls back to application support directory if HOME is unavailable. static Future extensionsDirectory() async { + if (mockExtensionsDirectory != null) { + return mockExtensionsDirectory!; + } final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) { final support = await getApplicationSupportDirectory(); diff --git a/test/core/extensions/local_extension_registry_test.dart b/test/core/extensions/local_extension_registry_test.dart new file mode 100644 index 00000000..f5d88037 --- /dev/null +++ b/test/core/extensions/local_extension_registry_test.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; + +void main() { + group('LocalExtensionRegistry', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_extensions_test'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + }); + + tearDown(() async { + ExtensionPaths.mockExtensionsDirectory = null; + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('loads extensions from manifest files', () async { + // Create a valid extension folder + final ext1Dir = Directory(p.join(tempDir.path, 'ext1')); + await ext1Dir.create(); + + final manifest1 = { + 'id': 'test.ext1', + 'name': 'Test Extension 1', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '^1.0.0'}, + }; + + final file1 = File(p.join(ext1Dir.path, 'manifest.json')); + await file1.writeAsString(jsonEncode(manifest1)); + + // Create an invalid extension folder (no manifest) + final ext2Dir = Directory(p.join(tempDir.path, 'ext2')); + await ext2Dir.create(); + + // Create a file that is not a directory + final notADir = File(p.join(tempDir.path, 'not_a_dir.txt')); + await notADir.writeAsString('I am a file'); + + // Reload registry to scan the test directory + await LocalExtensionRegistry.instance.reload(); + final manifests = LocalExtensionRegistry.instance.manifests; + + expect(manifests.length, 1); + final loaded = manifests.first; + expect(loaded.id, 'test.ext1'); + expect(loaded.type, ExtensionType.theme); + expect(loaded.installPath, ext1Dir.path); + }); + + test('ignores directories with invalid manifest.json', () async { + final extDir = Directory(p.join(tempDir.path, 'bad_ext')); + await extDir.create(); + + final file = File(p.join(extDir.path, 'manifest.json')); + await file.writeAsString('{"invalid_json": '); // Syntax error + + await LocalExtensionRegistry.instance.reload(); + + expect(LocalExtensionRegistry.instance.manifests, isEmpty); + }); + + test('returns cached manifests on subsequent load calls', () async { + final extDir = Directory(p.join(tempDir.path, 'ext3')); + await extDir.create(); + final file = File(p.join(extDir.path, 'manifest.json')); + await file.writeAsString(jsonEncode({ + 'id': 'test.ext3', + 'name': 'Test Ext 3', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {} + })); + + await LocalExtensionRegistry.instance.reload(); + expect(LocalExtensionRegistry.instance.manifests.length, 1); + + // Delete the file. load() shouldn't read from disk again unless reload() is called + await file.delete(); + final manifests = await LocalExtensionRegistry.instance.load(); + expect(manifests.length, 1, reason: 'Should return cached result'); + }); + }); +} From 6c4a0e15510c479ed010e2d60ebec04178b3055a Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:00:57 +0300 Subject: [PATCH 04/23] Feat: Migrate Custom Themes to Extension Registry (EXT-3) (#216) * fix(theme): use braces for multiline if statement * fix(theme): fix theme registry migration unit tests and watcher path * fix(theme): remove unused import theme_paths.dart --- cleanup.py | 32 ++ lib/core/theme/theme_controller.dart | 6 +- lib/core/theme/theme_registry_service.dart | 375 ++++++++--------- patch_theme.py | 391 ++++++++++++++++++ test/core/theme/theme_controller_test.dart | 62 ++- .../core/theme/theme_folder_watcher_test.dart | 33 +- .../theme_registry_builtin_assets_test.dart | 16 + .../core/theme/theme_registry_cache_test.dart | 21 +- .../theme/theme_registry_service_test.dart | 36 +- .../theme_remote_install_service_test.dart | 37 +- .../settings/theme_import_flow_test.dart | 10 +- 11 files changed, 798 insertions(+), 221 deletions(-) create mode 100644 cleanup.py create mode 100644 patch_theme.py diff --git a/cleanup.py b/cleanup.py new file mode 100644 index 00000000..bf4f7dee --- /dev/null +++ b/cleanup.py @@ -0,0 +1,32 @@ +import re + +with open('lib/core/theme/theme_registry_service.dart', 'r') as f: + content = f.read() + +# Fix curly_braces_in_flow_control_structures +content = content.replace("if (!await directory.exists()) continue;", "if (!await directory.exists()) { continue; }") + +# Fix unnecessary_brace_in_string_interps +content = content.replace("'${slug}-$counter'", "'$slug-$counter'") +content = content.replace("'${preferredBaseName}-$counter'", "'$preferredBaseName-$counter'") + +# Fix unused hash +content = content.replace("final hash = _contentHash(raw);\n final json = _decodeRoot(raw);", "final json = _decodeRoot(raw);") +content = content.replace("final hash = _contentHash(raw);\n final json = _decodeRoot(raw);", "final json = _decodeRoot(raw);") # Handle multiple occurrences if any + +# Remove unused methods +methods_to_remove = [ + r'Future _scanDirectory.*?^\s*\}\s*', + r'Future<_ResolvedImportDestination> _resolveImportDestination.*?^\s*\}\s*', + r'Future _nextRenamedThemeId.*?^\s*\}\s*', + r'Future _themeIdExists.*?^\s*\}\s*', + r'Future _nextAvailableThemeFile.*?^\s*\}\s*', + r'String _rewriteCustomThemeId.*?^\s*\}\s*', + r'class _ResolvedImportDestination.*?^\}\s*' +] + +for pattern in methods_to_remove: + content = re.sub(pattern, '', content, flags=re.DOTALL | re.MULTILINE) + +with open('lib/core/theme/theme_registry_service.dart', 'w') as f: + f.write(content) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index b735eec1..f38966e4 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -16,9 +16,9 @@ import 'theme_definition.dart'; import 'theme_folder_watcher.dart'; import 'theme_import_service.dart'; import 'theme_load_result.dart'; -import 'theme_paths.dart'; import 'theme_registry_service.dart'; import 'theme_remote_install_service.dart'; +import '../extensions/extension_paths.dart'; /// Active theme state: preset, optional imported colors, user overrides. class ThemeController extends ChangeNotifier { @@ -214,10 +214,10 @@ class ThemeController extends ChangeNotifier { } } - /// Watches `{appSupport}/themes/` and debounces [loadAvailableThemes]. + /// Watches extensions directory and debounces [loadAvailableThemes]. Future startThemeFolderWatcher() async { _themeFolderWatcher ??= ThemeFolderWatcher( - themesDirectory: ThemePaths.userThemesDirectory, + themesDirectory: ExtensionPaths.extensionsDirectory, onThemesChanged: loadAvailableThemes, ); await _themeFolderWatcher!.start(); diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index ab18e573..4c991311 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -18,6 +18,10 @@ import 'theme_import_service.dart'; import 'theme_load_result.dart'; import 'theme_metadata.dart'; import 'theme_paths.dart'; +import '../extensions/local_extension_registry.dart'; +import '../extensions/extension_paths.dart'; +import '../extensions/models/extension_manifest.dart'; +import '../extensions/models/extension_type.dart'; /// Scans theme directories and exposes lightweight [ThemeDefinition] metadata. class ThemeRegistryService { @@ -44,6 +48,7 @@ class ThemeRegistryService { final List _bundledThemeAssetFiles; final _ThemeLruCache _themeCache; int _themeParseCount = 0; + bool _hasMigratedThemes = false; /// Number of cache misses that performed a full theme parse. @visibleForTesting @@ -60,16 +65,30 @@ class ThemeRegistryService { await _loadBuiltinAssetDefinitions(definitions); - await _scanDirectory( - await _userThemesDirectory(), - ThemeSource.filesystem, - definitions, - ); - await _scanDirectory( - await _importedThemesDirectory(), - ThemeSource.imported, - definitions, - ); + if (!_hasMigratedThemes) { + await _migrateLegacyThemesToExtensions(); + _hasMigratedThemes = true; + } + + await LocalExtensionRegistry.instance.reload(); + for (final manifest in LocalExtensionRegistry.instance.manifests) { + if (manifest.type != ExtensionType.theme) continue; + final installPath = manifest.installPath; + final mainFile = manifest.main; + if (installPath == null || mainFile == null) continue; + + final file = File(p.join(installPath, mainFile)); + if (!await file.exists()) continue; + + final source = manifest.publisher.toLowerCase() == 'imported' + ? ThemeSource.imported + : ThemeSource.filesystem; + + final definition = await _definitionFromFile(file, source, extensionId: manifest.id); + if (definition != null) { + definitions.add(definition); + } + } final legacy = await _legacyImportedDefinition(); if (legacy != null) { @@ -87,7 +106,68 @@ class ThemeRegistryService { return List.unmodifiable(definitions); } - /// Validates [sourcePath], copies into the user themes directory, and returns + Future _migrateLegacyThemesToExtensions() async { + final dirs = [ + (await _userThemesDirectory(), ThemeSource.filesystem), + (await _importedThemesDirectory(), ThemeSource.imported), + ]; + + final extensionsDir = await ExtensionPaths.ensureExtensionsDirectory(); + + for (final pair in dirs) { + final directory = pair.$1; + final source = pair.$2; + if (!await directory.exists()) continue; + + await for (final entity in directory.list(followLinks: false)) { + if (entity is! File) continue; + if (p.basename(entity.path) == ThemeImportService.storedFileName) { + continue; + } + final ext = p.extension(entity.path).toLowerCase(); + if (ext != '.json' && ext != '.jsonc') continue; + + try { + final definition = + await _definitionFromFile(entity, source); + if (definition == null) continue; + + final slug = ThemeImportService.slugifyThemeName(definition.name); + var finalExtDir = Directory(p.join(extensionsDir.path, slug)); + var counter = 2; + while (await finalExtDir.exists()) { + finalExtDir = + Directory(p.join(extensionsDir.path, '$slug-$counter')); + counter++; + } + await finalExtDir.create(recursive: true); + + final themeFile = File(p.join(finalExtDir.path, 'theme.json')); + await entity.copy(themeFile.path); + + final manifest = ExtensionManifest( + id: definition.id, + name: definition.name, + version: '1.0.0', + publisher: source == ThemeSource.imported ? 'Imported' : 'Unknown', + type: ExtensionType.theme, + engines: const {'querya_desktop': '*'}, + main: 'theme.json', + description: 'Migrated custom theme', + ); + + final manifestFile = File(p.join(finalExtDir.path, 'manifest.json')); + await manifestFile.writeAsString(jsonEncode(manifest.toJson())); + + await entity.delete(); // Delete old file to complete migration + } catch (_) {} + } + } + // Reload extensions since we might have added new ones + await LocalExtensionRegistry.instance.reload(); + } + + /// Validates [sourcePath], creates an extension directory, and returns /// the scanned [ThemeDefinition]. Future importThemeFile(String sourcePath) async { try { @@ -97,20 +177,16 @@ class ThemeRegistryService { } final raw = await source.readAsString(); - final hash = _contentHash(raw); final json = _decodeRoot(raw); if (json == null) { return const ThemeDefinitionImportFailure('Invalid JSON.'); } - final themesDir = await _userThemesDirectory(); - if (!await themesDir.exists()) { - await themesDir.create(recursive: true); - } + final extensionsDir = await ExtensionPaths.ensureExtensionsDirectory(); final schema = json['schema']?.toString(); - late final String logicalId; - late final String preferredBaseName; + late String logicalId; + late String preferredBaseName; late String contentToWrite; if (schema == queryaThemeSchemaV1) { @@ -133,34 +209,94 @@ class ThemeRegistryService { contentToWrite = raw; } - var resolved = await _resolveImportDestination( - themesDir: themesDir, - hash: hash, - logicalId: logicalId, - preferredBaseName: preferredBaseName, - ); + await LocalExtensionRegistry.instance.load(); + final hash = _contentHash(raw); - if (!resolved.reused && - schema == queryaThemeSchemaV1 && - resolved.renamedId != null) { - contentToWrite = _rewriteCustomThemeId(raw, resolved.renamedId!); + File? sameHashFile; + ExtensionManifest? sameHashManifest; + ExtensionManifest? sameIdManifest; + + for (final extManifest in LocalExtensionRegistry.instance.manifests) { + if (extManifest.type != ExtensionType.theme) continue; + final installPath = extManifest.installPath; + final mainFile = extManifest.main; + if (installPath == null || mainFile == null) continue; + + final file = File(p.join(installPath, mainFile)); + if (await file.exists()) { + final existingRaw = await file.readAsString(); + if (_contentHash(existingRaw) == hash) { + sameHashFile = file; + sameHashManifest = extManifest; + break; + } + } + + if (extManifest.id == logicalId) { + sameIdManifest = extManifest; + } } - if (!resolved.reused) { - await resolved.file.writeAsString(contentToWrite); + bool reused = false; + File resolvedFile; + + if (sameHashManifest != null) { + reused = true; + resolvedFile = sameHashFile!; + logicalId = sameHashManifest.id; + } else { + if (sameIdManifest != null) { + var suffix = 2; + var candidateId = '$logicalId-$suffix'; + while (LocalExtensionRegistry.instance.manifests.any((m) => m.type == ExtensionType.theme && m.id == candidateId)) { + suffix++; + candidateId = '$logicalId-$suffix'; + } + logicalId = candidateId; + preferredBaseName = ThemeImportService.safeThemeFileBase(logicalId); + if (schema == queryaThemeSchemaV1) { + contentToWrite = _rewriteCustomThemeId(raw, logicalId); + } + } + + var finalExtDir = + Directory(p.join(extensionsDir.path, preferredBaseName)); + var counter = 2; + while (await finalExtDir.exists()) { + finalExtDir = Directory( + p.join(extensionsDir.path, '$preferredBaseName-$counter')); + counter++; + } + await finalExtDir.create(recursive: true); + + resolvedFile = File(p.join(finalExtDir.path, 'theme.json')); + await resolvedFile.writeAsString(contentToWrite); + + final newManifest = ExtensionManifest( + id: logicalId, + name: preferredBaseName, + version: '1.0.0', + publisher: 'Unknown', + type: ExtensionType.theme, + engines: const {'querya_desktop': '*'}, + main: 'theme.json', + description: 'Imported custom theme', + ); + final manifestFile = File(p.join(finalExtDir.path, 'manifest.json')); + await manifestFile.writeAsString(jsonEncode(newManifest.toJson())); + + await LocalExtensionRegistry.instance.reload(); } - final definition = - await _definitionFromFile(resolved.file, ThemeSource.filesystem); + final definition = await _definitionFromFile(resolvedFile, ThemeSource.filesystem, extensionId: logicalId); if (definition == null) { return const ThemeDefinitionImportFailure( - 'Failed to index imported theme.', - ); + 'Failed to index imported theme.'); } return ThemeDefinitionImportSuccess( definition: definition, - reusedExisting: resolved.reused, + reusedExisting: reused, ); } on QueryaThemeManifestParseException catch (e) { return ThemeDefinitionImportFailure(e.message); @@ -173,6 +309,13 @@ class ThemeRegistryService { } } + String _rewriteCustomThemeId(String raw, String newId) { + final decoded = jsonDecode(stripJsonc(raw)); + if (decoded is! Map) return raw; + decoded['id'] = newId; + return const JsonEncoder.withIndent(' ').convert(decoded); + } + /// Parses a scanned [definition] into a runtime [QueryaTheme]. Future loadTheme(ThemeDefinition definition) async { final path = definition.path; @@ -365,38 +508,11 @@ class ThemeRegistryService { static bool _isAssetPath(String path) => path.startsWith('assets/'); - Future _scanDirectory( - Directory directory, - ThemeSource source, - List out, - ) async { - if (!await directory.exists()) return; - - await for (final entity in directory.list(followLinks: false)) { - if (entity is Directory) { - if (p.basename(entity.path) == 'imported') continue; - continue; - } - if (entity is! File) continue; - - if (p.basename(entity.path) == ThemeImportService.storedFileName) { - continue; - } - - final ext = p.extension(entity.path).toLowerCase(); - if (ext != '.json' && ext != '.jsonc') continue; - - final definition = await _definitionFromFile(entity, source); - if (definition != null) { - out.add(definition); - } - } - } - Future _definitionFromFile( File file, - ThemeSource source, - ) async { + ThemeSource source, { + String? extensionId, + }) async { try { final stat = await file.stat(); final raw = await file.readAsString(); @@ -416,6 +532,7 @@ class ThemeRegistryService { source: source, contentHash: hash, lastModified: stat.modified, + extensionId: extensionId, ); } @@ -426,6 +543,7 @@ class ThemeRegistryService { source: source, contentHash: hash, lastModified: stat.modified, + extensionId: extensionId, ); } on Object catch (e) { _logScanError(file.path, e); @@ -440,6 +558,7 @@ class ThemeRegistryService { required ThemeSource source, required String contentHash, DateTime? lastModified, + String? extensionId, }) { final schema = json['schema']?.toString(); if (schema == queryaThemeSchemaV1) { @@ -449,6 +568,7 @@ class ThemeRegistryService { contentHash: contentHash, path: path, lastModified: lastModified, + extensionId: extensionId, ); } @@ -459,6 +579,7 @@ class ThemeRegistryService { fileBaseName: fileBaseName, path: path, lastModified: lastModified, + extensionId: extensionId, ); } @@ -468,8 +589,9 @@ class ThemeRegistryService { required String contentHash, required String path, DateTime? lastModified, + String? extensionId, }) { - final id = json['id']?.toString().trim(); + final id = extensionId ?? json['id']?.toString().trim(); final name = json['name']?.toString().trim(); final type = json['type']?.toString().trim().toLowerCase(); @@ -502,13 +624,14 @@ class ThemeRegistryService { required String fileBaseName, required String path, DateTime? lastModified, + String? extensionId, }) { final rawName = json['name']?.toString().trim(); final name = rawName != null && rawName.isNotEmpty ? rawName : fileBaseName; final type = json['type']?.toString().trim().toLowerCase(); return ThemeDefinition( - id: fileBaseName, + id: extensionId ?? fileBaseName, name: name, source: source, format: ThemeFormat.vscode, @@ -539,112 +662,6 @@ class ThemeRegistryService { return hash.toRadixString(16).padLeft(8, '0'); } - Future<_ResolvedImportDestination> _resolveImportDestination({ - required Directory themesDir, - required String hash, - required String logicalId, - required String preferredBaseName, - }) async { - File? sameIdFile; - - await for (final entity in themesDir.list(followLinks: false)) { - if (entity is Directory) continue; - if (entity is! File) continue; - - final name = p.basename(entity.path); - if (name == ThemeImportService.storedFileName) continue; - - final ext = p.extension(entity.path).toLowerCase(); - if (ext != '.json' && ext != '.jsonc') continue; - - late final String existingRaw; - try { - existingRaw = await entity.readAsString(); - } on IOException { - continue; - } - - if (_contentHash(existingRaw) == hash) { - return _ResolvedImportDestination(file: entity, reused: true); - } - - final definition = - await _definitionFromFile(entity, ThemeSource.filesystem); - if (definition?.id == logicalId) { - sameIdFile = entity; - } - } - - if (sameIdFile != null) { - final renamedId = await _nextRenamedThemeId(themesDir, logicalId); - final baseName = ThemeImportService.safeThemeFileBase(renamedId); - final primary = File(p.join(themesDir.path, '$baseName.json')); - final file = await primary.exists() - ? await _nextAvailableThemeFile(themesDir, baseName, startSuffix: 2) - : primary; - return _ResolvedImportDestination( - file: file, - reused: false, - renamedId: renamedId, - ); - } - - final primary = File(p.join(themesDir.path, '$preferredBaseName.json')); - if (!await primary.exists()) { - return _ResolvedImportDestination(file: primary, reused: false); - } - - final file = await _nextAvailableThemeFile( - themesDir, - preferredBaseName, - startSuffix: 2, - ); - return _ResolvedImportDestination(file: file, reused: false); - } - - Future _nextRenamedThemeId(Directory themesDir, String baseId) async { - for (var suffix = 2; suffix < 1000; suffix++) { - final candidate = '$baseId-$suffix'; - final taken = await _themeIdExists(themesDir, candidate); - if (!taken) return candidate; - } - return '$baseId-${_contentHash(baseId)}'; - } - - Future _themeIdExists(Directory themesDir, String id) async { - await for (final entity in themesDir.list(followLinks: false)) { - if (entity is! File) continue; - final ext = p.extension(entity.path).toLowerCase(); - if (ext != '.json' && ext != '.jsonc') continue; - final definition = - await _definitionFromFile(entity, ThemeSource.filesystem); - if (definition?.id == id) return true; - } - return false; - } - - Future _nextAvailableThemeFile( - Directory themesDir, - String baseName, { - int startSuffix = 2, - }) async { - for (var suffix = startSuffix; suffix < 1000; suffix++) { - final candidate = File(p.join(themesDir.path, '$baseName-$suffix.json')); - if (!await candidate.exists()) return candidate; - } - return File( - p.join(themesDir.path, - '$baseName-${DateTime.now().millisecondsSinceEpoch}.json'), - ); - } - - String _rewriteCustomThemeId(String raw, String newId) { - final decoded = jsonDecode(stripJsonc(raw)); - if (decoded is! Map) return raw; - decoded['id'] = newId; - return const JsonEncoder.withIndent(' ').convert(decoded); - } - void _logScanError(String path, Object error) { if (kDebugMode) { debugPrint('ThemeRegistryService: skipped $path ($error)'); @@ -652,18 +669,6 @@ class ThemeRegistryService { } } -class _ResolvedImportDestination { - const _ResolvedImportDestination({ - required this.file, - required this.reused, - this.renamedId, - }); - - final File file; - final bool reused; - final String? renamedId; -} - class _ThemeLruCache { _ThemeLruCache({required this.maxEntries}); diff --git a/patch_theme.py b/patch_theme.py new file mode 100644 index 00000000..dc4fc685 --- /dev/null +++ b/patch_theme.py @@ -0,0 +1,391 @@ +import os + +with open('lib/core/theme/theme_registry_service.dart', 'r') as f: + content = f.read() + +# 1. Imports +target_imports = """import 'theme_metadata.dart'; +import 'theme_paths.dart';""" +new_imports = """import 'theme_metadata.dart'; +import 'theme_paths.dart'; +import '../extensions/local_extension_registry.dart'; +import '../extensions/extension_paths.dart'; +import '../extensions/models/extension_manifest.dart'; +import '../extensions/models/extension_type.dart';""" +content = content.replace(target_imports, new_imports) + +# 2. Add flag +target_flag = """ final List _bundledThemeAssetFiles; + final _ThemeLruCache _themeCache; + int _themeParseCount = 0;""" +new_flag = """ final List _bundledThemeAssetFiles; + final _ThemeLruCache _themeCache; + int _themeParseCount = 0; + bool _hasMigratedThemes = false;""" +content = content.replace(target_flag, new_flag) + +# 3. loadThemeDefinitions +target_load = """ Future> loadThemeDefinitions() async { + final definitions = []; + + await _loadBuiltinAssetDefinitions(definitions); + + await _scanDirectory( + await _userThemesDirectory(), + ThemeSource.filesystem, + definitions, + ); + await _scanDirectory( + await _importedThemesDirectory(), + ThemeSource.imported, + definitions, + ); + + final legacy = await _legacyImportedDefinition(); + if (legacy != null) { + definitions.removeWhere( + (definition) => + definition.path == legacy.path && + definition.source != ThemeSource.legacyImported, + ); + definitions.add(legacy); + } + + definitions.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + return List.unmodifiable(definitions); + }""" +new_load = """ Future> loadThemeDefinitions() async { + final definitions = []; + + await _loadBuiltinAssetDefinitions(definitions); + + if (!_hasMigratedThemes) { + await _migrateLegacyThemesToExtensions(); + _hasMigratedThemes = true; + } + + await LocalExtensionRegistry.instance.load(); + for (final manifest in LocalExtensionRegistry.instance.manifests) { + if (manifest.type != ExtensionType.theme) continue; + final installPath = manifest.installPath; + final mainFile = manifest.main; + if (installPath == null || mainFile == null) continue; + + final file = File(p.join(installPath, mainFile)); + if (!await file.exists()) continue; + + final definition = await _definitionFromFile(file, ThemeSource.filesystem); + if (definition != null) { + definitions.add(definition); + } + } + + final legacy = await _legacyImportedDefinition(); + if (legacy != null) { + definitions.removeWhere( + (definition) => + definition.path == legacy.path && + definition.source != ThemeSource.legacyImported, + ); + definitions.add(legacy); + } + + definitions.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + return List.unmodifiable(definitions); + } + + Future _migrateLegacyThemesToExtensions() async { + final dirs = [ + await _userThemesDirectory(), + await _importedThemesDirectory(), + ]; + + final extensionsDir = await ExtensionPaths.ensureExtensionsDirectory(); + + for (final directory in dirs) { + if (!await directory.exists()) continue; + + await for (final entity in directory.list(followLinks: false)) { + if (entity is! File) continue; + if (p.basename(entity.path) == ThemeImportService.storedFileName) continue; + final ext = p.extension(entity.path).toLowerCase(); + if (ext != '.json' && ext != '.jsonc') continue; + + try { + final definition = await _definitionFromFile(entity, ThemeSource.filesystem); + if (definition == null) continue; + + final slug = ThemeImportService.slugifyThemeName(definition.name); + var finalExtDir = Directory(p.join(extensionsDir.path, slug)); + var counter = 2; + while (await finalExtDir.exists()) { + finalExtDir = Directory(p.join(extensionsDir.path, '$slug-$counter')); + counter++; + } + await finalExtDir.create(recursive: true); + + final themeFile = File(p.join(finalExtDir.path, 'theme.json')); + await entity.copy(themeFile.path); + + final manifest = ExtensionManifest( + id: definition.id, + name: definition.name, + version: '1.0.0', + publisher: 'Unknown', + type: ExtensionType.theme, + engines: const {'querya_desktop': '*'}, + main: 'theme.json', + description: 'Migrated custom theme', + ); + + final manifestFile = File(p.join(finalExtDir.path, 'manifest.json')); + await manifestFile.writeAsString(jsonEncode(manifest.toJson())); + + await entity.delete(); // Delete old file to complete migration + } catch (_) {} + } + } + // Reload extensions since we might have added new ones + await LocalExtensionRegistry.instance.reload(); + }""" +content = content.replace(target_load, new_load) + +# 4. importThemeFile +target_import = """ /// Validates [sourcePath], copies into the user themes directory, and returns + /// the scanned [ThemeDefinition]. + Future importThemeFile(String sourcePath) async { + try { + final source = File(sourcePath); + if (!await source.exists()) { + return const ThemeDefinitionImportFailure('Theme file not found.'); + } + + final raw = await source.readAsString(); + final hash = _contentHash(raw); + final json = _decodeRoot(raw); + if (json == null) { + return const ThemeDefinitionImportFailure('Invalid JSON.'); + } + + final themesDir = await _userThemesDirectory(); + if (!await themesDir.exists()) { + await themesDir.create(recursive: true); + } + + final schema = json['schema']?.toString(); + late final String logicalId; + late final String preferredBaseName; + late String contentToWrite; + + if (schema == queryaThemeSchemaV1) { + final manifest = QueryaThemeManifest.fromJsonString(raw); + logicalId = manifest.id; + preferredBaseName = ThemeImportService.safeThemeFileBase(manifest.id); + contentToWrite = raw; + } else { + final manifest = VsCodeThemeManifest.fromJsonString(raw); + if (manifest.colors.isEmpty) { + return const ThemeDefinitionImportFailure( + 'Theme file has no "colors" section to import.', + ); + } + final displayName = manifest.name?.trim().isNotEmpty == true + ? manifest.name!.trim() + : p.basenameWithoutExtension(sourcePath); + preferredBaseName = ThemeImportService.slugifyThemeName(displayName); + logicalId = preferredBaseName; + contentToWrite = raw; + } + + var resolved = await _resolveImportDestination( + themesDir: themesDir, + hash: hash, + logicalId: logicalId, + preferredBaseName: preferredBaseName, + ); + + if (!resolved.reused && + schema == queryaThemeSchemaV1 && + resolved.renamedId != null) { + contentToWrite = _rewriteCustomThemeId(raw, resolved.renamedId!); + } + + if (!resolved.reused) { + await resolved.file.writeAsString(contentToWrite); + } + + final definition = + await _definitionFromFile(resolved.file, ThemeSource.filesystem); + if (definition == null) { + return const ThemeDefinitionImportFailure( + 'Failed to index imported theme.', + ); + } + + return ThemeDefinitionImportSuccess( + definition: definition, + reusedExisting: resolved.reused, + ); + } on QueryaThemeManifestParseException catch (e) { + return ThemeDefinitionImportFailure(e.message); + } on VsCodeThemeParseException catch (e) { + return ThemeDefinitionImportFailure(e.message); + } on IOException catch (e) { + return ThemeDefinitionImportFailure(e.toString()); + } on Object catch (e) { + return ThemeDefinitionImportFailure(e.toString()); + } + }""" + +new_import = """ /// Validates [sourcePath], creates an extension directory, and returns + /// the scanned [ThemeDefinition]. + Future importThemeFile(String sourcePath) async { + try { + final source = File(sourcePath); + if (!await source.exists()) { + return const ThemeDefinitionImportFailure('Theme file not found.'); + } + + final raw = await source.readAsString(); + final json = _decodeRoot(raw); + if (json == null) { + return const ThemeDefinitionImportFailure('Invalid JSON.'); + } + + final extensionsDir = await ExtensionPaths.ensureExtensionsDirectory(); + + final schema = json['schema']?.toString(); + late final String logicalId; + late final String preferredBaseName; + late String contentToWrite; + + if (schema == queryaThemeSchemaV1) { + final manifest = QueryaThemeManifest.fromJsonString(raw); + logicalId = manifest.id; + preferredBaseName = ThemeImportService.safeThemeFileBase(manifest.id); + contentToWrite = raw; + } else { + final manifest = VsCodeThemeManifest.fromJsonString(raw); + if (manifest.colors.isEmpty) { + return const ThemeDefinitionImportFailure( + 'Theme file has no "colors" section to import.', + ); + } + final displayName = manifest.name?.trim().isNotEmpty == true + ? manifest.name!.trim() + : p.basenameWithoutExtension(sourcePath); + preferredBaseName = ThemeImportService.slugifyThemeName(displayName); + logicalId = preferredBaseName; + contentToWrite = raw; + } + + await LocalExtensionRegistry.instance.load(); + bool reused = false; + String? existingInstallPath; + + for (final extManifest in LocalExtensionRegistry.instance.manifests) { + if (extManifest.type == ExtensionType.theme && extManifest.id == logicalId) { + reused = true; + existingInstallPath = extManifest.installPath; + break; + } + } + + File resolvedFile; + if (reused && existingInstallPath != null) { + resolvedFile = File(p.join(existingInstallPath, 'theme.json')); + } else { + var finalExtDir = Directory(p.join(extensionsDir.path, preferredBaseName)); + var counter = 2; + while (await finalExtDir.exists()) { + finalExtDir = Directory(p.join(extensionsDir.path, '$preferredBaseName-$counter')); + counter++; + } + await finalExtDir.create(recursive: true); + + resolvedFile = File(p.join(finalExtDir.path, 'theme.json')); + await resolvedFile.writeAsString(contentToWrite); + + final newManifest = ExtensionManifest( + id: logicalId, + name: preferredBaseName, + version: '1.0.0', + publisher: 'Unknown', + type: ExtensionType.theme, + engines: const {'querya_desktop': '*'}, + main: 'theme.json', + description: 'Imported custom theme', + ); + final manifestFile = File(p.join(finalExtDir.path, 'manifest.json')); + await manifestFile.writeAsString(jsonEncode(newManifest.toJson())); + + await LocalExtensionRegistry.instance.reload(); + } + + final definition = await _definitionFromFile(resolvedFile, ThemeSource.filesystem); + if (definition == null) { + return const ThemeDefinitionImportFailure('Failed to index imported theme.'); + } + + return ThemeDefinitionImportSuccess( + definition: definition, + reusedExisting: reused, + ); + } on QueryaThemeManifestParseException catch (e) { + return ThemeDefinitionImportFailure(e.message); + } on VsCodeThemeParseException catch (e) { + return ThemeDefinitionImportFailure(e.message); + } on IOException catch (e) { + return ThemeDefinitionImportFailure(e.toString()); + } on Object catch (e) { + return ThemeDefinitionImportFailure(e.toString()); + } + }""" +content = content.replace(target_import, new_import) + +# 5. Remove unused methods using python block extracting instead of regex +# Or just simple replace +import re +to_remove = [ + (r' Future _scanDirectory\(', r'^\s*\}\s*$', 3), + (r' Future<_ResolvedImportDestination> _resolveImportDestination\(', r'^\s*\}\s*$', 3), + (r' Future _nextRenamedThemeId\(', r'^\s*\}\s*$', 3), + (r' Future _themeIdExists\(', r'^\s*\}\s*$', 3), + (r' Future _nextAvailableThemeFile\(', r'^\s*\}\s*$', 3), + (r' String _rewriteCustomThemeId\(', r'^\s*\}\s*$', 3), + (r'class _ResolvedImportDestination \{', r'^\}\s*$', 0), +] + +lines = content.split('\n') +for start_regex, end_regex, indent in to_remove: + start_idx = -1 + end_idx = -1 + for i, line in enumerate(lines): + if re.search(start_regex, line): + start_idx = i + break + if start_idx != -1: + # find matching closing brace + brace_count = 0 + started = False + for i in range(start_idx, len(lines)): + brace_count += lines[i].count('{') + brace_count -= lines[i].count('}') + if '{' in lines[i]: + started = True + if started and brace_count == 0: + end_idx = i + break + if end_idx != -1: + lines[start_idx:end_idx+1] = [] + +content = '\n'.join(lines) + +with open('lib/core/theme/theme_registry_service.dart', 'w') as f: + f.write(content) + +print("Patch applied successfully.") diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index b25708b5..41d8a59e 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -13,6 +13,8 @@ import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/core/theme/theme_load_result.dart'; import 'package:querya_desktop/core/theme/theme_definition.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/theme/theme_registry_service.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -74,6 +76,15 @@ void main() { themesDir = Directory(p.join(tempDir.path, 'themes')); importedDir = Directory(p.join(themesDir.path, 'imported')); await importedDir.create(recursive: true); + + final extDir = Directory(p.join(tempDir.path, 'extensions')); + ExtensionPaths.mockExtensionsDirectory = extDir; + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + await extDir.create(recursive: true); + await LocalExtensionRegistry.instance.reload(); + registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, @@ -90,13 +101,16 @@ void main() { }); tearDown(() async { - await ThemeController.instance.stopThemeFolderWatcher(); - await ThemeController.instance.endEditorPreview(); - await AppSettings.instance.clearThemeSettings(); - await ThemeImportService.deletePersistedImport(); if (await themesDir.exists()) { await themesDir.delete(recursive: true); } + final extDir = ExtensionPaths.mockExtensionsDirectory; + if (extDir != null && await extDir.exists()) { + await extDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); + await ThemeController.instance.resetToDefaults(); ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService()); }); @@ -272,7 +286,8 @@ void main() { await _copyFixture('querya_custom_dark.json', themeFile); await c.load(); await c.setThemeById('fixture-custom-dark'); - await themeFile.delete(); + final extDir = ExtensionPaths.mockExtensionsDirectory!; + await Directory(p.join(extDir.path, 'fixture-custom-dark')).delete(recursive: true); await c.load(); @@ -312,10 +327,19 @@ void main() { await c.load(); expect(c.selectedThemeLoadError, isNotNull); - await _copyFixture( - 'querya_custom_dark.json', - File(p.join(themesDir.path, 'querya_custom_dark.json')), - ); + final extDir = ExtensionPaths.mockExtensionsDirectory!; + final themeExt = Directory(p.join(extDir.path, 'fixture-custom-dark')); + await themeExt.create(); + await File(p.join('test/fixtures/themes', 'querya_custom_dark.json')).copy(p.join(themeExt.path, 'theme.json')); + await File(p.join(themeExt.path, 'manifest.json')).writeAsString('''{ + "schema": "querya.extension.v1", + "id": "fixture-custom-dark", + "name": "Fixture Custom Dark", + "version": "1.0.0", + "publisher": "Querya", + "type": "theme", + "main": "theme.json" + }'''); await c.loadAvailableThemes(); await c.setThemeById('fixture-custom-dark'); @@ -403,10 +427,19 @@ void main() { await c.load(); final beforeCount = c.availableThemes.length; - await _copyFixture( - 'querya_custom_dark.json', - File(p.join(themesDir.path, 'querya_custom_dark.json')), - ); + final extDir = ExtensionPaths.mockExtensionsDirectory!; + final themeExt = Directory(p.join(extDir.path, 'fixture-custom-dark')); + await themeExt.create(); + await File(p.join('test/fixtures/themes', 'querya_custom_dark.json')).copy(p.join(themeExt.path, 'theme.json')); + await File(p.join(themeExt.path, 'manifest.json')).writeAsString('''{ + "schema": "querya.extension.v1", + "id": "fixture-custom-dark", + "name": "Fixture Custom Dark", + "version": "1.0.0", + "publisher": "Querya", + "type": "theme", + "main": "theme.json" + }'''); await c.loadAvailableThemes(); expect(c.availableThemes.length, greaterThan(beforeCount)); @@ -428,7 +461,8 @@ void main() { await c.setThemeById('fixture-custom-dark'); final before = c.activeTheme; - await File(p.join(themesDir.path, 'querya_custom_dark.json')) + final extDir = ExtensionPaths.mockExtensionsDirectory!; + await File(p.join(extDir.path, 'fixture-custom-dark', 'theme.json')) .writeAsString('not valid theme json'); await c.loadAvailableThemes(); diff --git a/test/core/theme/theme_folder_watcher_test.dart b/test/core/theme/theme_folder_watcher_test.dart index bbb9e3a8..690b5780 100644 --- a/test/core/theme/theme_folder_watcher_test.dart +++ b/test/core/theme/theme_folder_watcher_test.dart @@ -5,6 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.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/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_folder_watcher.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; @@ -40,6 +42,14 @@ void main() { setUp(() async { themesDir = Directory(p.join(tempDir.path, 'themes')); await Directory(p.join(themesDir.path, 'imported')).create(recursive: true); + + final extDir = Directory(p.join(tempDir.path, 'extensions')); + ExtensionPaths.mockExtensionsDirectory = extDir; + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + await extDir.create(recursive: true); + await LocalExtensionRegistry.instance.reload(); }); tearDownAll(() async { @@ -56,6 +66,12 @@ void main() { if (await themesDir.exists()) { await themesDir.delete(recursive: true); } + final extDir = ExtensionPaths.mockExtensionsDirectory; + if (extDir != null && await extDir.exists()) { + await extDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService()); }); @@ -146,10 +162,21 @@ void main() { expect(c.isThemeFolderWatcherStarted, isTrue); final beforeCount = c.availableThemes.length; - await _copyFixture( - 'querya_custom_dark.json', - File(p.join(themesDir.path, 'querya_custom_dark.json')), + final extDir = ExtensionPaths.mockExtensionsDirectory!; + final themeExt = Directory(p.join(extDir.path, 'fixture-custom-dark')); + await themeExt.create(recursive: true); + await File(p.join(themeExt.path, 'theme.json')).writeAsString( + await File('test/fixtures/themes/querya_custom_dark.json').readAsString(), ); + await File(p.join(themeExt.path, 'manifest.json')).writeAsString('''{ + "schema": "querya.extension.v1", + "id": "fixture-custom-dark", + "name": "Fixture Custom Dark", + "version": "1.0.0", + "publisher": "Unknown", + "type": "theme", + "main": "theme.json" + }'''); await Future.delayed(const Duration(milliseconds: 700)); diff --git a/test/core/theme/theme_registry_builtin_assets_test.dart b/test/core/theme/theme_registry_builtin_assets_test.dart index b990cba1..8bb67753 100644 --- a/test/core/theme/theme_registry_builtin_assets_test.dart +++ b/test/core/theme/theme_registry_builtin_assets_test.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; 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/theme/builtin_theme_assets.dart'; import 'package:querya_desktop/core/theme/parser/color_parser.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; @@ -42,6 +44,14 @@ void main() { importedDir = Directory(p.join(themesDir.path, 'imported')); await importedDir.create(recursive: true); + final extDir = Directory(p.join(tempDir.path, 'extensions')); + ExtensionPaths.mockExtensionsDirectory = extDir; + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + await extDir.create(recursive: true); + await LocalExtensionRegistry.instance.reload(); + registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, @@ -53,6 +63,12 @@ void main() { if (await themesDir.exists()) { await themesDir.delete(recursive: true); } + final extDir = ExtensionPaths.mockExtensionsDirectory; + if (extDir != null && await extDir.exists()) { + await extDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); }); tearDownAll(() async { diff --git a/test/core/theme/theme_registry_cache_test.dart b/test/core/theme/theme_registry_cache_test.dart index 60029779..4a4d7078 100644 --- a/test/core/theme/theme_registry_cache_test.dart +++ b/test/core/theme/theme_registry_cache_test.dart @@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/theme/theme_load_result.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/theme/theme_registry_service.dart'; class _FakePathProvider extends PathProviderPlatform { @@ -37,6 +39,14 @@ void main() { importedDir = Directory(p.join(themesDir.path, 'imported')); await importedDir.create(recursive: true); + final extDir = Directory(p.join(tempDir.path, 'extensions')); + ExtensionPaths.mockExtensionsDirectory = extDir; + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + await extDir.create(recursive: true); + await LocalExtensionRegistry.instance.reload(); + registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, @@ -48,6 +58,12 @@ void main() { if (await themesDir.exists()) { await themesDir.delete(recursive: true); } + final extDir = ExtensionPaths.mockExtensionsDirectory; + if (extDir != null && await extDir.exists()) { + await extDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); }); tearDownAll(() async { @@ -81,8 +97,9 @@ void main() { await registry.loadTheme(before); expect(registry.themeParseCount, 1); - final raw = await themeFile.readAsString(); - await themeFile.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); + final extFile = File(before.path!); + final raw = await extFile.readAsString(); + await extFile.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); final after = (await registry.loadThemeDefinitions()).single; expect(after.contentHash, isNot(before.contentHash)); diff --git a/test/core/theme/theme_registry_service_test.dart b/test/core/theme/theme_registry_service_test.dart index 775c7eb6..3f886a98 100644 --- a/test/core/theme/theme_registry_service_test.dart +++ b/test/core/theme/theme_registry_service_test.dart @@ -8,6 +8,8 @@ import 'package:querya_desktop/core/theme/parser/color_parser.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/core/theme/theme_load_result.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/theme/theme_registry_service.dart'; class _FakePathProvider extends PathProviderPlatform { @@ -42,6 +44,14 @@ void main() { importedDir = Directory(p.join(themesDir.path, 'imported')); await importedDir.create(recursive: true); + final extDir = Directory(p.join(tempDir.path, 'extensions')); + ExtensionPaths.mockExtensionsDirectory = extDir; + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + await extDir.create(recursive: true); + await LocalExtensionRegistry.instance.reload(); + registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, @@ -53,6 +63,12 @@ void main() { if (await themesDir.exists()) { await themesDir.delete(recursive: true); } + final extDir = ExtensionPaths.mockExtensionsDirectory; + if (extDir != null && await extDir.exists()) { + await extDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); }); tearDownAll(() async { @@ -147,8 +163,9 @@ void main() { expect(before, hasLength(1)); final originalHash = before.single.contentHash; - final raw = await themeFile.readAsString(); - await themeFile.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); + final extFile = File(before.single.path!); + final raw = await extFile.readAsString(); + await extFile.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); final after = await registry.loadThemeDefinitions(); expect(after, hasLength(1)); @@ -261,7 +278,7 @@ void main() { expect(success.definition.id, 'fixture-custom-dark'); expect(success.definition.source, ThemeSource.filesystem); expect( - await File(p.join(themesDir.path, 'fixture-custom-dark.json')).exists(), + await File(p.join(tempDir.path, 'extensions', 'fixture-custom-dark', 'theme.json')).exists(), isTrue, ); @@ -281,7 +298,11 @@ void main() { expect(success.definition.format, ThemeFormat.vscode); expect( p.basename(success.definition.path!), - 'fixture-dark-subset.json', + 'theme.json', + ); + expect( + p.basename(p.dirname(success.definition.path!)), + 'fixture-dark-subset', ); }); @@ -298,11 +319,8 @@ void main() { expect(secondSuccess.reusedExisting, isTrue); expect(secondSuccess.definition.path, firstSuccess.definition.path); - final themeFiles = themesDir - .listSync() - .whereType() - .where((f) => p.extension(f.path) == '.json') - .length; + final extDir = ExtensionPaths.mockExtensionsDirectory!; + final themeFiles = extDir.listSync().length; expect(themeFiles, 1); }); diff --git a/test/core/theme/theme_remote_install_service_test.dart b/test/core/theme/theme_remote_install_service_test.dart index b9bac2ab..04adc8b6 100644 --- a/test/core/theme/theme_remote_install_service_test.dart +++ b/test/core/theme/theme_remote_install_service_test.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; 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/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/parser/color_parser.dart'; @@ -41,6 +43,15 @@ void main() { setUp(() async { themesDir = Directory(p.join(tempDir.path, 'themes')); await Directory(p.join(themesDir.path, 'imported')).create(recursive: true); + + final extDir = Directory(p.join(tempDir.path, 'extensions')); + ExtensionPaths.mockExtensionsDirectory = extDir; + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + await extDir.create(recursive: true); + await LocalExtensionRegistry.instance.reload(); + registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => Directory( @@ -65,6 +76,12 @@ void main() { if (await themesDir.exists()) { await themesDir.delete(recursive: true); } + final extDir = ExtensionPaths.mockExtensionsDirectory; + if (extDir != null && await extDir.exists()) { + await extDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService()); }); @@ -89,7 +106,7 @@ void main() { final success = result as ThemeDefinitionImportSuccess; expect(success.definition.id, 'fixture-custom-dark'); expect( - await File(p.join(themesDir.path, 'fixture-custom-dark.json')).exists(), + await File(p.join(tempDir.path, 'extensions', 'fixture-custom-dark', 'theme.json')).exists(), isTrue, ); }); @@ -116,7 +133,7 @@ void main() { (result as ThemeDefinitionImportFailure).message, contains('Checksum mismatch'), ); - expect(await themesDir.list().length, 1); + expect(await ExtensionPaths.mockExtensionsDirectory!.list().length, 0); }); test('rejects invalid JSON without writing to themes folder', () async { @@ -150,8 +167,20 @@ void main() { test('reuses existing file when remote content hash matches', () async { final raw = await File('test/fixtures/themes/querya_custom_dark.json') .readAsString(); - await File(p.join(themesDir.path, 'fixture-custom-dark.json')) - .writeAsString(raw); + final extDir = ExtensionPaths.mockExtensionsDirectory!; + final themeExt = Directory(p.join(extDir.path, 'fixture-custom-dark')); + await themeExt.create(recursive: true); + await File(p.join(themeExt.path, 'theme.json')).writeAsString(raw); + await File(p.join(themeExt.path, 'manifest.json')).writeAsString('''{ + "schema": "querya.extension.v1", + "id": "fixture-custom-dark", + "name": "Fixture Custom Dark", + "version": "1.0.0", + "publisher": "Unknown", + "type": "theme", + "main": "theme.json" + }'''); + await LocalExtensionRegistry.instance.reload(); final service = ThemeRemoteInstallService( registry, diff --git a/test/features/settings/theme_import_flow_test.dart b/test/features/settings/theme_import_flow_test.dart index 879b62b3..05bb13fb 100644 --- a/test/features/settings/theme_import_flow_test.dart +++ b/test/features/settings/theme_import_flow_test.dart @@ -9,6 +9,8 @@ import 'package:querya_desktop/core/theme/parser/color_parser.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/core/theme/theme_import_service.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/theme/theme_registry_service.dart'; class _FakePathProvider extends PathProviderPlatform { @@ -61,6 +63,10 @@ void main() { importedDir = Directory(p.join(themesDir.path, 'imported')); await importedDir.create(recursive: true); + final extensionsDir = Directory(p.join(tempDir.path, 'extensions')); + ExtensionPaths.mockExtensionsDirectory = extensionsDir; + await LocalExtensionRegistry.instance.reload(); + registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, @@ -76,6 +82,8 @@ void main() { if (await themesDir.exists()) { await themesDir.delete(recursive: true); } + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); ThemeController.instance.setRegistryServiceForTest( ThemeRegistryService( userThemesDirectory: () async => themesDir, @@ -119,7 +127,7 @@ void main() { parseQueryaThemeColor('#38BDF8'), ); expect( - await File(p.join(themesDir.path, 'fixture-custom-dark.json')).exists(), + await File(p.join(tempDir.path, 'extensions', 'fixture-custom-dark', 'theme.json')).exists(), isTrue, ); expect(await AppSettings.instance.getSelectedThemeId(), From d42e7560dad84df57961b215d2afbfd13d4bf53d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 21 Jun 2026 18:02:28 +0300 Subject: [PATCH 05/23] test(settings): fix preferences appearance section widget test by mocking extensions dir and utilizing pumpAndSettle --- .../preferences_appearance_section_test.dart | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index a5e7c678..efb0d6a0 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; 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/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; @@ -52,6 +54,15 @@ void main() { themesDir = Directory(p.join(tempDir.path, 'themes')); importedDir = Directory(p.join(themesDir.path, 'imported')); await importedDir.create(recursive: true); + + final extDir = Directory(p.join(tempDir.path, 'extensions')); + ExtensionPaths.mockExtensionsDirectory = extDir; + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + await extDir.create(recursive: true); + await LocalExtensionRegistry.instance.reload(); + registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, @@ -69,6 +80,13 @@ void main() { }); tearDown(() async { + final extDir = ExtensionPaths.mockExtensionsDirectory; + if (extDir != null && await extDir.exists()) { + await extDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); + ThemeController.instance.setRegistryServiceForTest( ThemeRegistryService( userThemesDirectory: () async => themesDir, @@ -108,8 +126,7 @@ void main() { expect(find.byType(ThemePickerButton), findsOneWidget); await tester.tap(find.text('Querya Dark')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); expect(find.text('Querya Light'), findsOneWidget); }); From 002058ecdf8ce2279de8cc00bf08e2b9a19ced34 Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:29:29 +0300 Subject: [PATCH 06/23] Fix TOCTOU, ID collisions, redundant scans, and remove legacy theme code (#221) --- .../extensions/local_extension_registry.dart | 14 +++- lib/core/theme/theme_controller.dart | 32 +-------- lib/core/theme/theme_import_service.dart | 65 ------------------- lib/core/theme/theme_registry_service.dart | 44 +++++++++++-- test/core/theme/theme_controller_test.dart | 16 +---- .../core/theme/theme_import_service_test.dart | 29 --------- .../theme_registry_legacy_import_test.dart | 54 ++++++--------- 7 files changed, 74 insertions(+), 180 deletions(-) diff --git a/lib/core/extensions/local_extension_registry.dart b/lib/core/extensions/local_extension_registry.dart index ddf17203..95b69c1d 100644 --- a/lib/core/extensions/local_extension_registry.dart +++ b/lib/core/extensions/local_extension_registry.dart @@ -13,6 +13,7 @@ class LocalExtensionRegistry { List _manifests = []; bool _loaded = false; + Future>? _loadFuture; /// Returns an unmodifiable list of loaded manifests. List get manifests => List.unmodifiable(_manifests); @@ -20,13 +21,24 @@ class LocalExtensionRegistry { /// Reloads manifests from the disk. Future reload() async { _loaded = false; + _loadFuture = null; await load(); } /// Loads manifests from the extensions directory if not already loaded. Future> load() async { if (_loaded) return manifests; - + if (_loadFuture != null) return _loadFuture!; + + _loadFuture = _doLoad(); + try { + return await _loadFuture!; + } finally { + _loadFuture = null; + } + } + + Future> _doLoad() async { final dir = await ExtensionPaths.extensionsDirectory(); final loadedManifests = []; diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index f38966e4..da681b09 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -473,37 +473,7 @@ class ThemeController extends ChangeNotifier { return result; } - /// Parses a VS Code theme file, persists it, and activates the imported preset. - Future importThemeFromFile(String path) async { - final result = await ThemeImportService.importFromPath(path); - switch (result) { - case ThemeImportSuccess( - :final name, - :final isDark, - :final colors, - :final tokenColors, - :final storedPath, - ): - await _clearRegistrySelection(); - _importedColors = Map.unmodifiable(colors); - _importedTokenColors = List.unmodifiable(tokenColors); - _importedThemeName = name; - _preset = QueryaThemePreset.imported; - _themeMode = isDark ? ThemeMode.dark : ThemeMode.light; - await AppSettings.instance.setThemeImportedColors(colors); - await AppSettings.instance.setThemeImportName(name); - await AppSettings.instance.setThemeImportPath(storedPath); - await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); - await AppSettings.instance.setThemeMode(_themeMode); - _availableThemes = _mergeBuiltinThemes( - await _registryService.loadThemeDefinitions(), - ); - _notifyThemeChanged(); - return result; - case ThemeImportFailure(): - return result; - } - } + /// Sets or clears a user override for a VS Code `colors` key. Future setWorkbenchColor(String vscodeKey, Color? value) async { diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart index 8787d884..2073bc48 100644 --- a/lib/core/theme/theme_import_service.dart +++ b/lib/core/theme/theme_import_service.dart @@ -6,31 +6,6 @@ import 'package:path_provider/path_provider.dart'; import 'parser/vscode_theme_manifest.dart'; import 'theme_definition.dart'; -/// Result of importing a VS Code theme file. -sealed class ThemeImportResult { - const ThemeImportResult(); -} - -class ThemeImportSuccess extends ThemeImportResult { - const ThemeImportSuccess({ - required this.name, - required this.isDark, - required this.colors, - required this.tokenColors, - required this.storedPath, - }); - - final String name; - final bool isDark; - final Map colors; - final List tokenColors; - final String storedPath; -} - -class ThemeImportFailure extends ThemeImportResult { - const ThemeImportFailure(this.message); - final String message; -} /// Result of copying a theme file into the user themes directory. sealed class ThemeDefinitionImportResult { @@ -80,46 +55,6 @@ abstract final class ThemeImportService { /// Path to the persisted legacy import copy under app support. static Future persistedImportFile() => _storedThemeFile(); - /// Reads [sourcePath], parses JSON/JSONC, copies to app data, returns colors. - static Future importFromPath(String sourcePath) async { - try { - final source = File(sourcePath); - if (!await source.exists()) { - return const ThemeImportFailure('Theme file not found.'); - } - final raw = await source.readAsString(); - final manifest = VsCodeThemeManifest.fromJsonString(raw); - if (manifest.colors.isEmpty) { - return const ThemeImportFailure( - 'Theme file has no "colors" section to import.', - ); - } - - final storedFile = await _storedThemeFile(); - await storedFile.parent.create(recursive: true); - await storedFile.writeAsString(raw); - - final name = manifest.name?.trim().isNotEmpty == true - ? manifest.name!.trim() - : p.basenameWithoutExtension(sourcePath); - - return ThemeImportSuccess( - name: name, - isDark: manifest.isDark || !manifest.isLight, - colors: Map.unmodifiable(manifest.colors), - tokenColors: List.unmodifiable(manifest.tokenColors), - storedPath: storedFile.path, - ); - } on VsCodeThemeParseException catch (e) { - return ThemeImportFailure(e.message); - } on FormatException catch (e) { - return ThemeImportFailure(e.message); - } on IOException catch (e) { - return ThemeImportFailure(e.toString()); - } on Object catch (e) { - return ThemeImportFailure(e.toString()); - } - } /// Reloads colors from the persisted import file, if present. static Future?> loadPersistedColors() async { diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index 4c991311..1d66f21b 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -132,21 +132,46 @@ class ThemeRegistryService { await _definitionFromFile(entity, source); if (definition == null) continue; + // Resolve ID collisions for legacy themes + var logicalId = definition.id; + var idSuffix = 2; + var candidateId = logicalId; + while (LocalExtensionRegistry.instance.manifests.any( + (m) => m.type == ExtensionType.theme && m.id == candidateId)) { + candidateId = '$logicalId-$idSuffix'; + idSuffix++; + } + logicalId = candidateId; + final slug = ThemeImportService.slugifyThemeName(definition.name); var finalExtDir = Directory(p.join(extensionsDir.path, slug)); var counter = 2; - while (await finalExtDir.exists()) { + while (true) { + if (!await finalExtDir.exists()) { + try { + await finalExtDir.create(recursive: false); + break; + } on FileSystemException { + // Another async task or process claimed it, keep looping. + } + } finalExtDir = Directory(p.join(extensionsDir.path, '$slug-$counter')); counter++; } - await finalExtDir.create(recursive: true); final themeFile = File(p.join(finalExtDir.path, 'theme.json')); - await entity.copy(themeFile.path); + + if (logicalId != definition.id && definition.format == ThemeFormat.queryaCustom) { + final raw = await entity.readAsString(); + final contentToWrite = _rewriteCustomThemeId(raw, logicalId); + await themeFile.writeAsString(contentToWrite); + } else { + await entity.copy(themeFile.path); + } final manifest = ExtensionManifest( - id: definition.id, + id: logicalId, name: definition.name, version: '1.0.0', publisher: source == ThemeSource.imported ? 'Imported' : 'Unknown', @@ -262,12 +287,19 @@ class ThemeRegistryService { var finalExtDir = Directory(p.join(extensionsDir.path, preferredBaseName)); var counter = 2; - while (await finalExtDir.exists()) { + while (true) { + if (!await finalExtDir.exists()) { + try { + await finalExtDir.create(recursive: false); + break; + } on FileSystemException { + // Another async task or process claimed it, keep looping. + } + } finalExtDir = Directory( p.join(extensionsDir.path, '$preferredBaseName-$counter')); counter++; } - await finalExtDir.create(recursive: true); resolvedFile = File(p.join(finalExtDir.path, 'theme.json')); await resolvedFile.writeAsString(contentToWrite); diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 41d8a59e..6cea1a06 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -176,21 +176,7 @@ void main() { ); }); - test('importThemeFromFile applies imported colors to activeTheme', () async { - final c = ThemeController.instance; - await c.load(); - final fixture = File('test/fixtures/themes/dark_subset.json'); - final result = await c.importThemeFromFile(fixture.path); - expect(result, isA()); - expect(c.preset, QueryaThemePreset.imported); - expect(c.hasImportedTheme, isTrue); - expect( - c.activeTheme.workbench.editorBackground, - const Color(0xFF1E1E1E), - ); - await c.resetToDefaults(); - expect(c.preset, QueryaThemePreset.queryaDark); - }); + test('setThemeAnimationEnabled persists and reset clears', () async { final c = ThemeController.instance; diff --git a/test/core/theme/theme_import_service_test.dart b/test/core/theme/theme_import_service_test.dart index e6ae274a..67069ea7 100644 --- a/test/core/theme/theme_import_service_test.dart +++ b/test/core/theme/theme_import_service_test.dart @@ -39,36 +39,7 @@ void main() { } }); - test('importFromPath parses fixture and persists copy', () async { - final fixture = File('test/fixtures/themes/dark_subset.json'); - final result = await ThemeImportService.importFromPath(fixture.path); - expect(result, isA()); - final success = result as ThemeImportSuccess; - expect(success.name, 'Fixture Dark Subset'); - expect(success.isDark, isTrue); - expect(success.colors['editor.background'], '#1e1e1e'); - final reloaded = await ThemeImportService.loadPersistedColors(); - expect(reloaded?['editor.background'], '#1e1e1e'); - }); - - test('importFromPath persists tokenColors from dracula fixture', () async { - final fixture = File('test/fixtures/themes/dracula_tokens.json'); - final result = await ThemeImportService.importFromPath(fixture.path); - expect(result, isA()); - final success = result as ThemeImportSuccess; - expect(success.tokenColors, isNotEmpty); - - final tokens = await ThemeImportService.loadPersistedTokenColors(); - expect(tokens.length, success.tokenColors.length); - expect(tokens.first.scopes, contains('comment')); - }); - - test('importFromPath returns failure for missing file', () async { - final result = - await ThemeImportService.importFromPath('/no/such/theme.json'); - expect(result, isA()); - }); test('slugifyThemeName produces filesystem-safe slug', () { expect( diff --git a/test/core/theme/theme_registry_legacy_import_test.dart b/test/core/theme/theme_registry_legacy_import_test.dart index b81258aa..575ce627 100644 --- a/test/core/theme/theme_registry_legacy_import_test.dart +++ b/test/core/theme/theme_registry_legacy_import_test.dart @@ -7,7 +7,7 @@ import 'package:path_provider_platform_interface/path_provider_platform_interfac 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_preset.dart'; -import 'package:querya_desktop/core/theme/theme_controller.dart'; + import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/core/theme/theme_load_result.dart'; @@ -67,14 +67,16 @@ void main() { test('exposes legacy imported theme from persisted import settings', () async { final fixture = File('test/fixtures/themes/dark_subset.json'); - final importResult = - await ThemeImportService.importFromPath(fixture.path); - expect(importResult, isA()); - final success = importResult as ThemeImportSuccess; - - await AppSettings.instance.setThemeImportedColors(success.colors); - await AppSettings.instance.setThemeImportName(success.name); - await AppSettings.instance.setThemeImportPath(success.storedPath); + final raw = await fixture.readAsString(); + final storedFile = await ThemeImportService.persistedImportFile(); + await storedFile.parent.create(recursive: true); + await storedFile.writeAsString(raw); + + await AppSettings.instance.setThemeImportedColors({ + 'editor.background': '#1e1e1e', + }); + await AppSettings.instance.setThemeImportName('Fixture Dark Subset'); + await AppSettings.instance.setThemeImportPath(storedFile.path); await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); final definitions = await registry.loadThemeDefinitions(); @@ -85,7 +87,7 @@ void main() { expect(legacy.id, ThemeImportService.legacyImportedThemeId); expect(legacy.name, 'Fixture Dark Subset'); expect(legacy.format, ThemeFormat.vscode); - expect(legacy.path, success.storedPath); + expect(legacy.path, storedFile.path); expect( definitions.where((definition) => definition.id == 'imported'), hasLength(1), @@ -94,13 +96,16 @@ void main() { test('loads legacy imported theme definition', () async { final fixture = File('test/fixtures/themes/dark_subset.json'); - final importResult = - await ThemeImportService.importFromPath(fixture.path); - final success = importResult as ThemeImportSuccess; + final raw = await fixture.readAsString(); + final storedFile = await ThemeImportService.persistedImportFile(); + await storedFile.parent.create(recursive: true); + await storedFile.writeAsString(raw); - await AppSettings.instance.setThemeImportedColors(success.colors); - await AppSettings.instance.setThemeImportName(success.name); - await AppSettings.instance.setThemeImportPath(success.storedPath); + await AppSettings.instance.setThemeImportedColors({ + 'editor.background': '#1e1e1e', + }); + await AppSettings.instance.setThemeImportName('Fixture Dark Subset'); + await AppSettings.instance.setThemeImportPath(storedFile.path); final legacy = (await registry.loadThemeDefinitions()).singleWhere( (definition) => definition.source == ThemeSource.legacyImported, @@ -134,23 +139,6 @@ void main() { expect((result as ThemeLoadFailure).message, 'Theme file not found.'); }); - test('QueryaThemePreset.imported still applies via ThemeController', - () async { - final controller = ThemeController.instance; - final fixture = File('test/fixtures/themes/dark_subset.json'); - final result = await controller.importThemeFromFile(fixture.path); - expect(result, isA()); - expect(controller.preset, QueryaThemePreset.imported); - expect(controller.hasImportedTheme, isTrue); - - final definitions = await registry.loadThemeDefinitions(); - expect( - definitions.any( - (definition) => definition.source == ThemeSource.legacyImported, - ), - isTrue, - ); - }); }); } From 3872164407838adcaec892bd4f334d5fcf1ed3db Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 21 Jun 2026 19:56:51 +0300 Subject: [PATCH 07/23] chore(release): prepare release 0.4.7 --- CHANGELOG.md | 16 ++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cca125de..5e691251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.7] - 2026-06-21 + +Local extension discovery and manifest foundation release. Git tag **`0.4.7`**. + +### Added + +- **Extension models (EXT-1)** — data models `ExtensionManifest` and `ExtensionType` to parse `manifest.json`. +- **Local scanner (EXT-2)** — `LocalExtensionRegistry` scans `~/.querya/extensions/` to find and load extension manifests. +- **Theme migration (EXT-3)** — migrated legacy custom themes to the new unified extension package format. +- **Unit tests (EXT-4)** — unit tests for manifest parsing, directory scanning, and registry cache logic. + +### Fixed + +- **Theme importing security** — resolved concurrent import TOCTOU filesystem races and theme ID collisions during migration, and cleaned up deprecated legacy import code. +- **Appearance Settings test** — resolved the preferences appearance section widget test failure by mocking the extensions directory. + ## [0.4.6-a] - 2026-06-18 ### Changed diff --git a/pubspec.yaml b/pubspec.yaml index bf8171d3..446519ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.6-a +version: 0.4.6+12 From 7b7c09e97cafc7dfa7bf08187be00c80f4734452 Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:37:54 +0300 Subject: [PATCH 08/23] feat(menu): implement File -> Exit to close the application window (#230) --- lib/features/main_screen/querya_window_title_bar.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 1d957b88..bf6e2937 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -84,7 +84,8 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (_) {}, child: const Text('Save')), const MenuDivider(), MenuButton( - onPressed: (_) {}, child: const Text('Exit')), + onPressed: (_) => appWindow.close(), + child: const Text('Exit')), ], child: const Text('File'), ), From f631eac424e0979e3cbc328b11a1a340725204e6 Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:49:37 +0300 Subject: [PATCH 09/23] feat(menu): implement File -> New, Open..., Save actions (#231) * feat(menu): implement File -> New, Open..., Save actions via Intents/Actions * refactor(menu): remove unnecessary widgets import in title bar --- lib/core/actions/sql_editor_actions.dart | 13 ++ .../main_screen/querya_window_title_bar.dart | 26 ++- lib/features/mysql/mysql_sql_workspace.dart | 186 ++++++++++------ .../postgresql/postgres_sql_workspace.dart | 198 ++++++++++++------ lib/features/sqlite/sqlite_sql_workspace.dart | 182 ++++++++++------ 5 files changed, 408 insertions(+), 197 deletions(-) create mode 100644 lib/core/actions/sql_editor_actions.dart diff --git a/lib/core/actions/sql_editor_actions.dart b/lib/core/actions/sql_editor_actions.dart new file mode 100644 index 00000000..f7ef4cfe --- /dev/null +++ b/lib/core/actions/sql_editor_actions.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +class NewSqlIntent extends Intent { + const NewSqlIntent(); +} + +class OpenSqlIntent extends Intent { + const OpenSqlIntent(); +} + +class SaveSqlIntent extends Intent { + const SaveSqlIntent(); +} diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index bf6e2937..2bfe0e9d 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Custom bitsdojo title bar styled from [QueryaThemeScope] workbench tokens. @@ -76,12 +77,29 @@ class QueryaWindowTitleBar extends StatelessWidget { MenuButton( subMenu: [ MenuButton( - onPressed: (_) {}, child: const Text('New')), - MenuButton( - onPressed: (_) {}, + onPressed: (ctx) { + Actions.maybeInvoke( + FocusManager.instance.primaryFocus?.context ?? ctx, + const NewSqlIntent(), + ); + }, + child: const Text('New')), + MenuButton( + onPressed: (ctx) { + Actions.maybeInvoke( + FocusManager.instance.primaryFocus?.context ?? ctx, + const OpenSqlIntent(), + ); + }, child: const Text('Open...')), MenuButton( - onPressed: (_) {}, child: const Text('Save')), + onPressed: (ctx) { + Actions.maybeInvoke( + FocusManager.instance.primaryFocus?.context ?? ctx, + const SaveSqlIntent(), + ); + }, + child: const Text('Save')), const MenuDivider(), MenuButton( onPressed: (_) => appWindow.close(), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index e2778fa2..339128c3 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; 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/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -237,78 +240,135 @@ class _MysqlSqlWorkspaceState extends material.State { return v.toInt(); } + 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()); - } - }, + return Actions( + actions: >{ + NewSqlIntent: CallbackAction( + onInvoke: (intent) { + _sqlController.clear(); + return null; + }, + ), + OpenSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_openSqlFile()); + return null; + }, + ), + SaveSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_saveSqlFile()); + return null; + }, + ), }, - child: material.Focus( - autofocus: true, - child: VerticalSplitPane( - fraction: _topFraction, - maxFraction: 0.85, - top: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _MysqlSqlToolbar( - onExecute: _running ? null : _execute, - running: _running, - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: widget.connectionRow.databaseName, - sqlController: _sqlController, - ); - } - : null, - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, + child: 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: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MysqlSqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, ), - ), - ], - ), - bottom: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + ], + ), + bottom: Column( + crossAxisAlignment: 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(), ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index d72b79a9..f6ecb83f 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,7 +1,10 @@ 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/actions/sql_editor_actions.dart'; import 'package:postgres/postgres.dart' as pg; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; @@ -377,84 +380,141 @@ class _PostgresSqlWorkspaceState extends material.State { return v.toString(); } + 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) _execute(); - }, + return Actions( + actions: >{ + NewSqlIntent: CallbackAction( + onInvoke: (intent) { + _sqlController.clear(); + return null; + }, + ), + OpenSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_openSqlFile()); + return null; + }, + ), + SaveSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_saveSqlFile()); + return null; + }, + ), }, - child: material.Focus( - autofocus: true, - child: VerticalSplitPane( - fraction: _topFraction, - maxFraction: 0.85, - top: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _SqlToolbar( - sessionDatabase: _effectiveSessionDatabase(), - onExecute: _running ? null : _execute, - running: _running, - autocommit: _autocommit, - onAutocommitChanged: (v) => setState(() => _autocommit = v), - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: _effectiveSessionDatabase(), - sqlController: _sqlController, - ); - } - : null, - txOpen: _txOpen, - onBegin: _running ? null : () => _runTxCommand('BEGIN'), - onCommit: _running ? null : () => _runTxCommand('COMMIT'), - onRollback: - _running ? null : () => _runTxCommand('ROLLBACK'), - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, + child: material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_running) _execute(); + }, + }, + child: material.Focus( + autofocus: true, + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SqlToolbar( + sessionDatabase: _effectiveSessionDatabase(), + onExecute: _running ? null : _execute, + running: _running, + autocommit: _autocommit, + onAutocommitChanged: (v) => setState(() => _autocommit = v), + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: _effectiveSessionDatabase(), + sqlController: _sqlController, + ); + } + : null, + txOpen: _txOpen, + onBegin: _running ? null : () => _runTxCommand('BEGIN'), + onCommit: _running ? null : () => _runTxCommand('COMMIT'), + onRollback: + _running ? null : () => _runTxCommand('ROLLBACK'), ), - ), - ], - ), - bottom: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + ], + ), + bottom: Column( + crossAxisAlignment: 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(), ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index b2449b70..45d38d52 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,6 +1,9 @@ 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/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -182,76 +185,133 @@ class _SqliteSqlWorkspaceState extends material.State { } } + 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()); - } - }, + return Actions( + actions: >{ + NewSqlIntent: CallbackAction( + onInvoke: (intent) { + _sqlController.clear(); + return null; + }, + ), + OpenSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_openSqlFile()); + return null; + }, + ), + SaveSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_saveSqlFile()); + return null; + }, + ), }, - child: material.Focus( - autofocus: true, - child: VerticalSplitPane( - fraction: _topFraction, - maxFraction: 0.85, - top: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SqliteSqlToolbar( - onExecute: _running ? null : _execute, - running: _running, - onOpenPreferences: () => showPreferencesDialog(context), - 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, + child: 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: [ + _SqliteSqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, ), - ), - ], - ), - bottom: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, + const Divider(height: 1), + material.Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + ], + ), + 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(), ), - 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, - affectedRows: _affectedRows, - statusLine: _statusLine, + const Divider(height: 1), + material.Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), ), - ), - ], + ], + ), ), ), ), From a59d153accbeada2ef61a73c5a50e58efe408c82 Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Mon, 22 Jun 2026 04:37:46 +0300 Subject: [PATCH 10/23] feat: implement connection management improvements and read-only mode (Issues #225, #226) (#232) * feat(connection): implement connection actions and fix layout test hang * feat(connection): implement Invalidate/Reconnect menu item * feat: implement Connection -> Read-only mode for database connections * fix ci --- lib/core/storage/local_db.dart | 1 + .../connections/connections_panel.dart | 86 +++++++++++++ .../connections/connections_panel_mongo.dart | 33 ++++- .../connections/connections_panel_mysql.dart | 33 ++++- ...connections_panel_postgres_connection.dart | 33 ++++- .../connections/connections_panel_redis.dart | 33 ++++- .../connections/connections_panel_sqlite.dart | 34 ++++- lib/features/main_screen/main_screen.dart | 42 +++++- .../main_screen_workspace_state.dart | 32 ++++- .../main_screen/querya_window_title_bar.dart | 40 +++++- lib/features/main_screen/workspace_panel.dart | 5 + lib/features/mysql/mysql_sql_workspace.dart | 15 ++- lib/features/mysql/mysql_workspace_home.dart | 11 ++ .../postgresql/postgres_sql_workspace.dart | 9 +- .../postgresql/postgres_workspace_home.dart | 11 ++ lib/features/sqlite/sqlite_sql_workspace.dart | 13 +- .../sqlite/sqlite_workspace_home.dart | 11 ++ .../connections_panel_layout_test.dart | 120 ++++++++++++++++++ .../main_screen_workspace_state_test.dart | 18 +++ 19 files changed, 545 insertions(+), 35 deletions(-) diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 07ff8442..eebba457 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -379,6 +379,7 @@ class LocalDb { Future close() async { await _db?.close(); _db = null; + _cachedDbPath = null; } } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index cc65b630..40bbfa6e 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -67,6 +67,8 @@ 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/shared/widgets/widgets.dart'; +import 'package:querya_desktop/core/database/redis_service.dart'; +import 'package:querya_desktop/app/app_shutdown.dart'; import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; @@ -219,6 +221,7 @@ class ConnectionsPanelState extends State { List _connections = []; Map _folderIdByName = {}; final Set _expandedFolders = {}; + final Set _expandedConnections = {}; /// Ignores stale [setState] when multiple [_loadData] runs overlap (e.g. tests). int _loadDataGeneration = 0; @@ -312,6 +315,68 @@ class ConnectionsPanelState extends State { await _loadData(); } + void connect(int connectionId) { + setState(() { + _expandedConnections.add(connectionId); + }); + } + + @visibleForTesting + bool isConnectionExpanded(int id) => _expandedConnections.contains(id); + + Future disconnect(ConnectionRow conn) async { + final id = conn.id!; + setState(() { + _expandedConnections.remove(id); + }); + if (conn.type == 'postgresql') { + PostgresService.instance.interrupt(conn, database: conn.databaseName ?? 'postgres', mode: PgSessionMode.readOnly); + PostgresService.instance.interrupt(conn, database: conn.databaseName ?? 'postgres', mode: PgSessionMode.readWrite); + } else if (conn.type == 'mysql') { + MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readOnly); + MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readWrite); + } else if (conn.type == 'sqlite') { + SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readOnly); + SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readWrite); + } else if (conn.type == 'redis') { + final redisConn = RedisService.instance.getConnection(id); + if (redisConn != null) { + await RedisService.instance.disconnect(redisConn); + } + } else if (conn.type == 'mongodb') { + await MongoService.instance.disconnectByConnectionId(id); + } + } + + Future disconnectAll() async { + setState(() { + _expandedConnections.clear(); + }); + await disconnectAllExternalServices(); + await SqliteService.instance.disconnectAll(); + } + + Future disconnectOthers(ConnectionRow keepConn) async { + final keepId = keepConn.id!; + setState(() { + _expandedConnections.clear(); + _expandedConnections.add(keepId); + }); + for (final conn in _connections) { + if (conn.id == keepId) continue; + await disconnect(conn); + } + } + + Future reconnect(ConnectionRow conn) async { + final id = conn.id!; + await disconnect(conn); + await Future.delayed(const Duration(milliseconds: 50)); + if (mounted) { + connect(id); + } + } + /// Icon for a connection type (matches New Connection dialog). material.IconData _iconForType(String type) { return switch (type) { @@ -338,6 +403,17 @@ class ConnectionsPanelState extends State { Widget _buildConnectionTile(ConnectionRow conn) { final isSelected = widget.selectedConnectionId != null && widget.selectedConnectionId == conn.id; + final isExpanded = _expandedConnections.contains(conn.id); + void handleExpandedChanged(bool expanded) { + setState(() { + if (expanded) { + _expandedConnections.add(conn.id!); + } else { + _expandedConnections.remove(conn.id!); + } + }); + } + if (conn.type == 'postgresql') { return _PostgresConnectionTile( connection: conn, @@ -348,6 +424,8 @@ class ConnectionsPanelState extends State { onTap: () => widget.onConnectionSelected?.call(conn), onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } else if (conn.type == 'mysql') { return _MysqlConnectionTile( @@ -359,6 +437,8 @@ class ConnectionsPanelState extends State { onTap: () => widget.onConnectionSelected?.call(conn), onMysqlObjectSelected: widget.onMysqlObjectSelected, onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } else if (conn.type == 'redis') { return _RedisConnectionTile( @@ -369,6 +449,8 @@ class ConnectionsPanelState extends State { onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } else if (conn.type == 'mongodb') { return _MongoConnectionTile( @@ -379,6 +461,8 @@ class ConnectionsPanelState extends State { onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } else if (conn.type == 'sqlite') { return _SqliteConnectionTile( @@ -390,6 +474,8 @@ class ConnectionsPanelState extends State { onTap: () => widget.onConnectionSelected?.call(conn), onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } return _ConnectionTile( diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 29ac0343..cc1c5d41 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -11,6 +11,8 @@ class _MongoConnectionTile extends StatefulWidget { required this.onRemove, this.onTap, this.onDatabaseTap, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -20,24 +22,47 @@ class _MongoConnectionTile extends StatefulWidget { final VoidCallback onRemove; final VoidCallback? onTap; final void Function(String database)? onDatabaseTap; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_MongoConnectionTile> createState() => _MongoConnectionTileState(); } class _MongoConnectionTileState extends State<_MongoConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; List _databases = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadDatabases(); } } + @override + void didUpdateWidget(_MongoConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_databases.isEmpty && !_loading) { + _loadDatabases(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _databases = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadDatabases() async { if (!mounted) return; setState(() { diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 10c67383..2e7d34eb 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -12,6 +12,8 @@ class _MysqlConnectionTile extends StatefulWidget { this.onTap, this.onMysqlObjectSelected, this.onMysqlOpenSqlWorkspace, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -27,24 +29,47 @@ class _MysqlConnectionTile extends StatefulWidget { MysqlObjectKind kind, )? onMysqlObjectSelected; final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_MysqlConnectionTile> createState() => _MysqlConnectionTileState(); } class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; List _databases = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadDatabases(); } } + @override + void didUpdateWidget(_MysqlConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_databases.isEmpty && !_loading) { + _loadDatabases(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _databases = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadDatabases() async { if (!mounted) return; setState(() { diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 925849a8..9bc2a359 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -12,6 +12,8 @@ class _PostgresConnectionTile extends StatefulWidget { this.onTap, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -28,6 +30,8 @@ class _PostgresConnectionTile extends StatefulWidget { PostgresObjectKind kind, )? onPostgresObjectSelected; final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_PostgresConnectionTile> createState() => @@ -35,18 +39,39 @@ class _PostgresConnectionTile extends StatefulWidget { } class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; List _databases = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadDatabases(); } } + @override + void didUpdateWidget(_PostgresConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_databases.isEmpty && !_loading) { + _loadDatabases(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _databases = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadDatabases() async { if (!mounted) return; setState(() { diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index efee18af..cc6d687c 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -11,6 +11,8 @@ class _RedisConnectionTile extends StatefulWidget { required this.onRemove, this.onTap, this.onDatabaseTap, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -20,25 +22,48 @@ class _RedisConnectionTile extends StatefulWidget { final VoidCallback onRemove; final VoidCallback? onTap; final void Function(int database)? onDatabaseTap; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_RedisConnectionTile> createState() => _RedisConnectionTileState(); } class _RedisConnectionTileState extends State<_RedisConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; // All 16 databases (db0–db15) with key counts List<({int index, int keys})> _databases = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadDatabases(); } } + @override + void didUpdateWidget(_RedisConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_databases.isEmpty && !_loading) { + _loadDatabases(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _databases = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadDatabases() async { if (!mounted) return; setState(() { diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index d02cd014..b403fb6b 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -15,6 +15,8 @@ class _SqliteConnectionTile extends StatefulWidget { this.onTap, this.onSqliteObjectSelected, this.onSqliteOpenSqlWorkspace, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -29,25 +31,49 @@ class _SqliteConnectionTile extends StatefulWidget { SqliteObjectKind kind, )? onSqliteObjectSelected; final void Function(ConnectionRow connection)? onSqliteOpenSqlWorkspace; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_SqliteConnectionTile> createState() => _SqliteConnectionTileState(); } class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; List _tables = []; List _views = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _tables.isEmpty && _views.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadTables(); } } + @override + void didUpdateWidget(_SqliteConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_tables.isEmpty && _views.isEmpty && !_loading) { + _loadTables(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _tables = []; + _views = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadTables() async { if (!mounted) return; setState(() { diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index fab3619c..2e5b0d1c 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -149,8 +149,45 @@ class _MainScreenState extends State { width: 1, child: Column( children: [ - QueryaWindowTitleBar( - onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, + ValueListenableBuilder( + valueListenable: _workspace, + builder: (context, workspace, _) { + return QueryaWindowTitleBar( + onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, + activeConnection: workspace.activeConnection, + isReadOnly: workspace.isReadOnly, + onReadOnlyChanged: () { + _workspace.value = _workspace.value.toggleReadOnly(); + }, + onConnect: () { + final active = workspace.activeConnection; + if (active != null && active.id != null) { + _connectionsPanelKey.currentState?.connect(active.id!); + } + }, + onReconnect: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState?.reconnect(active); + } + }, + onDisconnect: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState?.disconnect(active); + } + }, + onDisconnectAll: () { + _connectionsPanelKey.currentState?.disconnectAll(); + }, + onDisconnectOthers: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState?.disconnectOthers(active); + } + }, + ); + }, ), Divider(height: 1, color: wb.borderSubtle.withValues(alpha: 0.22)), Expanded( @@ -303,6 +340,7 @@ class _MainContentSplitState extends State<_MainContentSplit> { mysqlSqlTabRequestToken: ws.mysqlSqlTabRequestToken, selectedSqliteObject: ws.selectedSqliteObject, sqliteSqlTabRequestToken: ws.sqliteSqlTabRequestToken, + isReadOnly: ws.isReadOnly, onRequestNewConnection: widget.onRequestNewConnection, ); }, diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index d11f74fa..a973d4a8 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.isReadOnly = false, }); final ConnectionRow? activeConnection; @@ -53,9 +54,27 @@ class MainScreenWorkspaceState { SqliteObjectKind kind })? selectedSqliteObject; final int sqliteSqlTabRequestToken; + final bool isReadOnly; static const empty = MainScreenWorkspaceState(); + MainScreenWorkspaceState toggleReadOnly() { + return MainScreenWorkspaceState( + activeConnection: activeConnection, + activeRedisDb: activeRedisDb, + activeMongoDB: activeMongoDB, + selectedPostgresObject: selectedPostgresObject, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: postgresSqlEditorContext, + postgresSqlEditorContextToken: postgresSqlEditorContextToken, + selectedMysqlObject: selectedMysqlObject, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + selectedSqliteObject: selectedSqliteObject, + sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: !isReadOnly, + ); + } + MainScreenWorkspaceState selectConnection(ConnectionRow connection) { return MainScreenWorkspaceState( activeConnection: connection, @@ -69,6 +88,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: false, ); } @@ -96,6 +116,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -121,6 +142,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -144,6 +166,7 @@ class MainScreenWorkspaceState { kind: kind, ), sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -160,6 +183,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -177,6 +201,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -235,6 +260,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -251,6 +277,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken + 1, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -267,6 +294,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken + 1, + isReadOnly: isReadOnly, ); } @@ -284,7 +312,8 @@ class MainScreenWorkspaceState { _mysqlEquals(selectedMysqlObject, other.selectedMysqlObject) && mysqlSqlTabRequestToken == other.mysqlSqlTabRequestToken && _sqliteEquals(selectedSqliteObject, other.selectedSqliteObject) && - sqliteSqlTabRequestToken == other.sqliteSqlTabRequestToken; + sqliteSqlTabRequestToken == other.sqliteSqlTabRequestToken && + isReadOnly == other.isReadOnly; } @override @@ -325,6 +354,7 @@ class MainScreenWorkspaceState { selectedSqliteObject!.kind, ), sqliteSqlTabRequestToken, + isReadOnly, ); } diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 2bfe0e9d..bba9a8e3 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -1,6 +1,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material show BuildContext, Container, Icon, Icons, MainAxisSize, Widget; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; @@ -12,9 +13,25 @@ class QueryaWindowTitleBar extends StatelessWidget { const QueryaWindowTitleBar({ super.key, required this.onNewDatabaseConnection, + this.activeConnection, + this.onConnect, + this.onReconnect, + this.onDisconnect, + this.onDisconnectAll, + this.onDisconnectOthers, + this.isReadOnly = false, + this.onReadOnlyChanged, }); final Future Function() onNewDatabaseConnection; + final ConnectionRow? activeConnection; + final VoidCallback? onConnect; + final VoidCallback? onReconnect; + final VoidCallback? onDisconnect; + final VoidCallback? onDisconnectAll; + final VoidCallback? onDisconnectOthers; + final bool isReadOnly; + final VoidCallback? onReadOnlyChanged; @visibleForTesting static Color titleBarBackground(BuildContext context) => @@ -147,39 +164,48 @@ class QueryaWindowTitleBar extends StatelessWidget { ), const MenuDivider(), MenuButton( - enabled: false, + enabled: activeConnection != null, leading: const material.Icon( material.Icons.power_rounded, size: 18), - onPressed: (_) {}, + onPressed: (_) => onConnect?.call(), child: const Text('Connect'), ), MenuButton( + enabled: activeConnection != null, leading: const material.Icon( material.Icons.refresh_rounded, size: 18), - onPressed: (_) {}, + onPressed: (_) => onReconnect?.call(), child: const Text('Invalidate/Reconnect'), ), MenuButton( + enabled: activeConnection != null, leading: const material.Icon( material.Icons.power_off_rounded, size: 18), - onPressed: (_) {}, + onPressed: (_) => onDisconnect?.call(), child: const Text('Disconnect'), ), MenuButton( - onPressed: (_) {}, + onPressed: (_) => onDisconnectAll?.call(), child: const Text('Disconnect All')), MenuButton( - onPressed: (_) {}, + enabled: activeConnection != null, + onPressed: (_) => onDisconnectOthers?.call(), child: const Text('Disconnect Others')), const MenuDivider(), MenuButton( + enabled: activeConnection != null, leading: const material.Icon( material.Icons.lock_outline_rounded, size: 18), - onPressed: (_) {}, + trailing: isReadOnly + ? const material.Icon( + material.Icons.check_rounded, + size: 16) + : null, + onPressed: (_) => onReadOnlyChanged?.call(), child: const Text('Read-only'), ), ], diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 9e5acd11..80d89b72 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -60,11 +60,13 @@ class WorkspacePanel extends StatefulWidget { this.mysqlSqlTabRequestToken = 0, this.selectedSqliteObject, this.sqliteSqlTabRequestToken = 0, + this.isReadOnly = false, this.onRequestNewConnection, }); /// Currently selected connection from the sidebar. final ConnectionRow? activeConnection; + final bool isReadOnly; /// When set, the user selected a specific Redis database in the sidebar tree. /// null = show stats, non-null = show data explorer for that db. @@ -159,6 +161,7 @@ class _WorkspacePanelState extends State { postgresSqlEditorContextToken: widget.postgresSqlEditorContextToken, sqlTabRequestToken: widget.postgresSqlTabRequestToken, + isReadOnly: widget.isReadOnly, ) : buildPostgresObjectWorkspace( connection: activeConn, @@ -172,6 +175,7 @@ class _WorkspacePanelState extends State { key: ValueKey('mysql_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.mysqlSqlTabRequestToken, + isReadOnly: widget.isReadOnly, ) : MysqlTableView( key: ValueKey( @@ -216,6 +220,7 @@ class _WorkspacePanelState extends State { key: ValueKey('sqlite_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.sqliteSqlTabRequestToken, + isReadOnly: widget.isReadOnly, ) : SqliteTableView( key: ValueKey( diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 339128c3..42c0aac1 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -25,9 +25,11 @@ class MysqlSqlWorkspace extends material.StatefulWidget { const MysqlSqlWorkspace({ super.key, required this.connectionRow, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; @override material.State createState() => _MysqlSqlWorkspaceState(); @@ -66,6 +68,15 @@ class _MysqlSqlWorkspaceState extends material.State { }); } + @override + void didUpdateWidget(covariant MysqlSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isReadOnly != widget.isReadOnly) { + _lease?.release(); + _lease = null; + } + } + Future _loadWorkspaceSettings() async { final t = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); @@ -94,7 +105,7 @@ class _MysqlSqlWorkspaceState extends material.State { final lease = await MysqlService.instance.acquire( widget.connectionRow, database: _poolDatabaseKey(), - mode: MysqlSessionMode.readWrite, + mode: widget.isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -116,7 +127,7 @@ class _MysqlSqlWorkspaceState extends material.State { MysqlService.instance.interrupt( widget.connectionRow, database: _poolDatabaseKey(), - mode: MysqlSessionMode.readWrite, + mode: widget.isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, ); } _lease?.release(); diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 81f9f8ad..9005ad9a 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -15,9 +15,11 @@ class MysqlWorkspaceHome extends material.StatefulWidget { super.key, required this.connectionRow, this.sqlTabRequestToken = 0, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; /// Parent increments to switch to the SQL tab (e.g. context menu on connection). final int sqlTabRequestToken; @@ -74,6 +76,14 @@ class _MysqlWorkspaceHomeState extends material.State { child: material.Row( children: [ const Text('MySQL').semiBold().small(), + if (widget.isReadOnly) ...[ + const Gap(6), + material.Icon( + material.Icons.lock_outline_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ], const Spacer(), ...List.generate(2, (i) { final labels = ['Server', 'SQL']; @@ -120,6 +130,7 @@ class _MysqlWorkspaceHomeState extends material.State { MysqlSqlWorkspace( key: ValueKey('mysql_sql_${widget.connectionRow.id}'), connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, ), ], ), diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index f6ecb83f..22bc209f 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -37,9 +37,11 @@ class PostgresSqlWorkspace extends material.StatefulWidget { this.transactionOpenNotifier, this.postgresSqlEditorContext, this.postgresSqlEditorContextToken = 0, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; /// Updated when transaction state changes (for tab-switch warnings). final material.ValueNotifier? transactionOpenNotifier; @@ -113,6 +115,9 @@ class _PostgresSqlWorkspaceState extends material.State { if (oldWidget.connectionRow.id != widget.connectionRow.id) { _lastAppliedSqlContextToken = -1; } + if (oldWidget.isReadOnly != widget.isReadOnly) { + _dropLease(); + } _syncPostgresSqlTreeContext(); } @@ -178,7 +183,7 @@ class _PostgresSqlWorkspaceState extends material.State { final lease = await PostgresService.instance.acquire( widget.connectionRow, database: db, - mode: PgSessionMode.readWrite, + mode: widget.isReadOnly ? PgSessionMode.readOnly : PgSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -259,7 +264,7 @@ class _PostgresSqlWorkspaceState extends material.State { PostgresService.instance.interrupt( widget.connectionRow, database: _interruptDatabase ?? _effectiveSessionDatabase(), - mode: PgSessionMode.readWrite, + mode: widget.isReadOnly ? PgSessionMode.readOnly : PgSessionMode.readWrite, ); } _dropLease(); diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index e2cea5b7..bcf1b99f 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -18,9 +18,11 @@ class PostgresWorkspaceHome extends material.StatefulWidget { this.postgresSqlEditorContext, this.postgresSqlEditorContextToken = 0, this.sqlTabRequestToken = 0, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; /// Set when opening SQL from the tree (e.g. "Open in SQL") to seed session DB + template. final ({ @@ -120,6 +122,14 @@ class _PostgresWorkspaceHomeState child: material.Row( children: [ const Text('PostgreSQL').semiBold().small(), + if (widget.isReadOnly) ...[ + const Gap(6), + material.Icon( + material.Icons.lock_outline_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ], const Spacer(), ...List.generate(2, (i) { final labels = ['Server', 'SQL']; @@ -170,6 +180,7 @@ class _PostgresWorkspaceHomeState postgresSqlEditorContext: widget.postgresSqlEditorContext, postgresSqlEditorContextToken: widget.postgresSqlEditorContextToken, + isReadOnly: widget.isReadOnly, ), ], ), diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 45d38d52..2f34b117 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -21,9 +21,11 @@ class SqliteSqlWorkspace extends material.StatefulWidget { const SqliteSqlWorkspace({ super.key, required this.connectionRow, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; @override material.State createState() => _SqliteSqlWorkspaceState(); @@ -60,6 +62,15 @@ class _SqliteSqlWorkspaceState extends material.State { }); } + @override + void didUpdateWidget(covariant SqliteSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isReadOnly != widget.isReadOnly) { + _lease?.release(); + _lease = null; + } + } + Future _loadWorkspaceSettings() async { final rows = await AppSettings.instance.getSqlResultMaxRows(); final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); @@ -78,7 +89,7 @@ class _SqliteSqlWorkspaceState extends material.State { _lease = null; final lease = await SqliteService.instance.acquire( widget.connectionRow, - mode: SqliteSessionMode.readWrite, + mode: widget.isReadOnly ? SqliteSessionMode.readOnly : SqliteSessionMode.readWrite, ); if (!mounted) { lease.release(); diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index 67991e09..94fc438a 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -8,10 +8,12 @@ class SqliteWorkspaceHome extends material.StatefulWidget { super.key, required this.connectionRow, this.sqlTabRequestToken = 0, + this.isReadOnly = false, }); final ConnectionRow connectionRow; final int sqlTabRequestToken; + final bool isReadOnly; @override material.State createState() => _SqliteWorkspaceHomeState(); @@ -33,6 +35,14 @@ class _SqliteWorkspaceHomeState extends material.State { child: material.Row( children: [ const Text('SQLite').semiBold().small(), + if (widget.isReadOnly) ...[ + const Gap(6), + material.Icon( + material.Icons.lock_outline_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ], const Spacer(), material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -50,6 +60,7 @@ class _SqliteWorkspaceHomeState extends material.State { child: SqliteSqlWorkspace( key: ValueKey('sqlite_sql_${widget.connectionRow.id}'), connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, ), ), ], diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index 3f68e14b..4a67b096 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -246,4 +246,124 @@ void main() { _expectTextCount('Mongo local', 1); }); }); + + group('ConnectionsPanel state control methods', () { + late Directory stateTempDir; + + setUp(() async { + stateTempDir = await Directory.systemTemp.createTemp('querya_conn_state_test_'); + PathProviderPlatform.instance = _FakePathProvider(stateTempDir.path); + await LocalDb.instance.close(); + await LocalDb.instance.addConnection( + ConnectionRow( + type: 'generic', + name: 'Conn 1', + createdAt: _isoNow(), + ), + ); + await LocalDb.instance.addConnection( + ConnectionRow( + type: 'generic', + name: 'Conn 2', + createdAt: _isoNow(), + ), + ); + await FoldersStorage.instance.reload(); + }); + + tearDown(() async { + await LocalDb.instance.close(); + if (await stateTempDir.exists()) { + await stateTempDir.delete(recursive: true); + } + }); + + testWidgets('connect, disconnect, disconnectAll, and disconnectOthers update state', (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.SizedBox.expand( + child: ConnectionsPanel( + skipInitialDbLoadForTest: true, + onPostgresOpenSqlWorkspace: (_, {database, schema, name, kind}) {}, + ), + ), + ), + ); + await tester.pump(); + + final panelState = tester.state( + find.byType(ConnectionsPanel), + ); + + await tester.runAsync(() async { + await panelState.reloadConnectionsFromDb(); + }); + await tester.pump(); + + late final List conns; + await tester.runAsync(() async { + conns = await LocalDb.instance.getConnections(); + }); + final conn1 = conns.firstWhere((c) => c.name == 'Conn 1'); + final conn2 = conns.firstWhere((c) => c.name == 'Conn 2'); + final id1 = conn1.id!; + final id2 = conn2.id!; + + // 1. Initial state + expect(panelState.isConnectionExpanded(id1), false); + expect(panelState.isConnectionExpanded(id2), false); + + // 2. Connect + panelState.connect(id1); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + expect(panelState.isConnectionExpanded(id2), false); + + // 3. Disconnect Others + panelState.connect(id2); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + expect(panelState.isConnectionExpanded(id2), true); + + await tester.runAsync(() async { + await panelState.disconnectOthers(conn1); + }); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + expect(panelState.isConnectionExpanded(id2), false); + + // 4. Disconnect + await tester.runAsync(() async { + await panelState.disconnect(conn1); + }); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), false); + + // 5. Disconnect All + panelState.connect(id1); + panelState.connect(id2); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + expect(panelState.isConnectionExpanded(id2), true); + + await tester.runAsync(() async { + await panelState.disconnectAll(); + }); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), false); + expect(panelState.isConnectionExpanded(id2), false); + + // 6. Reconnect + panelState.connect(id1); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + + await tester.runAsync(() async { + await panelState.reconnect(conn1); + }); + await tester.pump(const Duration(milliseconds: 100)); + expect(panelState.isConnectionExpanded(id1), true); + }); + }); } diff --git a/test/features/main_screen/main_screen_workspace_state_test.dart b/test/features/main_screen/main_screen_workspace_state_test.dart index 4023ef9e..411eac6c 100644 --- a/test/features/main_screen/main_screen_workspace_state_test.dart +++ b/test/features/main_screen/main_screen_workspace_state_test.dart @@ -166,5 +166,23 @@ void main() { ); expect(a, isNot(c)); }); + + test('read-only state toggle and reset', () { + var state = MainScreenWorkspaceState.empty; + expect(state.isReadOnly, isFalse); + + state = state.toggleReadOnly(); + expect(state.isReadOnly, isTrue); + + state = state.toggleReadOnly(); + expect(state.isReadOnly, isFalse); + + state = state.toggleReadOnly(); + expect(state.isReadOnly, isTrue); + + // Selecting a new connection should reset isReadOnly to false + state = state.selectConnection(mysqlConn); + expect(state.isReadOnly, isFalse); + }); }); } From cc39154fc8ea04cb504209639b1547424fbfecdb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 22 Jun 2026 12:42:48 +0300 Subject: [PATCH 11/23] docs: document SQLite support, read-only mode, and connection actions --- docs/user-guide.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 78cc25ee..12997878 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -4,9 +4,18 @@ 1. Start the app. 2. Create a connection: **Connection → New Database Connection** (or right-click **Servers** in the tree). -3. Pick **PostgreSQL**, **MySQL**, **Redis**, or **MongoDB** and fill in host, port, and credentials. +3. Pick **PostgreSQL**, **MySQL**, **SQLite**, **Redis**, or **MongoDB**. For SQLite, select the database file; for other databases, fill in host, port, and credentials. + - **Read-only mode**: You can toggle the **Read-only** option to prevent write operations (a lock icon will appear in the workspace). 4. Saved connections appear in the left tree. +## Connection Actions + +The application menu and window title bar provide actions for managing database connections: +- **Connect**: Connect to the selected database connection. +- **Invalidate/Reconnect**: Refresh and re-establish the connection to the database. +- **Disconnect**: Safely close the active session. +- **Read-only**: Toggle read-only mode for the current session. + ## Where data is stored - **Connection list and settings**: local SQLite (`querya.db` under the app support directory). @@ -33,6 +42,6 @@ Preferences (except secrets) live in the same local SQLite file as connection me ## Supported capabilities -High-level feature depth varies by database type. PostgreSQL and MySQL include rich object trees and SQL workspaces; Redis and MongoDB focus on data exploration and commands suitable for day-to-day development. +High-level feature depth varies by database type. PostgreSQL, MySQL, and SQLite include rich object trees and SQL workspaces (with SQLite utilizing local `.db` files); Redis and MongoDB focus on data exploration and commands suitable for day-to-day development. For troubleshooting build/run issues, see the main [README.md](../README.md). From c2d0a5441e141f2287b8406ade885ad5da4f02ef Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 22 Jun 2026 14:02:46 +0300 Subject: [PATCH 12/23] chore(release): prepare pre-release 0.4.7-a --- CHANGELOG.md | 12 ++++++++++++ pubspec.yaml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e691251..b418fe2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.7-a] - 2026-06-22 + +### Added + +- **Connection Invalidate/Reconnect** — added Invalidate/Reconnect action in the application menu and window title bar. +- **Connection Read-only Mode** — added Read-only mode for PostgreSQL and SQLite database connections to prevent write operations (displays a lock icon in the workspace). +- **Connection Lifecycle Management** — added Connect and Disconnect menu items and corresponding title bar controls. + +### Documentation + +- Added comprehensive setup guides for SQLite, Read-only connection mode, and connection lifecycle controls in the English user guide and Obsidian Russian notes. + ## [0.4.7] - 2026-06-21 Local extension discovery and manifest foundation release. Git tag **`0.4.7`**. diff --git a/pubspec.yaml b/pubspec.yaml index 33f52043..835024d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.7+13 +version: 0.4.7-a From 44ddb8059de19d15ba324dc6da7584f1e2be3405 Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:28:04 +0300 Subject: [PATCH 13/23] feat(ui): Extension Manager View (#239) * docs(planning): add 0.4.8 milestone docs and bump version * feat(ui): add Extension Manager View UI-EXT-1 * fix(ui): resolve flutter analyze errors in extension manager dialog --- docs/planned-0.4.8.md | 14 ++ .../pages/extension_manager_dialog.dart | 133 ++++++++++++++++++ .../main_screen/querya_window_title_bar.dart | 10 ++ pubspec.yaml | 2 +- 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 docs/planned-0.4.8.md create mode 100644 lib/features/extensions/presentation/pages/extension_manager_dialog.dart diff --git a/docs/planned-0.4.8.md b/docs/planned-0.4.8.md new file mode 100644 index 00000000..96898554 --- /dev/null +++ b/docs/planned-0.4.8.md @@ -0,0 +1,14 @@ +# Milestone 0.4.8: Extension Manager UI + +**Theme**: Разработка пользовательского интерфейса менеджера расширений (Marketplace Client). Мы создаем вкладку в настройках или отдельное окно, где пользователь сможет просматривать установленные расширения, искать новые в маркетплейсе, скачивать, обновлять и удалять их. Бэкенд маркетплейса пока будет замокан или использоваться статический JSON для тестов. + +| ID | Scope | Summary | +|----|-------|---------| +| **UI-EXT-1** | `ui`, `extensions` | **Extension Manager View** — Создать страницу/диалог для менеджера расширений. Вкладки: Installed, Marketplace, Updates. | +| **UI-EXT-2** | `ui`, `extensions` | **Extension Card Component** — Виджет карточки расширения (иконка, название, автор, версия, описание, кнопка Install/Uninstall). | +| **UI-EXT-3** | `core`, `extensions` | **MarketplaceRepository Mock** — Реализовать моковый репозиторий для симуляции запросов к API маркетплейса (пока бэкенд не готов). | +| **UI-EXT-4** | `core`, `extensions` | **Download & Install Flow** — Интеграция UI с процессом скачивания (прогресс-бар), валидации sha256 и распаковки в `~/.querya/extensions/`. | +| **UI-EXT-5** | `core`, `extensions` | **Uninstall & Update** — Механизмы удаления и обновления локальных расширений через UI менеджера. | + +## Заметки +Этот релиз фокусируется на пользовательском опыте (UX/UI) взаимодействия с расширениями. Мы не реализуем сам Marketplace API (он будет в 0.5.0), но закладываем сетевой слой клиента (`MarketplaceRepository`) и мокаем данные для того, чтобы UI можно было использовать и тестировать. Под капотом используются механизмы локального обнаружения, заложенные в релизе 0.4.7. diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart new file mode 100644 index 00000000..8c87346f --- /dev/null +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +void showExtensionManagerDialog(material.BuildContext context) { + showAppDialog( + context: context, + builder: (ctx) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(ctx), + child: const _ExtensionManagerContent(), + ), + ); +} + +class _ExtensionManagerContent extends material.StatefulWidget { + const _ExtensionManagerContent(); + + @override + material.State<_ExtensionManagerContent> createState() => + _ExtensionManagerContentState(); +} + +class _ExtensionManagerContentState extends material.State<_ExtensionManagerContent> { + int _tabIndex = 0; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + final onPopover = theme.popoverForeground; + + return material.DefaultTextStyle( + style: material.TextStyle(color: onPopover), + child: material.IconTheme( + data: material.IconThemeData(color: onPopover), + child: material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 800, + minWidth: 600, + maxHeight: 700, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.border), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Extensions').large().semiBold().foreground(), + const material.SizedBox(height: 6), + const Text('Manage local and marketplace extensions') + .muted() + .small(), + ], + ), + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 24.0, vertical: 8.0), + child: material.Row( + children: [ + _buildTabButton(0, 'Installed'), + const material.SizedBox(width: 8), + _buildTabButton(1, 'Marketplace'), + const material.SizedBox(width: 8), + _buildTabButton(2, 'Updates'), + ], + ), + ), + material.Divider(height: 1, color: theme.border), + material.Expanded( + child: material.IndexedStack( + index: _tabIndex, + children: [ + _buildInstalledTab(), + _buildMarketplaceTab(), + _buildUpdatesTab(), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + material.Widget _buildTabButton(int index, String label) { + final isSelected = _tabIndex == index; + return SecondaryButton( + onPressed: () => setState(() => _tabIndex = index), + child: material.Text( + label, + style: material.TextStyle( + color: isSelected ? Theme.of(context).colorScheme.primary : null, + ), + ), + ); + } + + material.Widget _buildInstalledTab() { + return const material.Center(child: Text('Installed extensions will appear here.')); + } + + material.Widget _buildMarketplaceTab() { + return const material.Center(child: Text('Marketplace extensions will appear here.')); + } + + material.Widget _buildUpdatesTab() { + return const material.Center(child: Text('Extension updates will appear here.')); + } +} diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index bba9a8e3..ef072943 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -5,6 +5,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -134,6 +135,15 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (ctx) => showPreferencesDialog(ctx), child: const Text('Preferences…'), ), + const MenuDivider(), + MenuButton( + leading: const material.Icon( + material.Icons.extension_rounded, + size: 18, + ), + onPressed: (ctx) => showExtensionManagerDialog(ctx), + child: const Text('Extensions…'), + ), ], child: const Text('Edit'), ), diff --git a/pubspec.yaml b/pubspec.yaml index 835024d8..e2585775 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.7-a +version: 0.4.8 From 23cef196351997bb25d886786321f0da464cfc6b Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:13:20 +0300 Subject: [PATCH 14/23] feat(ui): Extension Card Component (#240) * feat(ui): add Extension Card Component UI-EXT-2 * fix(ui): use theme.card instead of theme.surface in extension card --- .../pages/extension_manager_dialog.dart | 51 +++++++- .../presentation/widgets/extension_card.dart | 122 ++++++++++++++++++ 2 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 lib/features/extensions/presentation/widgets/extension_card.dart diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 8c87346f..323847cb 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart' as material; +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/extensions/presentation/widgets/extension_card.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -24,6 +27,30 @@ class _ExtensionManagerContent extends material.StatefulWidget { class _ExtensionManagerContentState extends material.State<_ExtensionManagerContent> { int _tabIndex = 0; + final List _installedMocks = [ + const ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + description: 'Full support for ClickHouse databases including Dictionaries and Materialized Views.', + ), + ]; + + final List _marketMocks = [ + const ExtensionManifest( + id: 'community.redis-driver', + name: 'Redis Driver', + version: '0.9.5', + publisher: 'Community', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + description: 'Connect to Redis instances and visualize key-value storage.', + ), + ]; + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; @@ -120,14 +147,32 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont } material.Widget _buildInstalledTab() { - return const material.Center(child: Text('Installed extensions will appear here.')); + return material.ListView.separated( + padding: const material.EdgeInsets.all(24), + itemCount: _installedMocks.length, + separatorBuilder: (_, __) => const material.SizedBox(height: 16), + itemBuilder: (ctx, i) => ExtensionCard( + manifest: _installedMocks[i], + isInstalled: true, + onUninstall: () {}, + ), + ); } material.Widget _buildMarketplaceTab() { - return const material.Center(child: Text('Marketplace extensions will appear here.')); + return material.ListView.separated( + padding: const material.EdgeInsets.all(24), + itemCount: _marketMocks.length, + separatorBuilder: (_, __) => const material.SizedBox(height: 16), + itemBuilder: (ctx, i) => ExtensionCard( + manifest: _marketMocks[i], + isInstalled: false, + onInstall: () {}, + ), + ); } material.Widget _buildUpdatesTab() { - return const material.Center(child: Text('Extension updates will appear here.')); + return const material.Center(child: material.Text('No updates available.')); } } diff --git a/lib/features/extensions/presentation/widgets/extension_card.dart b/lib/features/extensions/presentation/widgets/extension_card.dart new file mode 100644 index 00000000..5f5fc45e --- /dev/null +++ b/lib/features/extensions/presentation/widgets/extension_card.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +class ExtensionCard extends material.StatelessWidget { + const ExtensionCard({ + super.key, + required this.manifest, + required this.isInstalled, + this.hasUpdate = false, + this.onInstall, + this.onUninstall, + this.onUpdate, + }); + + final ExtensionManifest manifest; + final bool isInstalled; + final bool hasUpdate; + final material.VoidCallback? onInstall; + final material.VoidCallback? onUninstall; + final material.VoidCallback? onUpdate; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusMd; + + return material.Container( + padding: const material.EdgeInsets.all(16), + decoration: material.BoxDecoration( + color: theme.card, + border: material.Border.all(color: theme.border), + borderRadius: material.BorderRadius.circular(radius), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + _buildIcon(theme, radius), + const material.SizedBox(width: 16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + material.Expanded( + child: Text(manifest.name).large().semiBold(), + ), + ], + ), + const material.SizedBox(height: 4), + material.Row( + children: [ + Text(manifest.publisher).muted().small(), + const material.SizedBox(width: 8), + material.Container( + width: 4, + height: 4, + decoration: material.BoxDecoration( + color: theme.mutedForeground, + shape: material.BoxShape.circle, + ), + ), + const material.SizedBox(width: 8), + Text('v${manifest.version}').muted().small(), + ], + ), + const material.SizedBox(height: 8), + Text( + manifest.description ?? 'No description provided.', + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted(), + ], + ), + ), + const material.SizedBox(width: 16), + material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.end, + children: [ + if (isInstalled && hasUpdate) + material.Padding( + padding: const material.EdgeInsets.only(bottom: 8.0), + child: PrimaryButton( + onPressed: onUpdate, + child: const Text('Update'), + ), + ), + if (isInstalled) + SecondaryButton( + onPressed: onUninstall, + child: const Text('Uninstall'), + ), + if (!isInstalled) + PrimaryButton( + onPressed: onInstall, + child: const Text('Install'), + ), + ], + ), + ], + ), + ); + } + + material.Widget _buildIcon(ColorScheme theme, double radius) { + return material.Container( + width: 48, + height: 48, + decoration: material.BoxDecoration( + color: theme.muted, + borderRadius: material.BorderRadius.circular(radius), + ), + child: material.Icon( + material.Icons.extension_rounded, + size: 24, + color: theme.mutedForeground, + ), + ); + } +} From 85637a479976219db707ee804daf2346acf1548d Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:18:21 +0300 Subject: [PATCH 15/23] feat(storage): implement atomic updateConnection in LocalDb (#240) (#245) * feat(ui): add Extension Card Component UI-EXT-2 * fix(ui): use theme.card instead of theme.surface in extension card * feat(market): implement MarketplaceRepository with mock ecosystem and UI integration * feat(storage): implement atomic updateConnection in LocalDb (#240) * style: fix all lint warnings and analyzer suggestions (#240) --- .../extensions/models/extension_manifest.dart | 65 +++- lib/core/market/extension_manifest.dart | 65 +--- .../market/http_marketplace_repository.dart | 204 ++++++++++++ lib/core/market/marketplace_client.dart | 16 +- lib/core/market/marketplace_repository.dart | 306 ++++++++++++++++++ lib/core/storage/local_db.dart | 24 ++ .../pages/extension_manager_dialog.dart | 226 +++++++++---- .../presentation/widgets/extension_card.dart | 69 +++- pubspec.yaml | 1 + .../market/marketplace_repository_test.dart | 225 +++++++++++++ test/core/storage/local_db_secrets_test.dart | 39 +++ .../extensions/extension_manager_test.dart | 134 ++++++++ 12 files changed, 1237 insertions(+), 137 deletions(-) create mode 100644 lib/core/market/http_marketplace_repository.dart create mode 100644 lib/core/market/marketplace_repository.dart create mode 100644 test/core/market/marketplace_repository_test.dart create mode 100644 test/features/extensions/extension_manager_test.dart diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index cafa2e04..efd9a6e1 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -1,6 +1,9 @@ +import '../../theme/theme_definition.dart'; import 'extension_type.dart'; class ExtensionManifest { + static const typeTheme = ExtensionType.theme; + final String id; final String name; final String version; @@ -11,6 +14,13 @@ class ExtensionManifest { final String? icon; final String? description; final String? installPath; + final String? downloadUrl; + final String? sha256Checksum; + final String? author; + final String? homepage; + final String? license; + final String? preview; + final List tags; const ExtensionManifest({ required this.id, @@ -23,20 +33,62 @@ class ExtensionManifest { this.icon, this.description, this.installPath, + this.downloadUrl, + this.sha256Checksum, + this.author, + this.homepage, + this.license, + this.preview, + this.tags = const [], }); + /// Maps a registry [ThemeDefinition] into marketplace field names. + factory ExtensionManifest.fromThemeDefinition( + ThemeDefinition definition, { + String downloadUrl = '', + String sha256Checksum = '', + ExtensionType type = typeTheme, + }) { + final metadata = definition.metadata; + return ExtensionManifest( + id: definition.id, + name: definition.name, + publisher: metadata?.author ?? 'Unknown', + type: type, + version: metadata?.version ?? '0.0.0', + engines: const {'querya_desktop': '^0.4.7'}, + downloadUrl: downloadUrl, + sha256Checksum: sha256Checksum.isNotEmpty + ? sha256Checksum + : (definition.contentHash ?? ''), + author: metadata?.author, + description: metadata?.description, + homepage: metadata?.homepage, + license: metadata?.license, + preview: metadata?.preview, + tags: metadata?.tags ?? const [], + ); + } + factory ExtensionManifest.fromJson(Map json, {String? installPath}) { return ExtensionManifest( id: json['id'] as String, name: json['name'] as String, - version: json['version'] as String, - publisher: json['publisher'] as String, - type: ExtensionType.fromString(json['type'] as String), + version: json['version'] as String? ?? '0.0.0', + 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?, icon: json['icon'] as String?, description: json['description'] as String?, installPath: installPath, + downloadUrl: json['downloadUrl'] 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? ?? []), ); } @@ -51,6 +103,13 @@ class ExtensionManifest { if (main != null) 'main': main, if (icon != null) 'icon': icon, if (description != null) 'description': description, + if (downloadUrl != null) 'downloadUrl': downloadUrl, + if (sha256Checksum != null) 'sha256Checksum': sha256Checksum, + if (author != null) 'author': author, + if (homepage != null) 'homepage': homepage, + if (license != null) 'license': license, + if (preview != null) 'preview': preview, + if (tags.isNotEmpty) 'tags': tags, }; } } diff --git a/lib/core/market/extension_manifest.dart b/lib/core/market/extension_manifest.dart index be1edec8..4cbed8b3 100644 --- a/lib/core/market/extension_manifest.dart +++ b/lib/core/market/extension_manifest.dart @@ -1,65 +1,2 @@ -import '../theme/theme_definition.dart'; +export 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; -/// Marketplace listing model (future `MarketplaceClient` response shape). -/// -/// See [docs/market-tech.md](https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/market-tech.md). -class ExtensionManifest { - const ExtensionManifest({ - required this.id, - required this.name, - required this.type, - required this.version, - required this.downloadUrl, - required this.sha256Checksum, - this.author, - this.description, - this.homepage, - this.license, - this.preview, - this.tags = const [], - }); - - static const typeTheme = 'theme'; - - final String id; - final String name; - final String type; - final String version; - final String downloadUrl; - final String sha256Checksum; - final String? author; - final String? description; - final String? homepage; - final String? license; - final String? preview; - final List tags; - - /// Maps a registry [ThemeDefinition] into marketplace field names. - /// - /// [downloadUrl] and [sha256Checksum] are required for remote install (TP-F4); - /// pass empty strings when building a local-only listing stub. - factory ExtensionManifest.fromThemeDefinition( - ThemeDefinition definition, { - String downloadUrl = '', - String sha256Checksum = '', - String type = typeTheme, - }) { - final metadata = definition.metadata; - return ExtensionManifest( - id: definition.id, - name: definition.name, - type: type, - version: metadata?.version ?? '0.0.0', - downloadUrl: downloadUrl, - sha256Checksum: sha256Checksum.isNotEmpty - ? sha256Checksum - : (definition.contentHash ?? ''), - author: metadata?.author, - description: metadata?.description, - homepage: metadata?.homepage, - license: metadata?.license, - preview: metadata?.preview, - tags: metadata?.tags ?? const [], - ); - } -} diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart new file mode 100644 index 00000000..03d7a7e9 --- /dev/null +++ b/lib/core/market/http_marketplace_repository.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.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/models/extension_type.dart'; +import 'marketplace_repository.dart'; + +class MarketplaceException implements Exception { + MarketplaceException(this.message); + final String message; + @override + String toString() => 'MarketplaceException: $message'; +} + +/// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend. +/// +/// Implements secure downloading with SHA256 checksum verification and safe +/// archive extraction preventing Path Traversal and Zip Bomb vulnerabilities (Issue #242). +class HttpMarketplaceRepository implements MarketplaceRepository { + HttpMarketplaceRepository({ + this.baseUrl = 'http://localhost:8000/api/v1', + http.Client? client, + }) : _client = client ?? http.Client(); + + final String baseUrl; + final http.Client _client; + + @override + Future> getTrending({ExtensionType? type}) async { + final uri = Uri.parse('$baseUrl/extensions/trending').replace( + queryParameters: type != null ? {'type': type.value} : null, + ); + final response = await _client.get(uri).timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw MarketplaceException('Failed to load trending extensions (HTTP ${response.statusCode})'); + } + final List data = jsonDecode(response.body) as List; + return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); + } + + @override + Future> search(String query, {ExtensionType? type}) async { + final uri = Uri.parse('$baseUrl/extensions/search').replace( + queryParameters: { + 'q': query.trim(), + if (type != null) 'type': type.value, + }, + ); + final response = await _client.get(uri).timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw MarketplaceException('Search failed (HTTP ${response.statusCode})'); + } + final List data = jsonDecode(response.body) as List; + return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); + } + + @override + Future download(String url, {void Function(double)? onProgress}) async { + final uri = Uri.tryParse(url); + if (uri == null) { + throw MarketplaceException('Invalid download URL: $url'); + } + + final request = http.Request('GET', uri); + final response = await _client.send(request).timeout(const Duration(seconds: 30)); + if (response.statusCode != 200) { + throw MarketplaceException('Download failed with HTTP status ${response.statusCode}'); + } + + final contentLength = response.contentLength ?? 0; + int received = 0; + + final tmpDir = Directory.systemTemp; + final file = File(p.join(tmpDir.path, 'querya_ext_${DateTime.now().millisecondsSinceEpoch}.zip')); + final sink = file.openWrite(); + + try { + await response.stream.forEach((chunk) { + sink.add(chunk); + received += chunk.length; + if (contentLength > 0 && onProgress != null) { + onProgress(received / contentLength); + } + }); + } finally { + await sink.close(); + } + + if (onProgress != null && contentLength == 0) { + onProgress(1.0); + } + + return file; + } + + @override + Future install( + ExtensionManifest manifest, { + void Function(double)? onProgress, + }) async { + final downloadUrl = manifest.downloadUrl; + if (downloadUrl == null || downloadUrl.trim().isEmpty) { + throw MarketplaceException('Extension manifest is missing downloadUrl'); + } + + // Step 1: Download archive with progress reporting (up to 80% of total progress) + final archiveFile = await download( + downloadUrl, + onProgress: (p) => onProgress?.call(p * 0.8), + ); + + try { + // Step 2: SHA-256 Integrity Verification (Critical Security Check) + if (manifest.sha256Checksum != null && manifest.sha256Checksum!.trim().isNotEmpty) { + final bytes = await archiveFile.readAsBytes(); + final actualSha256 = sha256.convert(bytes).toString().toLowerCase(); + final expectedSha256 = manifest.sha256Checksum!.trim().toLowerCase(); + if (actualSha256 != expectedSha256) { + throw MarketplaceException( + 'SHA256 checksum mismatch for "${manifest.id}". Expected: $expectedSha256, Actual: $actualSha256. Installation aborted.', + ); + } + } + + onProgress?.call(0.85); + + // Step 3: Safe Archive Extraction (Preventing Path Traversal / Zip Bomb - Issue #242) + final bytes = await archiveFile.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, manifest.id)); + if (!await extDir.exists()) { + await extDir.create(recursive: true); + } + + final extDirPath = p.normalize(extDir.path); + + for (final file in archive) { + final filename = file.name; + // Check for Path Traversal attempts + if (filename.contains('..') || filename.startsWith('/') || filename.startsWith('\\')) { + throw MarketplaceException('Security violation: Path traversal detected in archive entry "$filename"'); + } + + final targetPath = p.normalize(p.join(extDirPath, filename)); + if (!targetPath.startsWith(extDirPath)) { + throw MarketplaceException('Security violation: Extraction path out of bounds "$filename"'); + } + + if (file.isFile) { + final outFile = File(targetPath); + await outFile.create(recursive: true); + await outFile.writeAsBytes(file.content as List); + } else { + await Directory(targetPath).create(recursive: true); + } + } + + onProgress?.call(0.95); + + // Step 4: Write/Update manifest.json in the extension directory + final manifestFile = File(p.join(extDir.path, 'manifest.json')); + const encoder = JsonEncoder.withIndent(' '); + await manifestFile.writeAsString(encoder.convert(manifest.toJson())); + + // Step 5: Reload local extension registry + await LocalExtensionRegistry.instance.reload(); + onProgress?.call(1.0); + } finally { + if (await archiveFile.exists()) { + await archiveFile.delete(); + } + } + } + + @override + Future uninstall(String extensionId) async { + final manifest = LocalExtensionRegistry.instance.manifests + .where((e) => e.id == extensionId) + .firstOrNull; + + if (manifest != null && manifest.installPath != null) { + final extDir = Directory(manifest.installPath!); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } else { + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, extensionId)); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } + + await LocalExtensionRegistry.instance.reload(); + } +} diff --git a/lib/core/market/marketplace_client.dart b/lib/core/market/marketplace_client.dart index 1c450cb0..828bbf60 100644 --- a/lib/core/market/marketplace_client.dart +++ b/lib/core/market/marketplace_client.dart @@ -1,4 +1,5 @@ import 'extension_manifest.dart'; +import 'marketplace_repository.dart'; /// Future marketplace API client (mockable until backend exists). /// @@ -10,24 +11,19 @@ abstract class MarketplaceClient { }); } -/// In-memory placeholder for local development and tests. +/// In-memory placeholder for local development and tests that delegates to [MarketplaceRepository]. class MockMarketplaceClient implements MarketplaceClient { MockMarketplaceClient({List? seed}) - : _items = List.from(seed ?? const []); + : _repository = MockMarketplaceRepository(seed: seed); - final List _items; + final MockMarketplaceRepository _repository; @override Future> searchExtensions({ required String query, String? type, }) async { - final normalized = query.trim().toLowerCase(); - return _items.where((item) { - if (type != null && item.type != type) return false; - if (normalized.isEmpty) return true; - return item.name.toLowerCase().contains(normalized) || - item.id.toLowerCase().contains(normalized); - }).toList(growable: false); + return _repository.search(query); } } + diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart new file mode 100644 index 00000000..421ac9b6 --- /dev/null +++ b/lib/core/market/marketplace_repository.dart @@ -0,0 +1,306 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.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/models/extension_type.dart'; + +export 'http_marketplace_repository.dart'; + +/// Abstract repository contract for Marketplace operations (Block B). +/// +/// See [docs/market-tech.md] and Block B specification. +abstract class MarketplaceRepository { + static MarketplaceRepository instance = MockMarketplaceRepository(); + + /// Returns trending and recommended marketplace extensions. + Future> getTrending({ExtensionType? type}); + + /// Searches the marketplace catalog by query string and optional type filter. + Future> search(String query, {ExtensionType? type}); + + /// Simulates downloading an archive from [url] with progress reporting. + Future download(String url, {void Function(double)? onProgress}); + + /// Installs an extension by downloading, validating, and registering it locally. + Future install( + ExtensionManifest manifest, { + void Function(double)? onProgress, + }); + + /// Uninstalls a locally installed extension by ID. + Future uninstall(String extensionId); +} + +/// In-memory mock implementation for local UI development and testing (Release 0.4.8). +class MockMarketplaceRepository implements MarketplaceRepository { + MockMarketplaceRepository({List? seed}) + : _items = List.from(seed ?? _defaultMocks); + + final List _items; + + static final List _defaultMocks = [ + const ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Full support for ClickHouse databases including Dictionaries, Materialized Views, and real-time query metrics.', + downloadUrl: 'https://cdn.queryahub.com/extensions/clickhouse-driver-1.0.0.zip', + sha256Checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + homepage: 'https://queryahub.com/drivers/clickhouse', + license: 'MIT', + tags: ['database', 'clickhouse', 'olap', 'official'], + ), + const ExtensionManifest( + id: 'community.redis-driver', + name: 'Redis Driver', + version: '0.9.5', + publisher: 'Community', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Connect to Redis instances, visualize key-value storage, inspect Pub/Sub channels, and edit JSON documents.', + downloadUrl: 'https://cdn.queryahub.com/extensions/redis-driver-0.9.5.zip', + sha256Checksum: '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4', + homepage: 'https://github.com/queryahub/redis-driver', + license: 'Apache-2.0', + tags: ['database', 'redis', 'nosql', 'key-value'], + ), + const ExtensionManifest( + id: 'queryahub.cyberpunk-neon', + name: 'Cyberpunk Neon', + version: '1.2.0', + publisher: 'QueryaHub', + type: ExtensionType.theme, + engines: {'querya_desktop': '^0.4.7'}, + main: 'theme.json', + description: 'Vibrant neon color scheme inspired by Cyberpunk 2077 with glowing syntax highlighting and dark futuristic workbench.', + downloadUrl: 'https://cdn.queryahub.com/themes/cyberpunk-neon-1.2.0.zip', + sha256Checksum: 'a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0', + homepage: 'https://queryahub.com/themes/cyberpunk', + license: 'MIT', + tags: ['theme', 'dark', 'cyberpunk', 'neon', 'official'], + ), + const ExtensionManifest( + id: 'community.nord-theme', + name: 'Nord Theme', + version: '0.8.2', + publisher: 'Community', + type: ExtensionType.theme, + engines: {'querya_desktop': '^0.4.7'}, + main: 'theme.json', + description: 'An arctic, north-bluish clean and elegant color palette designed for focused, distraction-free SQL coding.', + downloadUrl: 'https://cdn.queryahub.com/themes/nord-theme-0.8.2.zip', + sha256Checksum: 'b2c3d4e5f6a10718293a4b5c6d7e8f90123456789abcdef0123456789abcdef1', + homepage: 'https://github.com/arcticicestudio/nord', + license: 'MIT', + tags: ['theme', 'dark', 'nord', 'arctic', 'minimal'], + ), + const ExtensionManifest( + id: 'queryahub.mongodb-driver', + name: 'MongoDB Driver', + version: '1.1.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Explore MongoDB collections, execute aggregation pipelines, and view BSON documents in an interactive tree view.', + downloadUrl: 'https://cdn.queryahub.com/extensions/mongodb-driver-1.1.0.zip', + sha256Checksum: 'c3d4e5f6a1b20718293a4b5c6d7e8f90123456789abcdef0123456789abcdef2', + homepage: 'https://queryahub.com/drivers/mongodb', + license: 'MIT', + tags: ['database', 'mongodb', 'nosql', 'bson', 'official'], + ), + const ExtensionManifest( + id: 'community.postgres-exporter', + name: 'PostgreSQL Schema Exporter', + version: '0.5.0', + publisher: 'Community', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Export complex PostgreSQL schemas to dbdiagram.io, Mermaid ERD, and DDL scripts with one click.', + downloadUrl: 'https://cdn.queryahub.com/extensions/postgres-exporter-0.5.0.zip', + sha256Checksum: 'd4e5f6a1b2c30718293a4b5c6d7e8f90123456789abcdef0123456789abcdef3', + homepage: 'https://github.com/querya-community/postgres-exporter', + license: 'MIT', + tags: ['postgresql', 'schema', 'export', 'erd', 'mermaid'], + ), + ]; + + @override + Future> getTrending({ExtensionType? type}) async { + await Future.delayed(const Duration(milliseconds: 150)); + return _items.where((item) { + if (type != null && item.type != type) return false; + return true; + }).toList(growable: false); + } + + @override + Future> search(String query, {ExtensionType? type}) async { + await Future.delayed(const Duration(milliseconds: 150)); + final normalized = query.trim().toLowerCase(); + return _items.where((item) { + if (type != null && item.type != type) return false; + if (normalized.isEmpty) return true; + return item.name.toLowerCase().contains(normalized) || + item.id.toLowerCase().contains(normalized) || + item.publisher.toLowerCase().contains(normalized) || + (item.description?.toLowerCase().contains(normalized) ?? false) || + item.tags.any((tag) => tag.toLowerCase().contains(normalized)); + }).toList(growable: false); + } + + @override + Future download(String url, {void Function(double)? onProgress}) async { + for (int i = 1; i <= 10; i++) { + await Future.delayed(const Duration(milliseconds: 80)); + onProgress?.call(i / 10.0); + } + final tmpDir = Directory.systemTemp; + final file = File(p.join(tmpDir.path, 'querya_ext_mock_${DateTime.now().millisecondsSinceEpoch}.zip')); + await file.writeAsString('mock archive payload'); + return file; + } + + @override + Future install( + ExtensionManifest manifest, { + void Function(double)? onProgress, + }) async { + // Simulate download & verification progress + for (int i = 1; i <= 10; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + onProgress?.call(i / 10.0); + } + + // Persist to local extensions directory so LocalExtensionRegistry discovers it + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, manifest.id)); + if (!await extDir.exists()) { + await extDir.create(recursive: true); + } + + final manifestFile = File(p.join(extDir.path, 'manifest.json')); + const encoder = JsonEncoder.withIndent(' '); + await manifestFile.writeAsString(encoder.convert(manifest.toJson())); + + // If installing a theme in mock mode, generate a working theme.json so it can be applied in settings + if (manifest.type == ExtensionType.theme) { + final themeFile = File(p.join(extDir.path, 'theme.json')); + final themeContent = _getMockThemeContent(manifest); + await themeFile.writeAsString(themeContent); + } + + // Reload local registry + await LocalExtensionRegistry.instance.reload(); + } + + String _getMockThemeContent(ExtensionManifest manifest) { + if (manifest.id.contains('nord')) { + return '''{ + "name": "Nord Theme", + "type": "dark", + "colors": { + "activityBar.background": "#2e3440", + "statusBar.background": "#2e3440", + "sideBar.background": "#3b4252", + "sideBar.foreground": "#d8dee9", + "tab.activeBackground": "#434c5e", + "panel.background": "#3b4252", + "input.background": "#434c5e", + "editor.background": "#2e3440", + "editor.foreground": "#eceff4", + "editor.selectionBackground": "#434c5e88", + "editorLineNumber.foreground": "#4c566a", + "focusBorder": "#88c0d0" + }, + "tokenColors": [ + { + "name": "Keywords", + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#81a1c1", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#a3be8c" } + }, + { + "name": "Numbers", + "scope": ["constant.numeric"], + "settings": { "foreground": "#b48ead" } + } + ] +}'''; + } + return '''{ + "name": "\${manifest.name}", + "type": "dark", + "colors": { + "activityBar.background": "#050508", + "statusBar.background": "#050508", + "sideBar.background": "#0c0820", + "sideBar.foreground": "#8b7cf8", + "tab.activeBackground": "#14102a", + "panel.background": "#14102a", + "input.background": "#14102a", + "editor.background": "#0a0a14", + "editor.foreground": "#e8f4ff", + "editor.selectionBackground": "#ff2a6d44", + "editorLineNumber.foreground": "#4a3f7a", + "focusBorder": "#00f5ff" + }, + "tokenColors": [ + { + "name": "Keywords", + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#ff2a6d", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#fcee09" } + }, + { + "name": "Functions", + "scope": ["entity.name.function"], + "settings": { "foreground": "#00f5ff" } + } + ] +}'''; + } + + @override + Future uninstall(String extensionId) async { + await Future.delayed(const Duration(milliseconds: 150)); + + // Check local extension registry first + final manifest = LocalExtensionRegistry.instance.manifests + .where((e) => e.id == extensionId) + .firstOrNull; + + if (manifest != null && manifest.installPath != null) { + final extDir = Directory(manifest.installPath!); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } else { + // Fallback: check standard extensions directory by ID + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, extensionId)); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } + + await LocalExtensionRegistry.instance.reload(); + } +} diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index eebba457..1daae918 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -370,6 +370,30 @@ class LocalDb { return id; } + /// Atomically updates an existing connection row in SQLite and its secrets in the secure store. + Future updateConnection(ConnectionRow row) async { + if (row.id == null) { + throw ArgumentError('ConnectionRow.id cannot be null when calling updateConnection'); + } + final db = await _open(); + await db.transaction((txn) async { + final count = await txn.update( + 'connections', + row.toPersistenceMap(), + where: 'id = ?', + whereArgs: [row.id], + ); + if (count == 0) { + throw ArgumentError('No connection found with id ${row.id}'); + } + }); + await ConnectionSecretsStore.writeForConnection( + row.id!, + password: row.password, + connectionString: row.connectionString, + ); + } + Future removeConnection(int id) async { await ConnectionSecretsStore.deleteForConnection(id); final db = await _open(); diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 323847cb..e54f2e46 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart' as material; +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/features/extensions/presentation/widgets/extension_card.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; void showExtensionManagerDialog(material.BuildContext context) { @@ -26,30 +27,73 @@ class _ExtensionManagerContent extends material.StatefulWidget { class _ExtensionManagerContentState extends material.State<_ExtensionManagerContent> { int _tabIndex = 0; + List _installed = []; + List _marketplace = []; + bool _loading = true; + final Map _installingProgress = {}; - final List _installedMocks = [ - const ExtensionManifest( - id: 'queryahub.clickhouse-driver', - name: 'ClickHouse Driver', - version: '1.0.0', - publisher: 'QueryaHub', - type: ExtensionType.databaseDriver, - engines: {'querya_desktop': '^0.4.7'}, - description: 'Full support for ClickHouse databases including Dictionaries and Materialized Views.', - ), - ]; + @override + void initState() { + super.initState(); + _loadData(); + } - final List _marketMocks = [ - const ExtensionManifest( - id: 'community.redis-driver', - name: 'Redis Driver', - version: '0.9.5', - publisher: 'Community', - type: ExtensionType.databaseDriver, - engines: {'querya_desktop': '^0.4.7'}, - description: 'Connect to Redis instances and visualize key-value storage.', - ), - ]; + Future _loadData() async { + setState(() => _loading = true); + await LocalExtensionRegistry.instance.load(); + final installed = LocalExtensionRegistry.instance.manifests; + final market = await MarketplaceRepository.instance.getTrending(); + if (mounted) { + setState(() { + _installed = installed; + _marketplace = market; + _loading = false; + }); + } + } + + Future _onSearchChanged(String query) async { + if (query.trim().isEmpty) { + final market = await MarketplaceRepository.instance.getTrending(); + if (mounted) setState(() => _marketplace = market); + } else { + final market = await MarketplaceRepository.instance.search(query); + if (mounted) setState(() => _marketplace = market); + } + } + + Future _installExtension(ExtensionManifest manifest) async { + setState(() => _installingProgress[manifest.id] = 0.01); + try { + await MarketplaceRepository.instance.install( + manifest, + onProgress: (progress) { + if (mounted) { + setState(() => _installingProgress[manifest.id] = progress); + } + }, + ); + if (mounted) { + setState(() { + _installingProgress.remove(manifest.id); + _installed = LocalExtensionRegistry.instance.manifests; + }); + } + } catch (e) { + if (mounted) { + setState(() => _installingProgress.remove(manifest.id)); + } + } + } + + Future _uninstallExtension(ExtensionManifest manifest) async { + await MarketplaceRepository.instance.uninstall(manifest.id); + if (mounted) { + setState(() { + _installed = LocalExtensionRegistry.instance.manifests; + }); + } + } @override material.Widget build(material.BuildContext context) { @@ -84,16 +128,19 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont mainAxisAlignment: material.MainAxisAlignment.spaceBetween, crossAxisAlignment: material.CrossAxisAlignment.center, children: [ - material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Extensions').large().semiBold().foreground(), - const material.SizedBox(height: 6), - const Text('Manage local and marketplace extensions') - .muted() - .small(), - ], + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Extensions').large().semiBold().foreground(), + const material.SizedBox(height: 6), + const Text('Manage local and marketplace extensions') + .muted() + .small(), + ], + ), ), + const material.SizedBox(width: 16), PrimaryButton( onPressed: () => material.Navigator.of(context).pop(), child: const Text('Close'), @@ -104,12 +151,12 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 24.0, vertical: 8.0), - child: material.Row( + child: material.Wrap( + spacing: 8, + runSpacing: 8, children: [ - _buildTabButton(0, 'Installed'), - const material.SizedBox(width: 8), + _buildTabButton(0, 'Installed', count: _installed.length), _buildTabButton(1, 'Marketplace'), - const material.SizedBox(width: 8), _buildTabButton(2, 'Updates'), ], ), @@ -133,46 +180,115 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont ); } - material.Widget _buildTabButton(int index, String label) { + material.Widget _buildTabButton(int index, String label, {int? count}) { final isSelected = _tabIndex == index; + final displayLabel = count != null ? '$label ($count)' : label; return SecondaryButton( onPressed: () => setState(() => _tabIndex = index), child: material.Text( - label, + displayLabel, style: material.TextStyle( color: isSelected ? Theme.of(context).colorScheme.primary : null, + fontWeight: isSelected ? material.FontWeight.w600 : material.FontWeight.w400, ), ), ); } material.Widget _buildInstalledTab() { + if (_loading) { + return const material.Center( + child: material.CircularProgressIndicator(), + ); + } + if (_installed.isEmpty) { + return const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text('No extensions installed yet. Explore the Marketplace tab to get started!'), + ), + ); + } return material.ListView.separated( padding: const material.EdgeInsets.all(24), - itemCount: _installedMocks.length, + itemCount: _installed.length, separatorBuilder: (_, __) => const material.SizedBox(height: 16), - itemBuilder: (ctx, i) => ExtensionCard( - manifest: _installedMocks[i], - isInstalled: true, - onUninstall: () {}, - ), + itemBuilder: (ctx, i) { + final manifest = _installed[i]; + final isInstalling = _installingProgress.containsKey(manifest.id); + final progress = _installingProgress[manifest.id]; + return ExtensionCard( + manifest: manifest, + isInstalled: true, + isInstalling: isInstalling, + installProgress: progress, + onUninstall: () => _uninstallExtension(manifest), + ); + }, ); } material.Widget _buildMarketplaceTab() { - return material.ListView.separated( - padding: const material.EdgeInsets.all(24), - itemCount: _marketMocks.length, - separatorBuilder: (_, __) => const material.SizedBox(height: 16), - itemBuilder: (ctx, i) => ExtensionCard( - manifest: _marketMocks[i], - isInstalled: false, - onInstall: () {}, - ), + if (_loading) { + return const material.Center( + child: material.CircularProgressIndicator(), + ); + } + return material.Column( + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 8), + child: TextField( + placeholder: const material.Text('Search extensions by name, tag, or description...'), + onChanged: _onSearchChanged, + features: const [ + InputFeature.leading( + Padding( + padding: material.EdgeInsets.only(right: 8), + child: material.Icon(material.Icons.search_rounded, size: 18), + ), + ), + ], + ), + ), + material.Expanded( + child: _marketplace.isEmpty + ? const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text('No extensions found matching your search.'), + ), + ) + : material.ListView.separated( + padding: const material.EdgeInsets.all(24), + itemCount: _marketplace.length, + separatorBuilder: (_, __) => const material.SizedBox(height: 16), + itemBuilder: (ctx, i) { + final manifest = _marketplace[i]; + final isInstalled = _installed.any((e) => e.id == manifest.id); + final isInstalling = _installingProgress.containsKey(manifest.id); + final progress = _installingProgress[manifest.id]; + return ExtensionCard( + manifest: manifest, + isInstalled: isInstalled, + isInstalling: isInstalling, + installProgress: progress, + onInstall: () => _installExtension(manifest), + onUninstall: () => _uninstallExtension(manifest), + ); + }, + ), + ), + ], ); } material.Widget _buildUpdatesTab() { - return const material.Center(child: material.Text('No updates available.')); + return const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text('All installed extensions are up to date!'), + ), + ); } } diff --git a/lib/features/extensions/presentation/widgets/extension_card.dart b/lib/features/extensions/presentation/widgets/extension_card.dart index 5f5fc45e..2d25b488 100644 --- a/lib/features/extensions/presentation/widgets/extension_card.dart +++ b/lib/features/extensions/presentation/widgets/extension_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; class ExtensionCard extends material.StatelessWidget { @@ -8,6 +9,8 @@ class ExtensionCard extends material.StatelessWidget { required this.manifest, required this.isInstalled, this.hasUpdate = false, + this.isInstalling = false, + this.installProgress, this.onInstall, this.onUninstall, this.onUpdate, @@ -16,6 +19,8 @@ class ExtensionCard extends material.StatelessWidget { final ExtensionManifest manifest; final bool isInstalled; final bool hasUpdate; + final bool isInstalling; + final double? installProgress; final material.VoidCallback? onInstall; final material.VoidCallback? onUninstall; final material.VoidCallback? onUpdate; @@ -71,6 +76,17 @@ class ExtensionCard extends material.StatelessWidget { maxLines: 2, overflow: material.TextOverflow.ellipsis, ).muted(), + if (manifest.tags.isNotEmpty) ...[ + const material.SizedBox(height: 10), + material.Wrap( + spacing: 6, + runSpacing: 4, + children: manifest.tags + .take(5) + .map((tag) => _buildTagBadge(theme, tag)) + .toList(), + ), + ], ], ), ), @@ -79,7 +95,7 @@ class ExtensionCard extends material.StatelessWidget { mainAxisAlignment: material.MainAxisAlignment.center, crossAxisAlignment: material.CrossAxisAlignment.end, children: [ - if (isInstalled && hasUpdate) + if (isInstalled && hasUpdate && !isInstalling) material.Padding( padding: const material.EdgeInsets.only(bottom: 8.0), child: PrimaryButton( @@ -87,12 +103,34 @@ class ExtensionCard extends material.StatelessWidget { child: const Text('Update'), ), ), - if (isInstalled) + if (isInstalling) + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.end, + children: [ + material.SizedBox( + width: 110, + child: material.LinearProgressIndicator( + value: installProgress, + backgroundColor: theme.muted, + color: theme.primary, + minHeight: 6, + borderRadius: material.BorderRadius.circular(3), + ), + ), + const material.SizedBox(height: 6), + Text(installProgress != null + ? 'Installing ${(installProgress! * 100).toInt()}%' + : 'Installing...') + .muted() + .small(), + ], + ) + else if (isInstalled) SecondaryButton( onPressed: onUninstall, child: const Text('Uninstall'), - ), - if (!isInstalled) + ) + else PrimaryButton( onPressed: onInstall, child: const Text('Install'), @@ -113,10 +151,31 @@ class ExtensionCard extends material.StatelessWidget { borderRadius: material.BorderRadius.circular(radius), ), child: material.Icon( - material.Icons.extension_rounded, + manifest.type == ExtensionType.theme + ? material.Icons.palette_outlined + : material.Icons.extension_rounded, size: 24, color: theme.mutedForeground, ), ); } + + material.Widget _buildTagBadge(ColorScheme theme, String tag) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: theme.muted, + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + tag, + style: material.TextStyle( + fontSize: 11, + color: theme.mutedForeground, + fontWeight: material.FontWeight.w500, + ), + ), + ); + } } + diff --git a/pubspec.yaml b/pubspec.yaml index e2585775..bf4086c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: flutter_secure_storage: ^9.2.4 file_selector: ^1.1.0 syntax_highlight: ^0.5.0 + archive: ^4.0.9 dev_dependencies: flutter_test: diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart new file mode 100644 index 00000000..32e9292a --- /dev/null +++ b/test/core/market/marketplace_repository_test.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.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/models/extension_type.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +void main() { + group('MockMarketplaceRepository', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_market_test_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + }); + + test('getTrending returns default seed items', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + expect(trending, isNotEmpty); + expect(trending.any((e) => e.id == 'queryahub.clickhouse-driver'), isTrue); + expect(trending.any((e) => e.id == 'queryahub.cyberpunk-neon'), isTrue); + }); + + test('search filters by name, id, description, and tags', () async { + final repo = MockMarketplaceRepository(); + + final byName = await repo.search('ClickHouse'); + expect(byName.length, 1); + expect(byName.first.id, 'queryahub.clickhouse-driver'); + + final byTag = await repo.search('neon'); + expect(byTag.length, 1); + expect(byTag.first.id, 'queryahub.cyberpunk-neon'); + + final empty = await repo.search('nonexistent_extension_query_12345'); + expect(empty, isEmpty); + }); + + test('install writes manifest to disk and reloads LocalExtensionRegistry', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver'); + + final progressValues = []; + await repo.install(target, onProgress: (p) => progressValues.add(p)); + + expect(progressValues, isNotEmpty); + expect(progressValues.last, 1.0); + + expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue); + + final extDir = Directory(p.join(tempDir.path, target.id)); + expect(await extDir.exists(), isTrue); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + }); + + test('uninstall removes directory and updates LocalExtensionRegistry', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver'); + + await repo.install(target); + expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue); + + await repo.uninstall(target.id); + expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isFalse); + expect(await Directory(p.join(tempDir.path, target.id)).exists(), isFalse); + }); + + test('install theme creates theme.json in extension directory', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + final target = trending.firstWhere((e) => e.id == 'queryahub.cyberpunk-neon'); + + await repo.install(target); + + final extDir = Directory(p.join(tempDir.path, target.id)); + expect(await extDir.exists(), isTrue); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + expect(await File(p.join(extDir.path, 'theme.json')).exists(), isTrue); + }); + }); + + group('HttpMarketplaceRepository', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_http_market_test_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + }); + + test('getTrending fetches from backend API', () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/api/v1/extensions/trending'); + return http.Response(jsonEncode([ + { + 'id': 'test.extension', + 'name': 'Test Ext', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + } + ]), 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final trending = await repo.getTrending(); + expect(trending.length, 1); + expect(trending.first.id, 'test.extension'); + }); + + test('search queries backend API', () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/api/v1/extensions/search'); + expect(request.url.queryParameters['q'], 'sql'); + return http.Response(jsonEncode([ + { + 'id': 'test.sql', + 'name': 'SQL Tools', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'databaseDriver', + 'engines': {'querya_desktop': '*'}, + } + ]), 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final results = await repo.search('sql'); + expect(results.length, 1); + expect(results.first.name, 'SQL Tools'); + }); + + test('install throws MarketplaceException on SHA256 checksum mismatch', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('test.txt', 4, utf8.encode('good'))); + final zipBytes = ZipEncoder().encode(archive); + + final mockClient = MockClient((request) async { + return http.Response.bytes(zipBytes, 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + const manifest = ExtensionManifest( + id: 'test.sha256', + name: 'SHA256 Test', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/test.zip', + sha256Checksum: '0000000000000000000000000000000000000000000000000000000000000000', + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('SHA256 checksum mismatch'), + )), + ); + }); + + test('install prevents Path Traversal during archive unpacking (Issue #242)', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('../evil.txt', 4, utf8.encode('evil'))); + final zipBytes = ZipEncoder().encode(archive); + final expectedSha256 = sha256.convert(zipBytes).toString(); + + final mockClient = MockClient((request) async { + return http.Response.bytes(zipBytes, 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final manifest = ExtensionManifest( + id: 'test.traversal', + name: 'Traversal Test', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: const {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/evil.zip', + sha256Checksum: expectedSha256, + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Security violation'), + )), + ); + }); + }); +} + + + diff --git a/test/core/storage/local_db_secrets_test.dart b/test/core/storage/local_db_secrets_test.dart index bee13adf..fdb76f02 100644 --- a/test/core/storage/local_db_secrets_test.dart +++ b/test/core/storage/local_db_secrets_test.dart @@ -116,5 +116,44 @@ void main() { expect(s.password, isNull); expect(s.connectionString, isNull); }); + + test('updateConnection atomically updates SQLite row and secure-store secrets', () async { + const initialRow = ConnectionRow( + type: 'postgres', + name: 'PG_Init', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'old-secret-password', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(initialRow); + + final updatedRow = ConnectionRow( + id: id, + type: 'postgres', + name: 'PG_Updated', + host: 'db.example.com', + port: 5433, + username: 'root', + password: 'new-secret-password', + connectionString: 'postgres://root:new-secret-password@db.example.com:5433/mydb', + createdAt: '2026-01-01T00:00:00Z', + ); + await LocalDb.instance.updateConnection(updatedRow); + + final list = await LocalDb.instance.getConnections(); + final loaded = list.singleWhere((c) => c.id == id); + expect(loaded.name, 'PG_Updated'); + expect(loaded.host, 'db.example.com'); + expect(loaded.port, 5433); + expect(loaded.username, 'root'); + expect(loaded.password, 'new-secret-password'); + expect(loaded.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + + final secrets = await ConnectionSecretsStore.readForConnection(id); + expect(secrets.password, 'new-secret-password'); + expect(secrets.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + }); }); } diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart new file mode 100644 index 00000000..48ce2bf2 --- /dev/null +++ b/test/features/extensions/extension_manager_test.dart @@ -0,0 +1,134 @@ +import 'dart:io'; +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.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/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('ExtensionCard', () { + testWidgets('renders manifest details and tag badges', (tester) async { + const manifest = ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + description: 'Full support for ClickHouse databases.', + tags: ['database', 'clickhouse', 'olap'], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ExtensionCard( + manifest: manifest, + isInstalled: false, + ), + ), + ), + ); + + expect(find.text('ClickHouse Driver'), findsOneWidget); + expect(find.text('QueryaHub'), findsOneWidget); + expect(find.text('v1.0.0'), findsOneWidget); + expect(find.text('Full support for ClickHouse databases.'), findsOneWidget); + expect(find.text('clickhouse'), findsOneWidget); + expect(find.text('Install'), findsOneWidget); + }); + + testWidgets('renders progress bar when isInstalling is true', (tester) async { + const manifest = ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ExtensionCard( + manifest: manifest, + isInstalled: false, + isInstalling: true, + installProgress: 0.45, + ), + ), + ), + ); + + expect(find.byType(material.LinearProgressIndicator), findsOneWidget); + expect(find.text('Installing 45%'), findsOneWidget); + }); + }); + + group('ExtensionManagerDialog', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_ui_test_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + MarketplaceRepository.instance = MockMarketplaceRepository(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + }); + + testWidgets('renders tabs and loads Marketplace items', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (ctx) => material.Scaffold( + body: material.Center( + child: PrimaryButton( + onPressed: () => showExtensionManagerDialog(ctx), + child: const Text('Open Dialog'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Extensions'), findsOneWidget); + expect(find.text('Installed (0)'), findsOneWidget); + expect(find.text('Marketplace'), findsOneWidget); + + // Switch to Marketplace tab + await tester.tap(find.text('Marketplace')); + await tester.pumpAndSettle(); + + expect(find.text('ClickHouse Driver'), findsOneWidget); + expect(find.text('Redis Driver'), findsOneWidget); + + // Search filtering + final finder = find.byType(TextField); + expect(finder, findsOneWidget); + + await tester.enterText(finder, 'Nord'); + await tester.pumpAndSettle(); + + expect(find.text('Nord Theme'), findsOneWidget); + expect(find.text('ClickHouse Driver'), findsNothing); + }); + }); +} From d38ce731b0671882481766a022190751e20273bd Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:05:13 +0300 Subject: [PATCH 16/23] fix(sqlite): support RETURNING clauses in write queries (#243) (#246) --- CHANGELOG.md | 4 ++++ lib/core/database/sqlite_connection.dart | 13 ++++++---- .../core/database/sqlite_connection_test.dart | 24 +++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b418fe2f..6776a31e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **SQLite / RETURNING clause support (#243)** — support RETURNING clauses for INSERT, UPDATE, and DELETE DML queries in the SQLite database driver, returning the resulting rows to the client. + ## [0.4.7-a] - 2026-06-22 ### Added diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index d4585d4a..f61e7b76 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -97,18 +97,21 @@ class SqliteConnection { .toLowerCase(); // SQLite can execute PRAGMA, SELECT, EXPLAIN statements, which return data - final isQuery = sqlLower.startsWith('select') || + final isReadOnlyQuery = sqlLower.startsWith('select') || sqlLower.startsWith('pragma') || sqlLower.startsWith('explain') || sqlLower.startsWith('with') || sqlLower.startsWith('values'); - if (isQuery) { + final hasReturning = RegExp(r'\breturning\b').hasMatch(sqlLower); + + if (readOnly && !isReadOnlyQuery) { + throw StateError('Database connection is read-only'); + } + + if (isReadOnlyQuery || hasReturning) { return await _db!.rawQuery(sql, arguments); } else { - if (readOnly) { - throw StateError('Database connection is read-only'); - } await _db!.execute(sql, arguments); return []; } diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index ccf74aee..cd2612f9 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -91,6 +91,30 @@ void main() { expect(columns, containsAll(['id', 'name'])); }); + test('executes INSERT, UPDATE, DELETE with RETURNING clause correctly', () async { + await conn.connect(); + + await conn.execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); + + // INSERT with RETURNING + final insertRes = await conn.execute("INSERT INTO users (name) VALUES ('Alice') RETURNING id, name"); + expect(insertRes, isNotEmpty); + expect(insertRes.first['id'], 1); + expect(insertRes.first['name'], 'Alice'); + + // UPDATE with RETURNING + final updateRes = await conn.execute("UPDATE users SET name = 'Bob' WHERE id = 1 RETURNING id, name"); + expect(updateRes, isNotEmpty); + expect(updateRes.first['id'], 1); + expect(updateRes.first['name'], 'Bob'); + + // DELETE with RETURNING + final deleteRes = await conn.execute("DELETE FROM users WHERE id = 1 RETURNING id, name"); + expect(deleteRes, isNotEmpty); + expect(deleteRes.first['id'], 1); + expect(deleteRes.first['name'], 'Bob'); + }); + test('throws StateError for modify operations in read-only mode', () async { final roConn = SqliteConnection( id: 2, From dbd5f14cd80de7f145be5b474c3ba279cc2efdd2 Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:35:33 +0300 Subject: [PATCH 17/23] security(mysql): replace string concatenation with parameterized queries in schema introspection (#241) (#247) --- CHANGELOG.md | 1 + lib/core/database/mysql_connection.dart | 20 ++--- test/core/database/mysql_connection_test.dart | 89 +++++++++++++++++++ 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6776a31e..a2781fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **SQLite / RETURNING clause support (#243)** — support RETURNING clauses for INSERT, UPDATE, and DELETE DML queries in the SQLite database driver, returning the resulting rows to the client. +- **Security / MySQL Injection Fix (#241)** — replaced manual escaping and string concatenation in schema introspection methods (`listViews`, `listColumnNames`, `listTables`) in the MySQL database driver with parameterized queries using parameter binding. ## [0.4.7-a] - 2026-06-22 diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index df453aeb..5c4c4c6f 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -76,9 +76,7 @@ class MysqlConnection { bool get _usesConnectionString => connectionString != null && connectionString!.trim().isNotEmpty; - static String _escapeSqlString(String s) { - return s.replaceAll(r'\', r'\\').replaceAll("'", "''"); - } + /// MySQL identifier quoting (backticks). static String quoteIdentifier(String id) { @@ -284,11 +282,11 @@ class MysqlConnection { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - final s = _escapeSqlString(schema); final rs = await execute( 'SELECT TABLE_NAME FROM information_schema.TABLES ' - "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'VIEW' " + "WHERE TABLE_SCHEMA = :schema AND TABLE_TYPE = 'VIEW' " 'ORDER BY TABLE_NAME', + {'schema': schema}, ); return rs.rows.map((r) => r.colAt(0)!).toList(); } @@ -301,12 +299,14 @@ class MysqlConnection { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - final d = _escapeSqlString(database); - final t = _escapeSqlString(table); final rs = await execute( 'SELECT COLUMN_NAME FROM information_schema.COLUMNS ' - "WHERE TABLE_SCHEMA = '$d' AND TABLE_NAME = '$t' " + "WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table " 'ORDER BY ORDINAL_POSITION', + { + 'database': database, + 'table': table, + }, ); return rs.rows.map((r) => r.colAt(0)!).toList(); } @@ -316,11 +316,11 @@ class MysqlConnection { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - final s = _escapeSqlString(schema); final rs = await execute( 'SELECT TABLE_NAME FROM information_schema.TABLES ' - "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'BASE TABLE' " + "WHERE TABLE_SCHEMA = :schema AND TABLE_TYPE = 'BASE TABLE' " 'ORDER BY TABLE_NAME', + {'schema': schema}, ); return rs.rows.map((r) => r.colAt(0)!).toList(); } diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart index 848a8e08..70f19680 100644 --- a/test/core/database/mysql_connection_test.dart +++ b/test/core/database/mysql_connection_test.dart @@ -39,4 +39,93 @@ void main() { ); }); }); + + group('MysqlConnection when not connected', () { + late MysqlConnection conn; + + setUp(() { + conn = MysqlConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + }); + + test('execute throws StateError', () { + expect( + () => conn.execute('SELECT 1'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listDatabases throws StateError', () { + expect( + () => conn.listDatabases(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listViews throws StateError', () { + expect( + () => conn.listViews(schema: 'db'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listColumnNames throws StateError', () { + expect( + () => conn.listColumnNames(database: 'db', table: 'tbl'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listTables throws StateError', () { + expect( + () => conn.listTables(schema: 'db'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('serverVersion throws StateError', () { + expect( + () => conn.serverVersion(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('serverStats throws StateError', () { + expect( + () => conn.serverStats(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + }); } From 998b2bdc322f99444a7c036a8e71a1525e3f2a3a Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:22:18 +0300 Subject: [PATCH 18/23] ... (#248) * feat(connections): implement New Connection from URL menu action (#228) Wire the Connection menu item to a URI dialog that parses supported database URLs into ConnectionRow and persists them through LocalDb. * test(connections): cover URL connection parser and dialog (#228) Add unit tests for URI parsing across supported drivers and widget tests for the new connection dialog validation and submit flow. * feat(ui): implement Help About and Documentation menu actions (#229) Add an About dialog with app version, MIT license notice, and repository link, and open project documentation in the browser via url_launcher. * test(ui): cover About dialog and app links (#229) Add widget tests for the About dialog and unit tests for canonical documentation and repository URLs. --- lib/core/app/app_links.dart | 9 + lib/core/app/external_link.dart | 13 ++ .../connections/connection_url_parser.dart | 156 ++++++++++++++++++ .../new_connection_url_dialog.dart | 156 ++++++++++++++++++ lib/features/help/about_dialog.dart | 118 +++++++++++++ lib/features/main_screen/main_screen.dart | 11 ++ .../main_screen/querya_window_title_bar.dart | 10 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.yaml | 2 + test/core/app/app_links_test.dart | 24 +++ .../connection_url_parser_test.dart | 136 +++++++++++++++ .../new_connection_url_dialog_test.dart | 106 ++++++++++++ test/features/help/about_dialog_test.dart | 51 ++++++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 17 files changed, 802 insertions(+), 3 deletions(-) create mode 100644 lib/core/app/app_links.dart create mode 100644 lib/core/app/external_link.dart create mode 100644 lib/features/connections/connection_url_parser.dart create mode 100644 lib/features/connections/new_connection_url_dialog.dart create mode 100644 lib/features/help/about_dialog.dart create mode 100644 test/core/app/app_links_test.dart create mode 100644 test/features/connections/connection_url_parser_test.dart create mode 100644 test/features/connections/new_connection_url_dialog_test.dart create mode 100644 test/features/help/about_dialog_test.dart diff --git a/lib/core/app/app_links.dart b/lib/core/app/app_links.dart new file mode 100644 index 00000000..1d6cefd9 --- /dev/null +++ b/lib/core/app/app_links.dart @@ -0,0 +1,9 @@ +/// Canonical external URLs for Querya Desktop. +abstract final class AppLinks { + static const repository = + 'https://github.com/QueryaHub/Querya-Desktop'; + static const documentation = + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/README.md'; + static const license = + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/LICENSE'; +} diff --git a/lib/core/app/external_link.dart b/lib/core/app/external_link.dart new file mode 100644 index 00000000..49e13bef --- /dev/null +++ b/lib/core/app/external_link.dart @@ -0,0 +1,13 @@ +import 'package:querya_desktop/core/app/app_links.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Opens [url] in the system browser. +Future launchExternalUrl(String url) { + final uri = Uri.parse(url); + return launchUrl(uri, mode: LaunchMode.externalApplication); +} + +Future launchRepositoryUrl() => launchExternalUrl(AppLinks.repository); + +Future launchDocumentationUrl() => + launchExternalUrl(AppLinks.documentation); diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart new file mode 100644 index 00000000..1634cbd7 --- /dev/null +++ b/lib/features/connections/connection_url_parser.dart @@ -0,0 +1,156 @@ +import 'package:querya_desktop/core/storage/local_db.dart'; + +const _supportedSchemes = { + 'postgresql', + 'postgres', + 'mysql', + 'sqlite', + 'mongodb', + 'mongodb+srv', + 'redis', + 'rediss', +}; + +/// Parses a database connection URL into a [ConnectionRow], or returns an error message. +({ConnectionRow? row, String? error}) parseConnectionUrlInput(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) { + return (row: null, error: 'URL/URI is required.'); + } + + final uri = Uri.tryParse(trimmed); + if (uri == null || uri.scheme.isEmpty) { + return (row: null, error: 'Invalid URL/URI format.'); + } + + final scheme = uri.scheme.toLowerCase(); + if (!_supportedSchemes.contains(scheme)) { + return ( + row: null, + error: + 'Unsupported protocol "$scheme". Supported: postgresql, mysql, sqlite, mongodb, redis.', + ); + } + + final row = _buildConnectionRow(trimmed, uri, scheme); + if (row == null) { + return (row: null, error: 'Failed to parse connection URL.'); + } + return (row: row, error: null); +} + +ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { + String type; + int? defaultPort; + + if (scheme == 'postgresql' || scheme == 'postgres') { + type = 'postgresql'; + defaultPort = 5432; + } else if (scheme == 'mysql') { + type = 'mysql'; + defaultPort = 3306; + } else if (scheme == 'sqlite') { + type = 'sqlite'; + } else if (scheme == 'mongodb' || scheme == 'mongodb+srv') { + type = 'mongodb'; + defaultPort = 27017; + } else if (scheme == 'redis' || scheme == 'rediss') { + type = 'redis'; + defaultPort = 6379; + } else { + return null; + } + + String? host; + int? port; + String? username; + String? password; + String? databaseName; + String? authSource; + String? connectionString; + var useSSL = scheme == 'rediss'; + + if (type == 'sqlite') { + String path; + if (url.contains(':memory:')) { + path = ':memory:'; + } else if (url.startsWith('sqlite:///')) { + path = uri.path; + } else if (url.startsWith('sqlite://')) { + path = url.substring(9); + } else if (url.startsWith('sqlite:')) { + path = url.substring(7); + } else { + path = uri.path; + } + host = path; + } else { + host = uri.host.isEmpty ? null : uri.host; + port = uri.hasPort ? uri.port : defaultPort; + + if (uri.userInfo.isNotEmpty) { + final parts = uri.userInfo.split(':'); + if (parts.isNotEmpty) { + username = Uri.decodeComponent(parts[0]); + } + if (parts.length > 1) { + password = Uri.decodeComponent(parts.sublist(1).join(':')); + } + } + + databaseName = uri.pathSegments.firstOrNull; + if (databaseName != null && databaseName.isEmpty) { + databaseName = null; + } + + authSource = uri.queryParameters['authSource'] ?? + uri.queryParameters['authsource']; + + final sslQuery = uri.queryParameters['sslmode'] ?? uri.queryParameters['ssl']; + if (sslQuery != null) { + final lowerSsl = sslQuery.toLowerCase(); + if (lowerSsl == 'true' || lowerSsl == 'require' || lowerSsl == 'prefer') { + useSSL = true; + } + } + + if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { + connectionString = url; + } + } + + final name = _connectionName(type, host, databaseName); + + return ConnectionRow( + type: type, + name: name, + host: host, + port: port, + username: username, + password: password, + databaseName: databaseName, + authSource: authSource, + useSSL: useSSL, + connectionString: connectionString, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); +} + +String _connectionName(String type, String? host, String? databaseName) { + if (type == 'sqlite') { + return host == ':memory:' ? 'SQLite (Memory)' : 'SQLite (${host!.split('/').last})'; + } + + final cleanHost = host ?? 'localhost'; + final cleanDb = databaseName ?? ''; + final typeName = switch (type) { + 'postgresql' => 'PostgreSQL', + 'mysql' => 'MySQL', + 'mongodb' => 'MongoDB', + _ => 'Redis', + }; + if (cleanDb.isNotEmpty) { + return '$typeName: $cleanDb'; + } + return '$typeName: $cleanHost'; +} diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart new file mode 100644 index 00000000..23e60638 --- /dev/null +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_url_parser.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows a dialog to create a new database connection from a URI. +/// Returns the ConnectionRow or null if cancelled. +Future showNewConnectionUrlDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _NewConnectionUrlDialogContent(), + ), + ); +} + +class _NewConnectionUrlDialogContent extends material.StatefulWidget { + const _NewConnectionUrlDialogContent(); + + @override + material.State<_NewConnectionUrlDialogContent> createState() => + _NewConnectionUrlDialogContentState(); +} + +class _NewConnectionUrlDialogContentState + extends material.State<_NewConnectionUrlDialogContent> { + final _urlController = material.TextEditingController(); + String? _validationError; + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + void _validateAndSubmit() { + final result = parseConnectionUrlInput(_urlController.text); + if (result.error != null) { + setState(() => _validationError = result.error); + return; + } + material.Navigator.of(context).pop(result.row); + } + + @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: 500, + minWidth: 380, + ), + 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: [ + const Text('New connection from URL').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Create a connection by pasting a database URI (e.g. postgresql://user:pass@host:5432/db).', + ).muted().small(), + const material.SizedBox(height: 16), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _validationError != null + ? theme.destructive.withValues(alpha: 0.8) + : theme.border.withValues(alpha: 0.4), + ), + ), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + material.Icons.link_rounded, + size: 20, + color: _validationError != null + ? theme.destructive + : theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _urlController, + placeholder: const Text('database://user:pass@host:port/db'), + onSubmitted: (_) => _validateAndSubmit(), + onChanged: (_) { + if (_validationError != null) { + setState(() => _validationError = null); + } + }, + ), + ), + ], + ), + ), + if (_validationError != null) ...[ + const material.SizedBox(height: 8), + Text( + _validationError!, + style: material.TextStyle(color: theme.destructive), + ).small(), + ], + ], + ), + ), + 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( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _validateAndSubmit, + child: const Text('Create'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/help/about_dialog.dart b/lib/features/help/about_dialog.dart new file mode 100644 index 00000000..11eef0a7 --- /dev/null +++ b/lib/features/help/about_dialog.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart' as material; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:querya_desktop/core/app/external_link.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows the About Querya dialog. +Future showAboutDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _AboutDialogContent(), + ), + ); +} + +class _AboutDialogContent extends material.StatefulWidget { + const _AboutDialogContent(); + + @override + material.State<_AboutDialogContent> createState() => + _AboutDialogContentState(); +} + +class _AboutDialogContentState extends material.State<_AboutDialogContent> { + late final Future _packageInfo = PackageInfo.fromPlatform(); + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + final wb = context.workbench; + + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 420, + minWidth: 320, + ), + 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, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 28, 24, 8), + child: material.Column( + children: [ + material.Icon( + material.Icons.search_rounded, + size: 48, + color: wb.accent, + ), + const material.SizedBox(height: 16), + const Text('Querya').large().semiBold(), + const material.SizedBox(height: 8), + FutureBuilder( + future: _packageInfo, + builder: (context, snapshot) { + final version = snapshot.data?.version ?? '…'; + return Text('Version $version').muted().small(); + }, + ), + const material.SizedBox(height: 16), + const Text( + 'A lightweight desktop SQL/NoSQL client.', + ).muted().small(), + const material.SizedBox(height: 12), + const Text( + 'Licensed under the MIT License.', + ).small(), + const material.SizedBox(height: 16), + GhostButton( + onPressed: () => launchRepositoryUrl(), + child: const Text('View repository'), + ), + ], + ), + ), + 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( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Opens project documentation in the system browser. +Future openQueryaDocumentation() => launchDocumentationUrl(); diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 2e5b0d1c..e34ed870 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -16,6 +16,7 @@ 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'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -139,6 +140,15 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } + Future _onNewDatabaseConnectionFromUrl() async { + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) return; + final row = await showNewConnectionUrlDialog(context); + if (!mounted || row == null) return; + await LocalDb.instance.addConnection(row); + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; @@ -154,6 +164,7 @@ class _MainScreenState extends State { builder: (context, workspace, _) { return QueryaWindowTitleBar( onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, + onNewDatabaseConnectionFromUrl: _onNewDatabaseConnectionFromUrl, activeConnection: workspace.activeConnection, isReadOnly: workspace.isReadOnly, onReadOnlyChanged: () { diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index ef072943..373b2cef 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; +import 'package:querya_desktop/features/help/about_dialog.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -14,6 +15,7 @@ class QueryaWindowTitleBar extends StatelessWidget { const QueryaWindowTitleBar({ super.key, required this.onNewDatabaseConnection, + required this.onNewDatabaseConnectionFromUrl, this.activeConnection, this.onConnect, this.onReconnect, @@ -25,6 +27,7 @@ class QueryaWindowTitleBar extends StatelessWidget { }); final Future Function() onNewDatabaseConnection; + final Future Function() onNewDatabaseConnectionFromUrl; final ConnectionRow? activeConnection; final VoidCallback? onConnect; final VoidCallback? onReconnect; @@ -162,7 +165,7 @@ class QueryaWindowTitleBar extends StatelessWidget { leading: const material.Icon( material.Icons.link_rounded, size: 18), - onPressed: (_) {}, + onPressed: (_) => onNewDatabaseConnectionFromUrl(), child: const Text('New Connection from URL'), ), MenuButton( @@ -224,9 +227,10 @@ class QueryaWindowTitleBar extends StatelessWidget { MenuButton( subMenu: [ MenuButton( - onPressed: (_) {}, child: const Text('About')), + onPressed: (ctx) => showAboutDialog(ctx), + child: const Text('About')), MenuButton( - onPressed: (_) {}, + onPressed: (_) => openQueryaDocumentation(), child: const Text('Documentation')), ], child: const Text('Help'), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f09d0a3b..85e343b4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -12,6 +12,7 @@ #include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar = @@ -32,4 +33,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7792787e..f4b41b9b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST irondash_engine_context refresh_rate super_native_extensions + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d30bbdad..ff1df342 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,9 +10,11 @@ import device_info_plus import file_selector_macos import flutter_secure_storage_macos import irondash_engine_context +import package_info_plus import refresh_rate import sqflite_darwin import super_native_extensions +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) @@ -20,7 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RefreshRatePlugin.register(with: registry.registrar(forPlugin: "RefreshRatePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index bf4086c7..171641ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: file_selector: ^1.1.0 syntax_highlight: ^0.5.0 archive: ^4.0.9 + url_launcher: ^6.3.1 + package_info_plus: ^8.3.0 dev_dependencies: flutter_test: diff --git a/test/core/app/app_links_test.dart b/test/core/app/app_links_test.dart new file mode 100644 index 00000000..89618178 --- /dev/null +++ b/test/core/app/app_links_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/app/app_links.dart'; + +void main() { + group('AppLinks', () { + test('repository points to Querya-Desktop GitHub', () { + expect(AppLinks.repository, 'https://github.com/QueryaHub/Querya-Desktop'); + }); + + test('documentation points to docs README on main', () { + expect( + AppLinks.documentation, + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/README.md', + ); + }); + + test('license points to LICENSE file on main', () { + expect( + AppLinks.license, + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/LICENSE', + ); + }); + }); +} diff --git a/test/features/connections/connection_url_parser_test.dart b/test/features/connections/connection_url_parser_test.dart new file mode 100644 index 00000000..1e0d384c --- /dev/null +++ b/test/features/connections/connection_url_parser_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/connection_url_parser.dart'; + +void main() { + group('parseConnectionUrlInput', () { + test('returns error for empty input', () { + final result = parseConnectionUrlInput(' '); + expect(result.row, isNull); + expect(result.error, 'URL/URI is required.'); + }); + + test('returns error for invalid format', () { + final result = parseConnectionUrlInput('not a url'); + expect(result.row, isNull); + expect(result.error, 'Invalid URL/URI format.'); + }); + + test('returns error for unsupported scheme', () { + final result = parseConnectionUrlInput('ftp://localhost/db'); + expect(result.row, isNull); + expect(result.error, contains('Unsupported protocol')); + }); + + test('parses postgresql URL with credentials and database', () { + final result = parseConnectionUrlInput( + 'postgresql://alice:secret@db.example.com:5432/myapp', + ); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'postgresql'); + expect(row.name, 'PostgreSQL: myapp'); + expect(row.host, 'db.example.com'); + expect(row.port, 5432); + expect(row.username, 'alice'); + expect(row.password, 'secret'); + expect(row.databaseName, 'myapp'); + expect(row.connectionString, 'postgresql://alice:secret@db.example.com:5432/myapp'); + expect(row.useSSL, false); + }); + + test('parses postgres alias scheme', () { + final result = parseConnectionUrlInput('postgres://localhost/appdb'); + expect(result.error, isNull); + expect(result.row!.type, 'postgresql'); + expect(result.row!.port, 5432); + expect(result.row!.databaseName, 'appdb'); + }); + + test('parses postgresql sslmode=require', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=require', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, true); + }); + + test('parses mysql URL', () { + final result = parseConnectionUrlInput( + 'mysql://root:p%40ss@127.0.0.1:3307/sakila', + ); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'mysql'); + expect(row.name, 'MySQL: sakila'); + expect(row.host, '127.0.0.1'); + expect(row.port, 3307); + expect(row.username, 'root'); + expect(row.password, 'p@ss'); + expect(row.connectionString, 'mysql://root:p%40ss@127.0.0.1:3307/sakila'); + }); + + test('parses sqlite file path', () { + final result = parseConnectionUrlInput('sqlite:///tmp/test.db'); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'sqlite'); + expect(row.host, '/tmp/test.db'); + expect(row.name, 'SQLite (test.db)'); + }); + + test('parses sqlite in-memory', () { + final result = parseConnectionUrlInput('sqlite:///:memory:'); + expect(result.error, isNull); + expect(result.row!.host, ':memory:'); + expect(result.row!.name, 'SQLite (Memory)'); + }); + + test('parses mongodb URL with authSource', () { + final result = parseConnectionUrlInput( + 'mongodb://admin:pass@mongo.local:27017/app?authSource=admin', + ); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'mongodb'); + expect(row.name, 'MongoDB: app'); + expect(row.authSource, 'admin'); + expect(row.connectionString, contains('mongodb://')); + }); + + test('parses mongodb+srv URL', () { + final result = parseConnectionUrlInput( + 'mongodb+srv://user:pass@cluster.example.net/mydb', + ); + expect(result.error, isNull); + expect(result.row!.type, 'mongodb'); + expect(result.row!.host, 'cluster.example.net'); + expect(result.row!.databaseName, 'mydb'); + }); + + test('parses redis URL', () { + final result = parseConnectionUrlInput('redis://:password@localhost:6379'); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'redis'); + expect(row.name, 'Redis: localhost'); + expect(row.password, 'password'); + expect(row.connectionString, isNull); + }); + + test('parses rediss URL with SSL enabled', () { + final result = parseConnectionUrlInput('rediss://localhost'); + expect(result.error, isNull); + expect(result.row!.type, 'redis'); + expect(result.row!.useSSL, true); + expect(result.row!.port, 6379); + }); + + test('password with colon is preserved', () { + final result = parseConnectionUrlInput( + 'postgresql://user:p%3Aart@localhost/mydb', + ); + expect(result.error, isNull); + expect(result.row!.password, 'p:art'); + }); + }); +} diff --git a/test/features/connections/new_connection_url_dialog_test.dart b/test/features/connections/new_connection_url_dialog_test.dart new file mode 100644 index 00000000..5af32e03 --- /dev/null +++ b/test/features/connections/new_connection_url_dialog_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('showNewConnectionUrlDialog', () { + testWidgets('dialog shows title and form controls', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showNewConnectionUrlDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('New connection from URL'), findsOneWidget); + expect(find.text('Create'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Cancel closes dialog and returns null', (tester) async { + ConnectionRow? result; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showNewConnectionUrlDialog(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isNull); + }); + + testWidgets('empty URL shows validation error', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showNewConnectionUrlDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(find.text('URL/URI is required.'), findsOneWidget); + }); + + testWidgets('valid URL returns parsed ConnectionRow', (tester) async { + ConnectionRow? result; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showNewConnectionUrlDialog(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextField), + 'postgresql://user:pass@localhost:5432/mydb', + ); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.type, 'postgresql'); + expect(result!.databaseName, 'mydb'); + expect(result!.username, 'user'); + expect(result!.password, 'pass'); + }); + }); +} diff --git a/test/features/help/about_dialog_test.dart b/test/features/help/about_dialog_test.dart new file mode 100644 index 00000000..7465ffa6 --- /dev/null +++ b/test/features/help/about_dialog_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/help/about_dialog.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('showAboutDialog', () { + testWidgets('dialog shows app name, license, and actions', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showAboutDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Querya'), findsOneWidget); + expect(find.textContaining('Version'), findsOneWidget); + expect(find.text('Licensed under the MIT License.'), findsOneWidget); + expect(find.text('View repository'), findsOneWidget); + expect(find.text('Close'), findsOneWidget); + }); + + testWidgets('Close dismisses the dialog', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showAboutDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + expect(find.text('Querya'), findsNothing); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index eab8f5b5..1bce8ba7 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -12,6 +12,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { BitsdojoWindowPluginRegisterWithRegistrar( @@ -26,4 +27,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("RefreshRatePluginCApi")); SuperNativeExtensionsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index bf263147..134fbbb2 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST irondash_engine_context refresh_rate super_native_extensions + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 56512c2e3417f96f054d28179a40900e4655305e Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:56:06 +0300 Subject: [PATCH 19/23] fix(connections): validate PostgreSQL sslmode and map useSSL correctly (#249, #253, #254, #257, #258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject unsupported sslmode values (e.g. prefer, invalid) for PostgreSQL. - Map disable → useSSL=false; require, verify-ca, verify-full → useSSL=true. - Only store an explicit URI port; fall back to driver defaults when omitted. - Keep host:port in display name when no database is present. --- .../connections/connection_url_parser.dart | 92 ++++++++++++++++--- .../connection_url_parser_test.dart | 50 +++++++++- 2 files changed, 126 insertions(+), 16 deletions(-) diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 1634cbd7..82ca3723 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -11,6 +11,13 @@ const _supportedSchemes = { 'rediss', }; +const _validPostgresSslModes = { + 'disable', + 'require', + 'verify-ca', + 'verify-full', +}; + /// Parses a database connection URL into a [ConnectionRow], or returns an error message. ({ConnectionRow? row, String? error}) parseConnectionUrlInput(String input) { final trimmed = input.trim(); @@ -32,14 +39,67 @@ const _supportedSchemes = { ); } - final row = _buildConnectionRow(trimmed, uri, scheme); + final sslResult = _resolveSslForScheme(scheme, uri); + if (sslResult.error != null) { + return (row: null, error: sslResult.error); + } + + final row = _buildConnectionRow(trimmed, uri, scheme, sslResult.useSSL); if (row == null) { return (row: null, error: 'Failed to parse connection URL.'); } return (row: row, error: null); } -ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { +({bool? useSSL, String? error}) _resolveSslForScheme(String scheme, Uri uri) { + final type = _schemeToType(scheme); + if (type == null) return (useSSL: null, error: null); + + var useSSL = scheme == 'rediss'; + + if (type == 'postgresql') { + final sslMode = uri.queryParameters['sslmode']?.toLowerCase() ?? + uri.queryParameters['ssl']?.toLowerCase(); + if (sslMode != null && sslMode.isNotEmpty) { + if (!_validPostgresSslModes.contains(sslMode)) { + return ( + useSSL: null, + error: + 'Unsupported sslmode "$sslMode" for PostgreSQL. ' + 'Supported: disable, require, verify-ca, verify-full.', + ); + } + useSSL = sslMode != 'disable'; + } + } else if (type != 'sqlite') { + final sslQuery = uri.queryParameters['sslmode'] ?? + uri.queryParameters['ssl']; + if (sslQuery != null) { + final lowerSsl = sslQuery.toLowerCase(); + if (lowerSsl == 'true' || lowerSsl == 'require') { + useSSL = true; + } + } + } + + return (useSSL: useSSL, error: null); +} + +String? _schemeToType(String scheme) { + if (scheme == 'postgresql' || scheme == 'postgres') return 'postgresql'; + if (scheme == 'mysql') return 'mysql'; + if (scheme == 'sqlite') return 'sqlite'; + if (scheme == 'mongodb' || scheme == 'mongodb+srv') return 'mongodb'; + if (scheme == 'redis' || scheme == 'rediss') return 'redis'; + return null; +} + +ConnectionRow? _buildConnectionRow( + String url, + Uri uri, + String scheme, + bool? resolvedUseSSL, +) { String type; int? defaultPort; @@ -68,7 +128,7 @@ ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { String? databaseName; String? authSource; String? connectionString; - var useSSL = scheme == 'rediss'; + var useSSL = resolvedUseSSL ?? (scheme == 'rediss'); if (type == 'sqlite') { String path; @@ -86,7 +146,7 @@ ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { host = path; } else { host = uri.host.isEmpty ? null : uri.host; - port = uri.hasPort ? uri.port : defaultPort; + port = uri.hasPort ? uri.port : null; if (uri.userInfo.isNotEmpty) { final parts = uri.userInfo.split(':'); @@ -106,26 +166,18 @@ ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { authSource = uri.queryParameters['authSource'] ?? uri.queryParameters['authsource']; - final sslQuery = uri.queryParameters['sslmode'] ?? uri.queryParameters['ssl']; - if (sslQuery != null) { - final lowerSsl = sslQuery.toLowerCase(); - if (lowerSsl == 'true' || lowerSsl == 'require' || lowerSsl == 'prefer') { - useSSL = true; - } - } - if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { connectionString = url; } } - final name = _connectionName(type, host, databaseName); + final name = _connectionName(type, host, port, databaseName, defaultPort); return ConnectionRow( type: type, name: name, host: host, - port: port, + port: port ?? defaultPort, username: username, password: password, databaseName: databaseName, @@ -136,12 +188,19 @@ ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { ); } -String _connectionName(String type, String? host, String? databaseName) { +String _connectionName( + String type, + String? host, + int? port, + String? databaseName, + int? defaultPort, +) { if (type == 'sqlite') { return host == ':memory:' ? 'SQLite (Memory)' : 'SQLite (${host!.split('/').last})'; } final cleanHost = host ?? 'localhost'; + final cleanPort = port ?? defaultPort; final cleanDb = databaseName ?? ''; final typeName = switch (type) { 'postgresql' => 'PostgreSQL', @@ -152,5 +211,8 @@ String _connectionName(String type, String? host, String? databaseName) { if (cleanDb.isNotEmpty) { return '$typeName: $cleanDb'; } + if (cleanPort != null) { + return '$typeName: $cleanHost:$cleanPort'; + } return '$typeName: $cleanHost'; } diff --git a/test/features/connections/connection_url_parser_test.dart b/test/features/connections/connection_url_parser_test.dart index 1e0d384c..49c9905f 100644 --- a/test/features/connections/connection_url_parser_test.dart +++ b/test/features/connections/connection_url_parser_test.dart @@ -54,6 +54,47 @@ void main() { expect(result.row!.useSSL, true); }); + test('parses postgresql sslmode=verify-full as SSL enabled', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=verify-full', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, true); + }); + + test('parses postgresql sslmode=verify-ca as SSL enabled', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=verify-ca', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, true); + }); + + test('parses postgresql sslmode=disable as SSL disabled', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=disable', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, false); + }); + + test('returns error for postgresql sslmode=prefer', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=prefer', + ); + expect(result.row, isNull); + expect(result.error, contains('Unsupported sslmode')); + expect(result.error, contains('prefer')); + }); + + test('returns error for invalid postgresql sslmode', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=invalid', + ); + expect(result.row, isNull); + expect(result.error, contains('Unsupported sslmode')); + }); + test('parses mysql URL', () { final result = parseConnectionUrlInput( 'mysql://root:p%40ss@127.0.0.1:3307/sakila', @@ -112,11 +153,18 @@ void main() { expect(result.error, isNull); final row = result.row!; expect(row.type, 'redis'); - expect(row.name, 'Redis: localhost'); + expect(row.name, 'Redis: localhost:6379'); expect(row.password, 'password'); expect(row.connectionString, isNull); }); + test('uses default driver port when URI omits port', () { + final result = parseConnectionUrlInput('postgresql://localhost/mydb'); + expect(result.error, isNull); + expect(result.row!.port, 5432); + expect(result.row!.name, 'PostgreSQL: mydb'); + }); + test('parses rediss URL with SSL enabled', () { final result = parseConnectionUrlInput('rediss://localhost'); expect(result.error, isNull); From cb50ca7ae1c15d6576a0dd68bd03b7215ea77c8f Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:56:09 +0300 Subject: [PATCH 20/23] fix(connections): surface PostgreSQL connection errors instead of swallowing them (#250, #251, #252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(connections): validate PostgreSQL sslmode and map useSSL correctly (#249, #253, #254, #257, #258) - Reject unsupported sslmode values (e.g. prefer, invalid) for PostgreSQL. - Map disable → useSSL=false; require, verify-ca, verify-full → useSSL=true. - Only store an explicit URI port; fall back to driver defaults when omitted. - Keep host:port in display name when no database is present. * fix(connections): surface PostgreSQL connection errors instead of swallowing them (#250, #251, #252) - Return ({bool ok, String? error}) from PostgresConnection.testConnection so the form can show the real failure reason. - Display the actual exception message in the connection tree instead of the static 'Error' label. --- lib/core/database/postgres_connection.dart | 11 ++++++----- .../connections_panel_postgres_connection.dart | 15 +++++++++------ .../postgresql/postgresql_connection_form.dart | 8 ++++++-- test/core/database/postgres_connection_test.dart | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 752cc0f5..fe76ee13 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -174,16 +174,17 @@ class PostgresConnection { ); } - Future testConnection() async { + /// Tests connectivity and returns a result with an optional error message. + Future<({bool ok, String? error})> testConnection() async { try { await connect(); if (_conn != null) { await _conn!.execute('SELECT 1'); - return true; + return (ok: true, error: null); } - return false; - } catch (_) { - return false; + return (ok: false, error: 'Connection could not be established.'); + } catch (e) { + return (ok: false, error: e.toString()); } finally { await disconnect(); } diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 9bc2a359..5021b92a 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -242,12 +242,15 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { material.Padding( padding: const material.EdgeInsets.only( left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + child: material.Tooltip( + message: _error!, + child: material.Text( + _error!, + overflow: material.TextOverflow.ellipsis, + maxLines: 2, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), ), ), if (_databases.isNotEmpty) diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 4f72544a..8cdd2303 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -127,8 +127,12 @@ class _PostgresConnectionFormContentState useSSL: _useSSL, connectionString: uri.isEmpty ? null : uri, ); - final ok = await conn.testConnection(); - if (mounted) _showTestResult(ok ? 'success' : 'failed'); + final result = await conn.testConnection(); + if (mounted) { + _showTestResult( + result.ok ? 'success' : (result.error ?? 'failed'), + ); + } } catch (e) { if (mounted) _showTestResult('error: $e'); } diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index bcb68fca..4d2ea716 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -455,4 +455,19 @@ void main() { expect(out, isNot(contains('database=olddb'))); }); }); + + group('PostgresConnection.testConnection', () { + test('returns ok=false and error message when connection fails', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'postgresql://localhost/db?sslmode=invalid', + ); + final result = await conn.testConnection(); + expect(result.ok, false); + expect(result.error, isNotNull); + expect(result.error, contains('sslmode')); + }); + }); } From e992cbe7612bd169ee2190fd7f5b2c59a070042a Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:03:30 +0300 Subject: [PATCH 21/23] fix(connections): improve PostgreSQL connection UX and error typing (#255, #256, #259) - Extract host and port from the connection URI so URI-only PostgreSQL rows show meaningful metadata in the sidebar. - Throw PostgresConnectionException from connect() with original cause and stack trace preserved. - Wrap unexpected factory failures in PostgresConnectionPool.acquire and rethrow typed PostgreSQL exceptions unchanged. - Update tests for exception fields, typed connect errors, pool wrapping, and URI-derived display metadata. --- lib/core/database/postgres_connection.dart | 23 ++++++++-- .../database/postgres_connection_pool.dart | 21 +++++++-- .../postgresql_connection_form.dart | 19 ++++++-- .../postgres_connection_pool_test.dart | 46 +++++++++++++++++++ .../database/postgres_connection_test.dart | 33 +++++++++++++ .../postgresql_connection_form_test.dart | 39 ++++++++++++++++ 6 files changed, 171 insertions(+), 10 deletions(-) diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index fe76ee13..8aae9434 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -137,10 +137,17 @@ class PostgresConnection { ); } _isConnected = true; - } catch (e) { + } catch (e, st) { _isConnected = false; _conn = null; - rethrow; + Error.throwWithStackTrace( + PostgresConnectionException( + 'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: $e', + cause: e, + stackTrace: st, + ), + st, + ); } } @@ -183,6 +190,8 @@ class PostgresConnection { return (ok: true, error: null); } return (ok: false, error: 'Connection could not be established.'); + } on PostgresConnectionException catch (e) { + return (ok: false, error: e.message); } catch (e) { return (ok: false, error: e.toString()); } finally { @@ -770,8 +779,16 @@ class PostgresSequenceDetails { } class PostgresConnectionException implements Exception { - PostgresConnectionException(this.message); + PostgresConnectionException( + this.message, { + this.cause, + this.stackTrace, + }); + final String message; + final Object? cause; + final StackTrace? stackTrace; + @override String toString() => message; } diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index 73c71433..e4fa225d 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -87,10 +87,23 @@ class PostgresConnectionPool { _evictIfNeededBeforeNewSlot(); - final conn = await createAndConnect(row, database: database, mode: mode); - entry = _PoolEntry(conn)..refs = 1; - _pool[k] = entry; - return PgLease._(this, k, conn); + try { + final conn = await createAndConnect(row, database: database, mode: mode); + entry = _PoolEntry(conn)..refs = 1; + _pool[k] = entry; + return PgLease._(this, k, conn); + } on PostgresConnectionException { + rethrow; + } catch (e, st) { + Error.throwWithStackTrace( + PostgresConnectionException( + 'Failed to acquire PostgreSQL connection for database "$database": $e', + cause: e, + stackTrace: st, + ), + st, + ); + } } /// Drops idle LRU slots until there is room for one more key. diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 8cdd2303..2628e0ad 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -145,16 +145,29 @@ class _PostgresConnectionFormContentState final port = int.tryParse(_portController.text.trim()) ?? 5432; final database = _databaseController.text.trim(); final uri = _connectionStringController.text.trim(); + + String? uriHost; + int? uriPort; + if (uri.isNotEmpty) { + final parsedUri = Uri.tryParse(uri); + if (parsedUri != null && parsedUri.host.isNotEmpty) { + uriHost = parsedUri.host; + uriPort = parsedUri.hasPort ? parsedUri.port : null; + } + } + + final effectiveHost = uriHost ?? host; + final effectivePort = uriPort ?? port; final displayName = name.isNotEmpty ? name : (uri.isNotEmpty - ? 'PostgreSQL (URI)' + ? 'PostgreSQL: $effectiveHost:$effectivePort' : 'PostgreSQL $host:$port/$database'); final row = ConnectionRow( type: 'postgresql', name: displayName, - host: uri.isNotEmpty ? null : host, - port: uri.isNotEmpty ? null : port, + host: uriHost ?? (uri.isEmpty ? host : null), + port: uriPort ?? (uri.isEmpty ? port : null), username: _usernameController.text.trim().isEmpty ? null : _usernameController.text.trim(), diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart index 0542de50..dff4f3fd 100644 --- a/test/core/database/postgres_connection_pool_test.dart +++ b/test/core/database/postgres_connection_pool_test.dart @@ -339,4 +339,50 @@ void main() { expect(fake.disconnectCount, 1); }); }); + + group('PostgresConnectionPool error wrapping', () { + test('wraps unexpected factory errors in PostgresConnectionException', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + throw const FormatException('bad connection string'); + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + await expectLater( + pool.acquire(_row(), database: 'postgres'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Failed to acquire PostgreSQL connection'), + ), + ), + ); + }); + + test('rethrows PostgresConnectionException from factory', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + throw PostgresConnectionException('driver refused'); + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + await expectLater( + pool.acquire(_row(), database: 'postgres'), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('driver refused'), + ), + ), + ); + }); + }); } diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index 4d2ea716..673a87bb 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -433,6 +433,18 @@ void main() { expect(ex.message, 'connection refused'); expect(ex.toString(), 'connection refused'); }); + + test('can store cause and stack trace', () { + final cause = StateError('root'); + final trace = StackTrace.current; + final ex = PostgresConnectionException( + 'connection refused', + cause: cause, + stackTrace: trace, + ); + expect(ex.cause, cause); + expect(ex.stackTrace, trace); + }); }); group('replaceDatabaseInConnectionString', () { @@ -470,4 +482,25 @@ void main() { expect(result.error, contains('sslmode')); }); }); + + group('PostgresConnection.connect', () { + test('throws PostgresConnectionException on invalid sslmode', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'postgresql://localhost/db?sslmode=invalid', + ); + expect( + conn.connect, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('sslmode'), + ), + ), + ); + }); + }); } diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index c1589417..18b76694 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -88,5 +89,43 @@ void main() { expect(result, isNull); }); + + testWidgets('Save from URI extracts host and port for display', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + ConnectionRow? result; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showPostgresConnectionForm(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + ), + 'postgresql://u:p@remote.example.com:5433/db', + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.host, 'remote.example.com'); + expect(result!.port, 5433); + expect(result!.name, 'PostgreSQL: remote.example.com:5433'); + expect(result!.connectionString, 'postgresql://u:p@remote.example.com:5433/db'); + }); }); } From fe077469ca73520a43641fa926482721a9713044 Mon Sep 17 00:00:00 2001 From: Eva Rei <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:20:19 +0300 Subject: [PATCH 22/23] feat(connections): add SSL certificate file pickers to PostgreSQL form (#260) Add optional file pickers for sslrootcert, sslcert, and sslkey. Paths are stored in the connection URI (which is already persisted in the secure store), and the URI is auto-generated when certificate paths are provided in host/port mode. The certificate fields are also pre-populated from an existing URI. --- .../postgresql_connection_form.dart | 216 ++++++++++++++++-- .../postgresql_connection_form_test.dart | 141 ++++++++++++ 2 files changed, 344 insertions(+), 13 deletions(-) diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 2628e0ad..a7d5bb84 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -42,6 +43,9 @@ class _PostgresConnectionFormContentState final _usernameController = material.TextEditingController(text: 'postgres'); final _passwordController = material.TextEditingController(); final _connectionStringController = material.TextEditingController(); + final _sslRootCertController = material.TextEditingController(); + final _sslCertController = material.TextEditingController(); + final _sslKeyController = material.TextEditingController(); bool _useSSL = false; bool _showPassword = false; @@ -64,6 +68,10 @@ class _PostgresConnectionFormContentState ]) { _formValidNotifier.listenTo(c); } + _connectionStringController.addListener(_populateSslFieldsFromUri); + _sslRootCertController.addListener(_syncUriSslParams); + _sslCertController.addListener(_syncUriSslParams); + _sslKeyController.addListener(_syncUriSslParams); _formValidNotifier.seed(); } @@ -82,6 +90,119 @@ class _PostgresConnectionFormContentState return t.startsWith('postgres://') || t.startsWith('postgresql://'); } + void _populateSslFieldsFromUri() { + final uriText = _connectionStringController.text.trim(); + if (uriText.isEmpty) return; + final parsed = Uri.tryParse(uriText); + if (parsed == null) return; + _sslRootCertController.text = parsed.queryParameters['sslrootcert'] ?? ''; + _sslCertController.text = parsed.queryParameters['sslcert'] ?? ''; + _sslKeyController.text = parsed.queryParameters['sslkey'] ?? ''; + } + + void _setOrRemoveSslParam( + Map params, + String key, + material.TextEditingController controller, + ) { + final value = controller.text.trim(); + if (value.isEmpty) { + params.remove(key); + } else { + params[key] = value; + } + } + + void _syncUriSslParams() { + final uriText = _connectionStringController.text.trim(); + if (uriText.isEmpty) return; + final parsed = Uri.tryParse(uriText); + if (parsed == null) return; + final params = Map.from(parsed.queryParameters); + _setOrRemoveSslParam(params, 'sslrootcert', _sslRootCertController); + _setOrRemoveSslParam(params, 'sslcert', _sslCertController); + _setOrRemoveSslParam(params, 'sslkey', _sslKeyController); + final newUri = Uri( + scheme: parsed.scheme, + userInfo: parsed.userInfo.isEmpty ? null : parsed.userInfo, + host: parsed.host, + port: parsed.hasPort ? parsed.port : null, + path: parsed.path.isEmpty ? null : parsed.path, + queryParameters: params.isEmpty ? null : params, + fragment: parsed.fragment.isEmpty ? null : parsed.fragment, + ); + _connectionStringController.text = newUri.toString(); + _formValidNotifier.seed(); + } + + Future _pickCertificateFile( + material.TextEditingController controller, + ) async { + const typeGroup = XTypeGroup( + label: 'PEM files', + extensions: ['pem', 'crt', 'key', 'cer'], + ); + final file = await openFile(acceptedTypeGroups: const [typeGroup]); + if (file == null) return; + controller.text = file.path; + _syncUriSslParams(); + } + + String _buildConnectionUri({ + required String host, + required int port, + String? username, + String? password, + String? database, + String? sslRootCert, + String? sslCert, + String? sslKey, + }) { + final userInfoParts = [ + if (username != null && username.isNotEmpty) Uri.encodeComponent(username), + if (password != null && password.isNotEmpty) Uri.encodeComponent(password), + ]; + final queryParams = { + if (sslRootCert != null && sslRootCert.isNotEmpty) + 'sslrootcert': sslRootCert, + if (sslCert != null && sslCert.isNotEmpty) 'sslcert': sslCert, + if (sslKey != null && sslKey.isNotEmpty) 'sslkey': sslKey, + }; + return Uri( + scheme: 'postgresql', + userInfo: userInfoParts.join(':'), + host: host, + port: port, + path: database == null || database.isEmpty ? '' : '/$database', + queryParameters: queryParams.isEmpty ? null : queryParams, + ).toString(); + } + + String _effectiveConnectionUri() { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) return uri; + final sslRootCert = _sslRootCertController.text.trim(); + final sslCert = _sslCertController.text.trim(); + final sslKey = _sslKeyController.text.trim(); + if (sslRootCert.isEmpty && sslCert.isEmpty && sslKey.isEmpty) return ''; + return _buildConnectionUri( + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 5432, + username: _usernameController.text.trim(), + password: _passwordController.text, + database: _databaseController.text.trim(), + sslRootCert: sslRootCert, + sslCert: sslCert, + sslKey: sslKey, + ); + } + + bool _hasSslCertificateFields() { + return _sslRootCertController.text.trim().isNotEmpty || + _sslCertController.text.trim().isNotEmpty || + _sslKeyController.text.trim().isNotEmpty; + } + void _showTestResult(String result) { _dismissTimer?.cancel(); setState(() { @@ -108,13 +229,14 @@ class _PostgresConnectionFormContentState _testResult = null; }); try { - final uri = _connectionStringController.text.trim(); + final uri = _effectiveConnectionUri(); + final hasUri = uri.isNotEmpty; final conn = PostgresConnection( id: 0, name: _nameController.text.trim().isEmpty ? 'test' : _nameController.text.trim(), - host: uri.isNotEmpty ? 'localhost' : _hostController.text.trim(), + host: hasUri ? 'localhost' : _hostController.text.trim(), port: int.tryParse(_portController.text.trim()) ?? 5432, database: _databaseController.text.trim().isEmpty ? null @@ -124,8 +246,8 @@ class _PostgresConnectionFormContentState : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, - useSSL: _useSSL, - connectionString: uri.isEmpty ? null : uri, + useSSL: _useSSL || _hasSslCertificateFields(), + connectionString: hasUri ? uri : null, ); final result = await conn.testConnection(); if (mounted) { @@ -144,12 +266,16 @@ class _PostgresConnectionFormContentState final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 5432; final database = _databaseController.text.trim(); - final uri = _connectionStringController.text.trim(); + + _syncUriSslParams(); + final effectiveUri = _effectiveConnectionUri(); + final hasSslCerts = _hasSslCertificateFields(); + final effectiveUseSSL = _useSSL || hasSslCerts; String? uriHost; int? uriPort; - if (uri.isNotEmpty) { - final parsedUri = Uri.tryParse(uri); + if (effectiveUri.isNotEmpty) { + final parsedUri = Uri.tryParse(effectiveUri); if (parsedUri != null && parsedUri.host.isNotEmpty) { uriHost = parsedUri.host; uriPort = parsedUri.hasPort ? parsedUri.port : null; @@ -160,32 +286,67 @@ class _PostgresConnectionFormContentState final effectivePort = uriPort ?? port; final displayName = name.isNotEmpty ? name - : (uri.isNotEmpty + : (effectiveUri.isNotEmpty ? 'PostgreSQL: $effectiveHost:$effectivePort' : 'PostgreSQL $host:$port/$database'); final row = ConnectionRow( type: 'postgresql', name: displayName, - host: uriHost ?? (uri.isEmpty ? host : null), - port: uriPort ?? (uri.isEmpty ? port : null), + host: uriHost ?? (effectiveUri.isEmpty ? host : null), + port: uriPort ?? (effectiveUri.isEmpty ? port : null), username: _usernameController.text.trim().isEmpty ? null : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, databaseName: - uri.isNotEmpty ? null : (database.isEmpty ? null : database), - useSSL: _useSSL, - connectionString: uri.isEmpty ? null : uri, + effectiveUri.isNotEmpty ? null : (database.isEmpty ? null : database), + useSSL: effectiveUseSSL, + connectionString: effectiveUri.isEmpty ? null : effectiveUri, folderId: widget.folderId, createdAt: DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } + material.Widget _buildSslFileField({ + required String label, + required material.TextEditingController controller, + }) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).xSmall().muted(), + const Gap(4), + material.Row( + children: [ + material.Expanded( + child: TextField( + key: Key(label), + controller: controller, + placeholder: const Text('/path/to/file.pem'), + onChanged: (_) => _syncUriSslParams(), + ), + ), + const Gap(8), + GhostButton( + onPressed: () => _pickCertificateFile(controller), + child: const Icon(material.Icons.folder_open_rounded), + ), + ], + ), + ], + ); + } + @override void dispose() { _dismissTimer?.cancel(); + _connectionStringController.removeListener(_populateSslFieldsFromUri); + _sslRootCertController.removeListener(_syncUriSslParams); + _sslCertController.removeListener(_syncUriSslParams); + _sslKeyController.removeListener(_syncUriSslParams); for (final c in [ _nameController, _hostController, @@ -204,6 +365,9 @@ class _PostgresConnectionFormContentState _usernameController.dispose(); _passwordController.dispose(); _connectionStringController.dispose(); + _sslRootCertController.dispose(); + _sslCertController.dispose(); + _sslKeyController.dispose(); super.dispose(); } @@ -394,6 +558,32 @@ class _PostgresConnectionFormContentState const Text('Use SSL/TLS').small(), ], ), + if (_useSSL) ...[ + const Gap(16), + const Text('SSL Certificates (optional)') + .small() + .semiBold(), + const Gap(4), + const Text( + 'Root CA, client certificate, and client key are ' + 'appended to the connection URI.', + ).muted().small(), + const Gap(8), + _buildSslFileField( + label: 'Root CA / SSL Root Certificate', + controller: _sslRootCertController, + ), + const Gap(8), + _buildSslFileField( + label: 'SSL Client Certificate', + controller: _sslCertController, + ), + const Gap(8), + _buildSslFileField( + label: 'SSL Client Key', + controller: _sslKeyController, + ), + ], ], ), ), diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index 18b76694..adda7db4 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -127,5 +127,146 @@ void main() { expect(result!.name, 'PostgreSQL: remote.example.com:5433'); expect(result!.connectionString, 'postgresql://u:p@remote.example.com:5433/db'); }); + + testWidgets('SSL certificate path is appended to the URI', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showPostgresConnectionForm(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + ), + 'postgresql://u:p@remote.example.com:5433/db', + ); + + await tester.scrollUntilVisible( + find.byType(material.Checkbox).first, + 300, + scrollable: find.byType(material.Scrollable).first, + ); + await tester.tap(find.byType(material.Checkbox).first); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const Key('Root CA / SSL Root Certificate')), + 300, + scrollable: find.byType(material.Scrollable).first, + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('Root CA / SSL Root Certificate')), + '/certs/root.pem', + ); + await tester.pumpAndSettle(); + + final uriField = tester.widget( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + ), + ); + expect(uriField.controller?.text, contains('sslrootcert')); + expect(uriField.controller?.text, contains('root.pem')); + }); + + testWidgets('Save with SSL certs and no URI builds a connection URI', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + ConnectionRow? result; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showPostgresConnectionForm(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'My PostgreSQL Server', + ), + 'Cert PG', + ); + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'localhost', + ).first, + 'pg.example.com', + ); + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', + ).first, + 'appdb', + ); + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', + ).last, + 'admin', + ); + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'Password', + ), + 'secret', + ); + + await tester.scrollUntilVisible( + find.byType(material.Checkbox).first, + 300, + scrollable: find.byType(material.Scrollable).first, + ); + await tester.tap(find.byType(material.Checkbox).first); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const Key('Root CA / SSL Root Certificate')), + 300, + scrollable: find.byType(material.Scrollable).first, + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('Root CA / SSL Root Certificate')), + '/certs/root.pem', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.connectionString, isNotNull); + expect(result!.connectionString, contains('pg.example.com')); + expect(result!.connectionString, contains('sslrootcert')); + expect(result!.connectionString, contains('root.pem')); + expect(result!.connectionString, contains('admin')); + expect(result!.connectionString, contains('secret')); + expect(result!.useSSL, true); + }); }); } From 3dc8ee19b26e218e2b706888fc0516719e35a341 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 12:31:22 +0300 Subject: [PATCH 23/23] chore(release): prepare 0.4.9 release - Bump version to 0.4.9+1 - Add [0.4.8] and [0.4.9] sections to CHANGELOG.md --- CHANGELOG.md | 18 ++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2781fe0..feadc289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.9] - 2026-07-10 + +PostgreSQL connection reliability and TLS improvements, plus menu and URI import polish. + +### Added + +- **Connection → New Connection from URL (#228)** — create a connection by pasting a database URI for PostgreSQL, MySQL, MongoDB, Redis, or SQLite. The URL is parsed, validated, and saved to `LocalDb`. +- **Help → About & Documentation (#229)** — added an About dialog showing the app version, MIT license, and repository link; the Documentation menu item opens the project docs in the browser. +- **PostgreSQL SSL certificate file pickers (#260)** — added optional file pickers for `sslrootcert`, `sslcert`, and `sslkey` in the PostgreSQL connection form. Paths are merged into the connection URI or used to generate a URI when in host/port mode. + +### Fixed + +- **PostgreSQL URL parser / SSL (#249, #253, #254, #257, #258)** — validate `sslmode` (reject `prefer` and unknown values), correctly map `useSSL` for `disable`/`require`/`verify-ca`/`verify-full`, use driver default ports when omitted, and preserve display host/port for URI-only connections. +- **PostgreSQL connection error reporting (#250, #251, #252)** — `testConnection` now returns the underlying error message, the form shows the real failure reason, and the connection tree displays the full exception text instead of a generic "Error" label. +- **PostgreSQL connection error typing (#256, #259)** — `connect()` throws `PostgresConnectionException` with the original cause and stack trace; the pool wraps unexpected factory errors in the same typed exception. + +## [0.4.8] - 2026-07-08 + ### Fixed - **SQLite / RETURNING clause support (#243)** — support RETURNING clauses for INSERT, UPDATE, and DELETE DML queries in the SQLite database driver, returning the resulting rows to the client. diff --git a/pubspec.yaml b/pubspec.yaml index 171641ad..6fc38a73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.8 +version: 0.4.9+1