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
188 changes: 188 additions & 0 deletions lib/core/theme/theme_registry_service.dart
Original file line number Diff line number Diff line change
@@ -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<Directory> Function()? userThemesDirectory,
Future<Directory> Function()? importedThemesDirectory,
}) : _userThemesDirectory =
userThemesDirectory ?? ThemePaths.userThemesDirectory,
_importedThemesDirectory =
importedThemesDirectory ?? ThemePaths.importedThemesDirectory;

final Future<Directory> Function() _userThemesDirectory;
final Future<Directory> Function() _importedThemesDirectory;

Future<List<ThemeDefinition>> loadThemeDefinitions() async {
final definitions = <ThemeDefinition>[];

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<void> _scanDirectory(
Directory directory,
ThemeSource source,
List<ThemeDefinition> 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<ThemeDefinition?> _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<String, dynamic> 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<String, dynamic> 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<String, dynamic>? _decodeRoot(String raw) {
try {
final decoded = jsonDecode(stripJsonc(raw));
if (decoded is Map<String, dynamic>) 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)');
}
}
}
158 changes: 158 additions & 0 deletions test/core/theme/theme_registry_service_test.dart
Original file line number Diff line number Diff line change
@@ -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<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_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));
});
});
}
Loading