diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart index 7a40e5d0..e47ece44 100644 --- a/lib/core/theme/theme_import_service.dart +++ b/lib/core/theme/theme_import_service.dart @@ -33,7 +33,11 @@ class ThemeImportFailure extends ThemeImportResult { /// Parses and persists an imported VS Code theme under app support. abstract final class ThemeImportService { - static const String _storedFileName = 'imported.json'; + static const String legacyImportedThemeId = 'imported'; + static const String storedFileName = 'imported.json'; + + /// 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 { @@ -109,6 +113,6 @@ abstract final class ThemeImportService { static Future _storedThemeFile() async { final support = await getApplicationSupportDirectory(); - return File(p.join(support.path, 'themes', _storedFileName)); + return File(p.join(support.path, 'themes', storedFileName)); } } diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index 090ada5d..eb8154f6 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; +import '../storage/app_settings.dart'; import 'parser/jsonc_preprocessor.dart'; import 'parser/querya_theme_from_manifest.dart'; import 'parser/querya_theme_from_vscode.dart'; @@ -11,6 +12,7 @@ import 'parser/querya_theme_manifest.dart'; import 'parser/vscode_theme_manifest.dart'; import 'querya_theme.dart'; import 'theme_definition.dart'; +import 'theme_import_service.dart'; import 'theme_load_result.dart'; import 'theme_paths.dart'; @@ -57,6 +59,16 @@ class ThemeRegistryService { 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()), ); @@ -138,6 +150,75 @@ class ThemeRegistryService { return buildQueryaThemeFromVsCodeManifest(manifest); } + Future _legacyImportedDefinition() async { + try { + final settings = AppSettings.instance; + final importedColors = await settings.getThemeImportedColors(); + final importName = await settings.getThemeImportName(); + final importPath = await settings.getThemeImportPath(); + final storedFile = await ThemeImportService.persistedImportFile(); + final hasStoredFile = await storedFile.exists(); + + final hasLegacyData = importedColors.isNotEmpty || + (importName != null && importName.isNotEmpty) || + (importPath != null && importPath.isNotEmpty) || + hasStoredFile; + if (!hasLegacyData) return null; + + final path = _firstNonEmpty([ + importPath, + if (hasStoredFile) storedFile.path, + storedFile.path, + ]); + if (path == null) return null; + + final name = (importName != null && importName.isNotEmpty) + ? importName + : 'Imported theme'; + + var isDark = true; + DateTime? lastModified; + String? contentHash; + + final file = File(path); + if (await file.exists()) { + try { + final stat = await file.stat(); + lastModified = stat.modified; + final raw = await file.readAsString(); + contentHash = _contentHash(raw); + final manifest = VsCodeThemeManifest.fromJsonString(raw); + isDark = manifest.isDark || !manifest.isLight; + } on Object catch (e) { + _logScanError(path, e); + } + } + + return ThemeDefinition( + id: ThemeImportService.legacyImportedThemeId, + name: name, + source: ThemeSource.legacyImported, + format: ThemeFormat.vscode, + isDark: isDark, + path: path, + lastModified: lastModified, + contentHash: contentHash, + ); + } on Object catch (e) { + if (kDebugMode) { + debugPrint('ThemeRegistryService: legacy import skipped ($e)'); + } + return null; + } + } + + String? _firstNonEmpty(List values) { + for (final value in values) { + if (value != null && value.isNotEmpty) return value; + } + return null; + } + Future _scanDirectory( Directory directory, ThemeSource source, @@ -152,6 +233,10 @@ class ThemeRegistryService { } 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; diff --git a/test/core/theme/theme_registry_legacy_import_test.dart b/test/core/theme/theme_registry_legacy_import_test.dart new file mode 100644 index 00000000..318b4d52 --- /dev/null +++ b/test/core/theme/theme_registry_legacy_import_test.dart @@ -0,0 +1,152 @@ +import 'dart:io'; + +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/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'; +import 'package:querya_desktop/core/theme/theme_registry_service.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory themesDir; + late Directory importedDir; + late ThemeRegistryService registry; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_legacy_import_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + setUp(() async { + themesDir = Directory(p.join(tempDir.path, 'themes')); + importedDir = Directory(p.join(themesDir.path, 'imported')); + await importedDir.create(recursive: true); + + registry = ThemeRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => importedDir, + ); + }); + + tearDown(() async { + await AppSettings.instance.clearThemeSettings(); + await ThemeImportService.deletePersistedImport(); + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + await ThemeController.instance.load(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('ThemeRegistryService legacy imported migration', () { + 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); + await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); + + final definitions = await registry.loadThemeDefinitions(); + final legacy = definitions.singleWhere( + (definition) => definition.source == ThemeSource.legacyImported, + ); + + expect(legacy.id, ThemeImportService.legacyImportedThemeId); + expect(legacy.name, 'Fixture Dark Subset'); + expect(legacy.format, ThemeFormat.vscode); + expect(legacy.path, success.storedPath); + expect( + definitions.where((definition) => definition.id == 'imported'), + hasLength(1), + ); + }); + + 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; + + await AppSettings.instance.setThemeImportedColors(success.colors); + await AppSettings.instance.setThemeImportName(success.name); + await AppSettings.instance.setThemeImportPath(success.storedPath); + + final legacy = (await registry.loadThemeDefinitions()).singleWhere( + (definition) => definition.source == ThemeSource.legacyImported, + ); + final result = await registry.loadTheme(legacy); + + expect(result, isA()); + expect( + (result as ThemeLoadSuccess).theme.workbench.editorBackground, + const Color(0xFF1E1E1E), + ); + }); + + test('missing legacy file does not crash scan or load', () async { + await AppSettings.instance.setThemeImportedColors({ + 'editor.background': '#1e1e1e', + }); + await AppSettings.instance.setThemeImportName('Missing Legacy'); + await AppSettings.instance.setThemeImportPath( + p.join(themesDir.path, 'missing-imported.json'), + ); + + final definitions = await registry.loadThemeDefinitions(); + final legacy = definitions.singleWhere( + (definition) => definition.source == ThemeSource.legacyImported, + ); + + expect(legacy.name, 'Missing Legacy'); + final result = await registry.loadTheme(legacy); + expect(result, isA()); + 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, + ); + }); + }); +}