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
17 changes: 17 additions & 0 deletions lib/core/theme/theme_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,23 @@ class ThemeController extends ChangeNotifier {
_notifyThemeChanged();
}

/// Copies a theme into the user themes directory and activates it.
Future<ThemeDefinitionImportResult> importRegistryThemeFile(
String path,
) async {
final result = await _registryService.importThemeFile(path);
switch (result) {
case ThemeDefinitionImportSuccess(:final definition):
_availableThemes = _mergeBuiltinThemes(
await _registryService.loadThemeDefinitions(),
);
await setThemeById(definition.id);
case ThemeDefinitionImportFailure():
notifyListeners();
}
return result;
}

/// Parses a VS Code theme file, persists it, and activates the imported preset.
Future<ThemeImportResult> importThemeFromFile(String path) async {
final result = await ThemeImportService.importFromPath(path);
Expand Down
41 changes: 41 additions & 0 deletions lib/core/theme/theme_import_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

import 'parser/vscode_theme_manifest.dart';
import 'theme_definition.dart';

/// Result of importing a VS Code theme file.
sealed class ThemeImportResult {
Expand Down Expand Up @@ -31,11 +32,51 @@ class ThemeImportFailure extends ThemeImportResult {
final String message;
}

/// Result of copying a theme file into the user themes directory.
sealed class ThemeDefinitionImportResult {
const ThemeDefinitionImportResult();
}

final class ThemeDefinitionImportSuccess extends ThemeDefinitionImportResult {
const ThemeDefinitionImportSuccess({
required this.definition,
required this.reusedExisting,
});

final ThemeDefinition definition;
final bool reusedExisting;
}

final class ThemeDefinitionImportFailure extends ThemeDefinitionImportResult {
const ThemeDefinitionImportFailure(this.message);
final String message;
}

/// Parses and persists an imported VS Code theme under app support.
abstract final class ThemeImportService {
static const String legacyImportedThemeId = 'imported';
static const String storedFileName = 'imported.json';

/// Lowercase slug for VS Code theme filenames.
static String slugifyThemeName(String name) {
final slug = name
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), '-')
.replaceAll(RegExp(r'-+'), '-')
.replaceAll(RegExp(r'^-|-$'), '');
return slug.isEmpty ? 'vscode-theme' : slug;
}

/// Safe basename for theme files (without extension).
static String safeThemeFileBase(String value) {
final safe = value
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9._-]+'), '-')
.replaceAll(RegExp(r'-+'), '-')
.replaceAll(RegExp(r'^-|-$'), '');
return safe.isEmpty ? 'theme' : safe;
}

/// Path to the persisted legacy import copy under app support.
static Future<File> persistedImportFile() => _storedThemeFile();

Expand Down
203 changes: 203 additions & 0 deletions lib/core/theme/theme_registry_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,92 @@ class ThemeRegistryService {
return List.unmodifiable(definitions);
}

/// Validates [sourcePath], copies into the user themes directory, and returns
/// the scanned [ThemeDefinition].
Future<ThemeDefinitionImportResult> importThemeFile(String sourcePath) async {
try {
final source = File(sourcePath);
if (!await source.exists()) {
return const ThemeDefinitionImportFailure('Theme file not found.');
}

final raw = await source.readAsString();
final hash = _contentHash(raw);
final json = _decodeRoot(raw);
if (json == null) {
return const ThemeDefinitionImportFailure('Invalid JSON.');
}

final themesDir = await _userThemesDirectory();
if (!await themesDir.exists()) {
await themesDir.create(recursive: true);
}

final schema = json['schema']?.toString();
late final String logicalId;
late final String preferredBaseName;
late String contentToWrite;

if (schema == queryaThemeSchemaV1) {
final manifest = QueryaThemeManifest.fromJsonString(raw);
logicalId = manifest.id;
preferredBaseName = ThemeImportService.safeThemeFileBase(manifest.id);
contentToWrite = raw;
} else {
final manifest = VsCodeThemeManifest.fromJsonString(raw);
if (manifest.colors.isEmpty) {
return const ThemeDefinitionImportFailure(
'Theme file has no "colors" section to import.',
);
}
final displayName = manifest.name?.trim().isNotEmpty == true
? manifest.name!.trim()
: p.basenameWithoutExtension(sourcePath);
preferredBaseName = ThemeImportService.slugifyThemeName(displayName);
logicalId = preferredBaseName;
contentToWrite = raw;
}

var resolved = await _resolveImportDestination(
themesDir: themesDir,
hash: hash,
logicalId: logicalId,
preferredBaseName: preferredBaseName,
);

if (!resolved.reused &&
schema == queryaThemeSchemaV1 &&
resolved.renamedId != null) {
contentToWrite = _rewriteCustomThemeId(raw, resolved.renamedId!);
}

if (!resolved.reused) {
await resolved.file.writeAsString(contentToWrite);
}

final definition =
await _definitionFromFile(resolved.file, ThemeSource.filesystem);
if (definition == null) {
return const ThemeDefinitionImportFailure(
'Failed to index imported theme.',
);
}

return ThemeDefinitionImportSuccess(
definition: definition,
reusedExisting: resolved.reused,
);
} on QueryaThemeManifestParseException catch (e) {
return ThemeDefinitionImportFailure(e.message);
} on VsCodeThemeParseException catch (e) {
return ThemeDefinitionImportFailure(e.message);
} on IOException catch (e) {
return ThemeDefinitionImportFailure(e.toString());
} on Object catch (e) {
return ThemeDefinitionImportFailure(e.toString());
}
}

/// Parses a scanned [definition] into a runtime [QueryaTheme].
Future<ThemeLoadResult> loadTheme(ThemeDefinition definition) async {
final path = definition.path;
Expand Down Expand Up @@ -361,13 +447,130 @@ class ThemeRegistryService {
return hash.toRadixString(16).padLeft(8, '0');
}

Future<_ResolvedImportDestination> _resolveImportDestination({
required Directory themesDir,
required String hash,
required String logicalId,
required String preferredBaseName,
}) async {
File? sameIdFile;

await for (final entity in themesDir.list(followLinks: false)) {
if (entity is Directory) continue;
if (entity is! File) continue;

final name = p.basename(entity.path);
if (name == ThemeImportService.storedFileName) continue;

final ext = p.extension(entity.path).toLowerCase();
if (ext != '.json' && ext != '.jsonc') continue;

late final String existingRaw;
try {
existingRaw = await entity.readAsString();
} on IOException {
continue;
}

if (_contentHash(existingRaw) == hash) {
return _ResolvedImportDestination(file: entity, reused: true);
}

final definition =
await _definitionFromFile(entity, ThemeSource.filesystem);
if (definition?.id == logicalId) {
sameIdFile = entity;
}
}

if (sameIdFile != null) {
final renamedId = await _nextRenamedThemeId(themesDir, logicalId);
final baseName = ThemeImportService.safeThemeFileBase(renamedId);
final primary = File(p.join(themesDir.path, '$baseName.json'));
final file = await primary.exists()
? await _nextAvailableThemeFile(themesDir, baseName, startSuffix: 2)
: primary;
return _ResolvedImportDestination(
file: file,
reused: false,
renamedId: renamedId,
);
}

final primary = File(p.join(themesDir.path, '$preferredBaseName.json'));
if (!await primary.exists()) {
return _ResolvedImportDestination(file: primary, reused: false);
}

final file = await _nextAvailableThemeFile(
themesDir,
preferredBaseName,
startSuffix: 2,
);
return _ResolvedImportDestination(file: file, reused: false);
}

Future<String> _nextRenamedThemeId(Directory themesDir, String baseId) async {
for (var suffix = 2; suffix < 1000; suffix++) {
final candidate = '$baseId-$suffix';
final taken = await _themeIdExists(themesDir, candidate);
if (!taken) return candidate;
}
return '$baseId-${_contentHash(baseId)}';
}

Future<bool> _themeIdExists(Directory themesDir, String id) async {
await for (final entity in themesDir.list(followLinks: false)) {
if (entity is! File) continue;
final ext = p.extension(entity.path).toLowerCase();
if (ext != '.json' && ext != '.jsonc') continue;
final definition =
await _definitionFromFile(entity, ThemeSource.filesystem);
if (definition?.id == id) return true;
}
return false;
}

Future<File> _nextAvailableThemeFile(
Directory themesDir,
String baseName, {
int startSuffix = 2,
}) async {
for (var suffix = startSuffix; suffix < 1000; suffix++) {
final candidate = File(p.join(themesDir.path, '$baseName-$suffix.json'));
if (!await candidate.exists()) return candidate;
}
return File(
p.join(themesDir.path, '$baseName-${DateTime.now().millisecondsSinceEpoch}.json'),
);
}

String _rewriteCustomThemeId(String raw, String newId) {
final decoded = jsonDecode(stripJsonc(raw));
if (decoded is! Map<String, dynamic>) return raw;
decoded['id'] = newId;
return const JsonEncoder.withIndent(' ').convert(decoded);
}

void _logScanError(String path, Object error) {
if (kDebugMode) {
debugPrint('ThemeRegistryService: skipped $path ($error)');
}
}
}

class _ResolvedImportDestination {
const _ResolvedImportDestination({
required this.file,
required this.reused,
this.renamedId,
});

final File file;
final bool reused;
final String? renamedId;
}

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

Expand Down
6 changes: 3 additions & 3 deletions lib/features/settings/preferences_appearance_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ class _PreferencesAppearanceSectionState
if (file == null) return;
final path = file.path;
if (path.isEmpty) return;
final result = await _controller.importThemeFromFile(path);
final result = await _controller.importRegistryThemeFile(path);
if (!mounted) return;
switch (result) {
case ThemeImportSuccess():
case ThemeDefinitionImportSuccess():
setState(() => _importError = null);
case ThemeImportFailure(:final message):
case ThemeDefinitionImportFailure(:final message):
setState(() => _importError = message);
}
} finally {
Expand Down
16 changes: 16 additions & 0 deletions test/core/theme/theme_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -359,5 +359,21 @@ void main() {

expect(c.isLoadingAvailableThemes, isFalse);
});

test('importRegistryThemeFile adds theme to registry and selects it', () async {
final c = ThemeController.instance;
await c.load();
final source = File(p.join('test/fixtures/themes', 'querya_custom_dark.json'));

final result = await c.importRegistryThemeFile(source.path);

expect(result, isA<ThemeDefinitionImportSuccess>());
expect(c.selectedThemeId, 'fixture-custom-dark');
expect(
c.availableThemes.map((theme) => theme.id),
contains('fixture-custom-dark'),
);
expect(c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#38BDF8'));
});
});
}
8 changes: 8 additions & 0 deletions test/core/theme/theme_import_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,12 @@ void main() {
await ThemeImportService.importFromPath('/no/such/theme.json');
expect(result, isA<ThemeImportFailure>());
});

test('slugifyThemeName produces filesystem-safe slug', () {
expect(
ThemeImportService.slugifyThemeName('Fixture Dark Subset'),
'fixture-dark-subset',
);
expect(ThemeImportService.safeThemeFileBase('My Theme!'), 'my-theme');
});
}
Loading
Loading