diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart new file mode 100644 index 00000000..2330c14b --- /dev/null +++ b/lib/core/theme/theme_registry_service.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import 'parser/jsonc_preprocessor.dart'; +import 'parser/querya_theme_manifest.dart'; +import 'theme_definition.dart'; +import 'theme_paths.dart'; + +/// Scans theme directories and exposes lightweight [ThemeDefinition] metadata. +class ThemeRegistryService { + ThemeRegistryService({ + Future Function()? userThemesDirectory, + Future Function()? importedThemesDirectory, + }) : _userThemesDirectory = + userThemesDirectory ?? ThemePaths.userThemesDirectory, + _importedThemesDirectory = + importedThemesDirectory ?? ThemePaths.importedThemesDirectory; + + final Future Function() _userThemesDirectory; + final Future Function() _importedThemesDirectory; + + Future> loadThemeDefinitions() async { + final definitions = []; + + await _scanDirectory( + await _userThemesDirectory(), + ThemeSource.filesystem, + definitions, + ); + await _scanDirectory( + await _importedThemesDirectory(), + ThemeSource.imported, + definitions, + ); + + definitions.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + return List.unmodifiable(definitions); + } + + 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; + + 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 { + try { + final stat = await file.stat(); + final raw = await file.readAsString(); + final hash = _contentHash(raw); + final json = _decodeRoot(raw); + if (json == null) { + _logScanError(file.path, 'Invalid JSON'); + return null; + } + + final schema = json['schema']?.toString(); + if (schema == queryaThemeSchemaV1) { + return _customDefinition( + json: json, + file: file, + source: source, + lastModified: stat.modified, + contentHash: hash, + ); + } + + return _vscodeDefinition( + json: json, + file: file, + source: source, + lastModified: stat.modified, + contentHash: hash, + ); + } on Object catch (e) { + _logScanError(file.path, e); + return null; + } + } + + ThemeDefinition? _customDefinition({ + required Map json, + required File file, + required ThemeSource source, + required DateTime lastModified, + required String contentHash, + }) { + final id = json['id']?.toString().trim(); + final name = json['name']?.toString().trim(); + final type = json['type']?.toString().trim().toLowerCase(); + + if (id == null || id.isEmpty || name == null || name.isEmpty) { + _logScanError(file.path, 'Missing required custom theme fields'); + return null; + } + if (type != 'dark' && type != 'light') { + _logScanError(file.path, 'Invalid custom theme type "$type"'); + return null; + } + + return ThemeDefinition( + id: id, + name: name, + source: source, + format: ThemeFormat.queryaCustom, + isDark: type == 'dark', + path: file.path, + lastModified: lastModified, + contentHash: contentHash, + ); + } + + ThemeDefinition? _vscodeDefinition({ + required Map json, + required File file, + required ThemeSource source, + required DateTime lastModified, + required String contentHash, + }) { + final fileId = p.basenameWithoutExtension(file.path); + final rawName = json['name']?.toString().trim(); + final name = rawName != null && rawName.isNotEmpty ? rawName : fileId; + final type = json['type']?.toString().trim().toLowerCase(); + + return ThemeDefinition( + id: fileId, + name: name, + source: source, + format: ThemeFormat.vscode, + isDark: type == 'dark', + path: file.path, + lastModified: lastModified, + contentHash: contentHash, + ); + } + + Map? _decodeRoot(String raw) { + try { + final decoded = jsonDecode(stripJsonc(raw)); + if (decoded is Map) return decoded; + } on FormatException { + return null; + } + return null; + } + + static String _contentHash(String content) { + final bytes = utf8.encode(content); + var hash = 0x811c9dc5; + for (final b in bytes) { + hash ^= b; + hash = (hash * 0x01000193) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); + } + + void _logScanError(String path, Object error) { + if (kDebugMode) { + debugPrint('ThemeRegistryService: skipped $path ($error)'); + } + } +} diff --git a/test/core/theme/theme_registry_service_test.dart b/test/core/theme/theme_registry_service_test.dart new file mode 100644 index 00000000..b94ffdcf --- /dev/null +++ b/test/core/theme/theme_registry_service_test.dart @@ -0,0 +1,158 @@ +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/theme/theme_definition.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; +} + +Future _copyFixture(String fixtureName, File destination) async { + final source = File(p.join('test/fixtures/themes', fixtureName)); + await destination.writeAsString(await source.readAsString()); +} + +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_registry_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + }); + + 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 { + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('ThemeRegistryService.loadThemeDefinitions', () { + test('includes valid custom and VS Code themes, skips broken file', () async { + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await _copyFixture( + 'dark_subset.json', + File(p.join(themesDir.path, 'dark_subset.json')), + ); + await _copyFixture( + 'querya_custom_invalid_missing_id.json', + File(p.join(themesDir.path, 'broken.json')), + ); + + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions, hasLength(2)); + expect( + definitions.map((d) => d.format).toSet(), + equals({ThemeFormat.queryaCustom, ThemeFormat.vscode}), + ); + expect( + definitions.singleWhere((d) => d.format == ThemeFormat.queryaCustom).id, + 'fixture-custom-dark', + ); + expect( + definitions.singleWhere((d) => d.format == ThemeFormat.vscode).name, + 'Fixture Dark Subset', + ); + }); + + test('sorts definitions by name case-insensitively', () async { + await File(p.join(themesDir.path, 'z-theme.json')).writeAsString(''' +{ + "schema": "querya.theme.v1", + "id": "z-theme", + "name": "Zebra Theme", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {} +} +'''); + await File(p.join(themesDir.path, 'a-theme.json')).writeAsString(''' +{ + "schema": "querya.theme.v1", + "id": "a-theme", + "name": "alpha theme", + "type": "light", + "shadcn_colors": {}, + "editor_colors": {} +} +'''); + + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions.map((d) => d.name), ['alpha theme', 'Zebra Theme']); + }); + + test('content hash changes when file content changes', () async { + final themeFile = File(p.join(themesDir.path, 'hash-theme.json')); + await _copyFixture('querya_custom_minimal.json', themeFile); + + final before = await registry.loadThemeDefinitions(); + expect(before, hasLength(1)); + final originalHash = before.single.contentHash; + + final raw = await themeFile.readAsString(); + await themeFile.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); + + final after = await registry.loadThemeDefinitions(); + expect(after, hasLength(1)); + expect(after.single.contentHash, isNot(originalHash)); + }); + + test('scans imported directory with imported source', () async { + await _copyFixture( + 'querya_custom_light.json', + File(p.join(importedDir.path, 'querya_custom_light.json')), + ); + + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions, hasLength(1)); + expect(definitions.single.source, ThemeSource.imported); + expect(definitions.single.id, 'fixture-custom-light'); + }); + + test('ignores non-json theme extensions', () async { + await File(p.join(themesDir.path, 'notes.txt')).writeAsString('not a theme'); + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions, hasLength(1)); + }); + }); +}