diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index 219fef16..090ada5d 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -19,13 +19,29 @@ class ThemeRegistryService { ThemeRegistryService({ Future Function()? userThemesDirectory, Future Function()? importedThemesDirectory, + int maxCacheEntries = 16, }) : _userThemesDirectory = userThemesDirectory ?? ThemePaths.userThemesDirectory, _importedThemesDirectory = - importedThemesDirectory ?? ThemePaths.importedThemesDirectory; + importedThemesDirectory ?? ThemePaths.importedThemesDirectory, + _themeCache = _ThemeLruCache(maxEntries: maxCacheEntries); + + static const defaultMaxCacheEntries = 16; final Future Function() _userThemesDirectory; final Future Function() _importedThemesDirectory; + final _ThemeLruCache _themeCache; + int _themeParseCount = 0; + + /// Number of cache misses that performed a full theme parse. + @visibleForTesting + int get themeParseCount => _themeParseCount; + + /// Clears parsed theme cache and parse counter. + void clearCache() { + _themeCache.clear(); + _themeParseCount = 0; + } Future> loadThemeDefinitions() async { final definitions = []; @@ -65,12 +81,20 @@ class ThemeRegistryService { ); } + final cacheKey = definition.stableCacheKey; + final cachedTheme = _themeCache.get(cacheKey); + if (cachedTheme != null) { + return ThemeLoadSuccess(definition: definition, theme: cachedTheme); + } + try { final raw = await file.readAsString(); final theme = switch (definition.format) { ThemeFormat.queryaCustom => _loadCustomTheme(raw), ThemeFormat.vscode => _loadVsCodeTheme(raw), }; + _themeCache.put(cacheKey, theme); + _themeParseCount++; return ThemeLoadSuccess(definition: definition, theme: theme); } on QueryaThemeManifestParseException catch (e) { return ThemeLoadFailure( @@ -258,3 +282,28 @@ class ThemeRegistryService { } } } + +class _ThemeLruCache { + _ThemeLruCache({required this.maxEntries}); + + final int maxEntries; + final _entries = {}; + + QueryaTheme? get(String key) { + final value = _entries.remove(key); + if (value == null) return null; + _entries[key] = value; + return value; + } + + void put(String key, QueryaTheme theme) { + _entries.remove(key); + _entries[key] = theme; + while (_entries.length > maxEntries) { + final oldest = _entries.keys.first; + _entries.remove(oldest); + } + } + + void clear() => _entries.clear(); +} diff --git a/test/core/theme/theme_registry_cache_test.dart b/test/core/theme/theme_registry_cache_test.dart new file mode 100644 index 00000000..9e379d49 --- /dev/null +++ b/test/core/theme/theme_registry_cache_test.dart @@ -0,0 +1,142 @@ +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_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; +} + +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_cache_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 cache', () { + test('loads same definition twice with a single parse', () async { + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + + final definition = (await registry.loadThemeDefinitions()).single; + + final first = await registry.loadTheme(definition); + final second = await registry.loadTheme(definition); + + expect(first, isA()); + expect(second, isA()); + expect(registry.themeParseCount, 1); + }); + + test('re-parses when content hash changes', () async { + final themeFile = File(p.join(themesDir.path, 'hash-theme.json')); + await _copyFixture('querya_custom_minimal.json', themeFile); + + final before = (await registry.loadThemeDefinitions()).single; + await registry.loadTheme(before); + expect(registry.themeParseCount, 1); + + final raw = await themeFile.readAsString(); + await themeFile.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); + + final after = (await registry.loadThemeDefinitions()).single; + expect(after.contentHash, isNot(before.contentHash)); + + await registry.loadTheme(after); + expect(registry.themeParseCount, 2); + }); + + test('evicts oldest entry after cache limit', () async { + final limitedRegistry = ThemeRegistryService( + maxCacheEntries: 2, + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => importedDir, + ); + + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await _copyFixture( + 'querya_custom_light.json', + File(p.join(themesDir.path, 'querya_custom_light.json')), + ); + await _copyFixture( + 'querya_custom_minimal.json', + File(p.join(themesDir.path, 'querya_custom_minimal.json')), + ); + + final definitions = await limitedRegistry.loadThemeDefinitions(); + expect(definitions, hasLength(3)); + + for (final definition in definitions) { + await limitedRegistry.loadTheme(definition); + } + expect(limitedRegistry.themeParseCount, 3); + + await limitedRegistry.loadTheme(definitions.first); + expect(limitedRegistry.themeParseCount, 4); + }); + + test('clearCache forces re-parse on next load', () async { + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + + final definition = (await registry.loadThemeDefinitions()).single; + await registry.loadTheme(definition); + await registry.loadTheme(definition); + expect(registry.themeParseCount, 1); + + registry.clearCache(); + await registry.loadTheme(definition); + await registry.loadTheme(definition); + expect(registry.themeParseCount, 1); + }); + }); +}