Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion lib/core/theme/theme_registry_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,29 @@ class ThemeRegistryService {
ThemeRegistryService({
Future<Directory> Function()? userThemesDirectory,
Future<Directory> 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<Directory> Function() _userThemesDirectory;
final Future<Directory> 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<List<ThemeDefinition>> loadThemeDefinitions() async {
final definitions = <ThemeDefinition>[];
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -258,3 +282,28 @@ class ThemeRegistryService {
}
}
}

class _ThemeLruCache {
_ThemeLruCache({required this.maxEntries});

final int maxEntries;
final _entries = <String, QueryaTheme>{};

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();
}
142 changes: 142 additions & 0 deletions test/core/theme/theme_registry_cache_test.dart
Original file line number Diff line number Diff line change
@@ -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<String?> getApplicationSupportPath() async => _root;
}

Future<void> _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<ThemeLoadSuccess>());
expect(second, isA<ThemeLoadSuccess>());
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);
});
});
}
Loading