From 9fd340691352828db5cbe8f58925a9675b7519c0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:26:52 +0300 Subject: [PATCH 1/3] feat(theme): import theme files into user themes registry Add registry-backed importThemeFile with deduplication by content hash, slugified VS Code filenames, and suffixed custom theme ids on conflicts. Wire Preferences Import theme to importRegistryThemeFile while keeping legacy imported.json flow intact. --- lib/core/theme/theme_controller.dart | 17 ++ lib/core/theme/theme_import_service.dart | 41 ++++ lib/core/theme/theme_registry_service.dart | 203 ++++++++++++++++++ .../preferences_appearance_section.dart | 6 +- 4 files changed, 264 insertions(+), 3 deletions(-) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index c187a40c..6f995c1e 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -360,6 +360,23 @@ class ThemeController extends ChangeNotifier { _notifyThemeChanged(); } + /// Copies a theme into the user themes directory and activates it. + Future importRegistryThemeFile( + String path, + ) async { + final result = await _registryService.importThemeFile(path); + switch (result) { + case ThemeDefinitionImportSuccess(:final definition): + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); + await setThemeById(definition.id); + case ThemeDefinitionImportFailure(): + notifyListeners(); + } + 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); diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart index e47ece44..8787d884 100644 --- a/lib/core/theme/theme_import_service.dart +++ b/lib/core/theme/theme_import_service.dart @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p; 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 { @@ -31,11 +32,51 @@ class ThemeImportFailure extends ThemeImportResult { final String message; } +/// Result of copying a theme file into the user themes directory. +sealed class ThemeDefinitionImportResult { + const ThemeDefinitionImportResult(); +} + +final class ThemeDefinitionImportSuccess extends ThemeDefinitionImportResult { + const ThemeDefinitionImportSuccess({ + required this.definition, + required this.reusedExisting, + }); + + final ThemeDefinition definition; + final bool reusedExisting; +} + +final class ThemeDefinitionImportFailure extends ThemeDefinitionImportResult { + const ThemeDefinitionImportFailure(this.message); + final String message; +} + /// Parses and persists an imported VS Code theme under app support. abstract final class ThemeImportService { static const String legacyImportedThemeId = 'imported'; static const String storedFileName = 'imported.json'; + /// Lowercase slug for VS Code theme filenames. + static String slugifyThemeName(String name) { + final slug = name + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '-') + .replaceAll(RegExp(r'-+'), '-') + .replaceAll(RegExp(r'^-|-$'), ''); + return slug.isEmpty ? 'vscode-theme' : slug; + } + + /// Safe basename for theme files (without extension). + static String safeThemeFileBase(String value) { + final safe = value + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9._-]+'), '-') + .replaceAll(RegExp(r'-+'), '-') + .replaceAll(RegExp(r'^-|-$'), ''); + return safe.isEmpty ? 'theme' : safe; + } + /// Path to the persisted legacy import copy under app support. static Future persistedImportFile() => _storedThemeFile(); diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index eb8154f6..1a75a63b 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -75,6 +75,92 @@ class ThemeRegistryService { return List.unmodifiable(definitions); } + /// 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()); + } + } + /// Parses a scanned [definition] into a runtime [QueryaTheme]. Future loadTheme(ThemeDefinition definition) async { final path = definition.path; @@ -361,6 +447,111 @@ 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)'); @@ -368,6 +559,18 @@ 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/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index a23dd277..5ee9b966 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -74,12 +74,12 @@ class _PreferencesAppearanceSectionState if (file == null) return; final path = file.path; if (path.isEmpty) return; - final result = await _controller.importThemeFromFile(path); + final result = await _controller.importRegistryThemeFile(path); if (!mounted) return; switch (result) { - case ThemeImportSuccess(): + case ThemeDefinitionImportSuccess(): setState(() => _importError = null); - case ThemeImportFailure(:final message): + case ThemeDefinitionImportFailure(:final message): setState(() => _importError = message); } } finally { From 931e23a466c826e9c3c61c540501cde7d9d05bea Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:26:52 +0300 Subject: [PATCH 2/3] test(theme): cover registry theme import and duplicate handling Closes #118 --- test/core/theme/theme_controller_test.dart | 17 ++++ .../core/theme/theme_import_service_test.dart | 8 ++ .../theme/theme_registry_service_test.dart | 79 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 0ef9b839..88ada938 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/theme/querya_theme.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_import_service.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/theme/theme_registry_service.dart'; @@ -359,5 +360,21 @@ void main() { expect(c.isLoadingAvailableThemes, isFalse); }); + + test('importRegistryThemeFile adds theme to registry and selects it', () async { + final c = ThemeController.instance; + await c.load(); + final source = File(p.join('test/fixtures/themes', 'querya_custom_dark.json')); + + final result = await c.importRegistryThemeFile(source.path); + + expect(result, isA()); + expect(c.selectedThemeId, 'fixture-custom-dark'); + expect( + c.availableThemes.map((theme) => theme.id), + contains('fixture-custom-dark'), + ); + expect(c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + }); }); } diff --git a/test/core/theme/theme_import_service_test.dart b/test/core/theme/theme_import_service_test.dart index 0a6fa510..e6ae274a 100644 --- a/test/core/theme/theme_import_service_test.dart +++ b/test/core/theme/theme_import_service_test.dart @@ -69,4 +69,12 @@ void main() { await ThemeImportService.importFromPath('/no/such/theme.json'); expect(result, isA()); }); + + test('slugifyThemeName produces filesystem-safe slug', () { + expect( + ThemeImportService.slugifyThemeName('Fixture Dark Subset'), + 'fixture-dark-subset', + ); + expect(ThemeImportService.safeThemeFileBase('My Theme!'), 'my-theme'); + }); } diff --git a/test/core/theme/theme_registry_service_test.dart b/test/core/theme/theme_registry_service_test.dart index ae305dfd..d870d2a6 100644 --- a/test/core/theme/theme_registry_service_test.dart +++ b/test/core/theme/theme_registry_service_test.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; 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/theme/theme_registry_service.dart'; @@ -224,4 +225,82 @@ void main() { expect((result as ThemeLoadFailure).message, contains('id')); }); }); + + group('ThemeRegistryService.importThemeFile', () { + test('imports custom theme into user themes directory', () async { + final source = File(p.join('test/fixtures/themes', 'querya_custom_dark.json')); + final result = await registry.importThemeFile(source.path); + + expect(result, isA()); + final success = result as ThemeDefinitionImportSuccess; + expect(success.reusedExisting, isFalse); + 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(), + isTrue, + ); + + final definitions = await registry.loadThemeDefinitions(); + expect( + definitions.map((d) => d.id), + contains('fixture-custom-dark'), + ); + }); + + test('imports VS Code theme with slugified filename', () async { + final source = File(p.join('test/fixtures/themes', 'dark_subset.json')); + final result = await registry.importThemeFile(source.path); + + expect(result, isA()); + final success = result as ThemeDefinitionImportSuccess; + expect(success.definition.format, ThemeFormat.vscode); + expect( + p.basename(success.definition.path!), + 'fixture-dark-subset.json', + ); + }); + + test('reuses existing file when content hash matches', () async { + final source = File(p.join('test/fixtures/themes', 'querya_custom_minimal.json')); + final first = await registry.importThemeFile(source.path); + expect(first, isA()); + final firstSuccess = first as ThemeDefinitionImportSuccess; + + final second = await registry.importThemeFile(source.path); + expect(second, isA()); + final secondSuccess = second as ThemeDefinitionImportSuccess; + expect(secondSuccess.reusedExisting, isTrue); + expect(secondSuccess.definition.path, firstSuccess.definition.path); + + final themeFiles = themesDir + .listSync() + .whereType() + .where((f) => p.extension(f.path) == '.json') + .length; + expect(themeFiles, 1); + }); + + test('suffixes custom theme id when same id has different content', () async { + final source = File(p.join('test/fixtures/themes', 'querya_custom_minimal.json')); + final first = await registry.importThemeFile(source.path); + expect(first, isA()); + + final modified = File(p.join(tempDir.path, 'modified-custom.json')); + final raw = await source.readAsString(); + await modified.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); + + final second = await registry.importThemeFile(modified.path); + expect(second, isA()); + final secondSuccess = second as ThemeDefinitionImportSuccess; + expect(secondSuccess.reusedExisting, isFalse); + expect(secondSuccess.definition.id, 'fixture-custom-minimal-2'); + + final definitions = await registry.loadThemeDefinitions(); + expect( + definitions.map((d) => d.id), + containsAll(['fixture-custom-minimal', 'fixture-custom-minimal-2']), + ); + }); + }); } From 09eaa91f87698441faf7ee82126123d22d8f7fdf Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:30:30 +0300 Subject: [PATCH 3/3] fix ci --- test/core/theme/theme_controller_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 88ada938..48a55c8c 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -11,7 +11,6 @@ import 'package:querya_desktop/core/theme/querya_theme.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_import_service.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/theme/theme_registry_service.dart';