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/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/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/extension_paths.dart b/lib/core/extensions/extension_paths.dart new file mode 100644 index 00000000..d2ef4ef4 --- /dev/null +++ b/lib/core/extensions/extension_paths.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +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'; + + @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(); + 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..95b69c1d --- /dev/null +++ b/lib/core/extensions/local_extension_registry.dart @@ -0,0 +1,73 @@ +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; + Future>? _loadFuture; + + /// Returns an unmodifiable list of loaded manifests. + List get manifests => List.unmodifiable(_manifests); + + /// 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 = []; + + 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 new file mode 100644 index 00000000..cafa2e04 --- /dev/null +++ b/lib/core/extensions/models/extension_manifest.dart @@ -0,0 +1,56 @@ +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; + final String? installPath; + + 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, + this.installPath, + }); + + factory ExtensionManifest.fromJson(Map json, {String? installPath}) { + return ExtensionManifest( + id: json['id'] as String, + name: json['name'] as String, + version: json['version'] as String, + 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?, + installPath: installPath, + ); + } + + 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/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index b735eec1..da681b09 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(); @@ -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 ab18e573..1d66f21b 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,93 @@ 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; + + // 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 (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++; + } + + final themeFile = File(p.join(finalExtDir.path, 'theme.json')); + + 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: logicalId, + 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 +202,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 +234,101 @@ 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 (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++; + } + + 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 +341,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 +540,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 +564,7 @@ class ThemeRegistryService { source: source, contentHash: hash, lastModified: stat.modified, + extensionId: extensionId, ); } @@ -426,6 +575,7 @@ class ThemeRegistryService { source: source, contentHash: hash, lastModified: stat.modified, + extensionId: extensionId, ); } on Object catch (e) { _logScanError(file.path, e); @@ -440,6 +590,7 @@ class ThemeRegistryService { required ThemeSource source, required String contentHash, DateTime? lastModified, + String? extensionId, }) { final schema = json['schema']?.toString(); if (schema == queryaThemeSchemaV1) { @@ -449,6 +600,7 @@ class ThemeRegistryService { contentHash: contentHash, path: path, lastModified: lastModified, + extensionId: extensionId, ); } @@ -459,6 +611,7 @@ class ThemeRegistryService { fileBaseName: fileBaseName, path: path, lastModified: lastModified, + extensionId: extensionId, ); } @@ -468,8 +621,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 +656,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 +694,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 +701,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/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/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/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 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'); + }); + }); +} 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())); + }); + }); +} diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index b25708b5..6cea1a06 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()); }); @@ -162,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; @@ -272,7 +272,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 +313,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 +413,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 +447,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_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_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_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, - ); - }); }); } 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/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); }); 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(),