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
8 changes: 6 additions & 2 deletions lib/core/theme/theme_import_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ class ThemeImportFailure extends ThemeImportResult {

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

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

/// Reads [sourcePath], parses JSON/JSONC, copies to app data, returns colors.
static Future<ThemeImportResult> importFromPath(String sourcePath) async {
Expand Down Expand Up @@ -109,6 +113,6 @@ abstract final class ThemeImportService {

static Future<File> _storedThemeFile() async {
final support = await getApplicationSupportDirectory();
return File(p.join(support.path, 'themes', _storedFileName));
return File(p.join(support.path, 'themes', storedFileName));
}
}
85 changes: 85 additions & 0 deletions lib/core/theme/theme_registry_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;

import '../storage/app_settings.dart';
import 'parser/jsonc_preprocessor.dart';
import 'parser/querya_theme_from_manifest.dart';
import 'parser/querya_theme_from_vscode.dart';
import 'parser/querya_theme_manifest.dart';
import 'parser/vscode_theme_manifest.dart';
import 'querya_theme.dart';
import 'theme_definition.dart';
import 'theme_import_service.dart';
import 'theme_load_result.dart';
import 'theme_paths.dart';

Expand Down Expand Up @@ -57,6 +59,16 @@ class ThemeRegistryService {
definitions,
);

final legacy = await _legacyImportedDefinition();
if (legacy != null) {
definitions.removeWhere(
(definition) =>
definition.path == legacy.path &&
definition.source != ThemeSource.legacyImported,
);
definitions.add(legacy);
}

definitions.sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
Expand Down Expand Up @@ -138,6 +150,75 @@ class ThemeRegistryService {
return buildQueryaThemeFromVsCodeManifest(manifest);
}

Future<ThemeDefinition?> _legacyImportedDefinition() async {
try {
final settings = AppSettings.instance;
final importedColors = await settings.getThemeImportedColors();
final importName = await settings.getThemeImportName();
final importPath = await settings.getThemeImportPath();
final storedFile = await ThemeImportService.persistedImportFile();
final hasStoredFile = await storedFile.exists();

final hasLegacyData = importedColors.isNotEmpty ||
(importName != null && importName.isNotEmpty) ||
(importPath != null && importPath.isNotEmpty) ||
hasStoredFile;
if (!hasLegacyData) return null;

final path = _firstNonEmpty([
importPath,
if (hasStoredFile) storedFile.path,
storedFile.path,
]);
if (path == null) return null;

final name = (importName != null && importName.isNotEmpty)
? importName
: 'Imported theme';

var isDark = true;
DateTime? lastModified;
String? contentHash;

final file = File(path);
if (await file.exists()) {
try {
final stat = await file.stat();
lastModified = stat.modified;
final raw = await file.readAsString();
contentHash = _contentHash(raw);
final manifest = VsCodeThemeManifest.fromJsonString(raw);
isDark = manifest.isDark || !manifest.isLight;
} on Object catch (e) {
_logScanError(path, e);
}
}

return ThemeDefinition(
id: ThemeImportService.legacyImportedThemeId,
name: name,
source: ThemeSource.legacyImported,
format: ThemeFormat.vscode,
isDark: isDark,
path: path,
lastModified: lastModified,
contentHash: contentHash,
);
} on Object catch (e) {
if (kDebugMode) {
debugPrint('ThemeRegistryService: legacy import skipped ($e)');
}
return null;
}
}

String? _firstNonEmpty(List<String?> values) {
for (final value in values) {
if (value != null && value.isNotEmpty) return value;
}
return null;
}

Future<void> _scanDirectory(
Directory directory,
ThemeSource source,
Expand All @@ -152,6 +233,10 @@ class ThemeRegistryService {
}
if (entity is! File) continue;

if (p.basename(entity.path) == ThemeImportService.storedFileName) {
continue;
}

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

Expand Down
152 changes: 152 additions & 0 deletions test/core/theme/theme_registry_legacy_import_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import 'dart:io';

import 'package:flutter/material.dart';
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/storage/app_settings.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
import 'package:querya_desktop/core/theme/querya_theme_preset.dart';
import 'package:querya_desktop/core/theme/theme_controller.dart';
import 'package:querya_desktop/core/theme/theme_definition.dart';
import 'package:querya_desktop/core/theme/theme_import_service.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;
}

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_legacy_import_test_');
PathProviderPlatform.instance = _FakePathProvider(tempDir.path);
await LocalDb.initFfi();
});

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 {
await AppSettings.instance.clearThemeSettings();
await ThemeImportService.deletePersistedImport();
if (await themesDir.exists()) {
await themesDir.delete(recursive: true);
}
await ThemeController.instance.load();
});

tearDownAll(() async {
await LocalDb.instance.close();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});

group('ThemeRegistryService legacy imported migration', () {
test('exposes legacy imported theme from persisted import settings', () async {
final fixture = File('test/fixtures/themes/dark_subset.json');
final importResult = await ThemeImportService.importFromPath(fixture.path);
expect(importResult, isA<ThemeImportSuccess>());
final success = importResult as ThemeImportSuccess;

await AppSettings.instance.setThemeImportedColors(success.colors);
await AppSettings.instance.setThemeImportName(success.name);
await AppSettings.instance.setThemeImportPath(success.storedPath);
await AppSettings.instance.setThemePreset(QueryaThemePreset.imported);

final definitions = await registry.loadThemeDefinitions();
final legacy = definitions.singleWhere(
(definition) => definition.source == ThemeSource.legacyImported,
);

expect(legacy.id, ThemeImportService.legacyImportedThemeId);
expect(legacy.name, 'Fixture Dark Subset');
expect(legacy.format, ThemeFormat.vscode);
expect(legacy.path, success.storedPath);
expect(
definitions.where((definition) => definition.id == 'imported'),
hasLength(1),
);
});

test('loads legacy imported theme definition', () async {
final fixture = File('test/fixtures/themes/dark_subset.json');
final importResult = await ThemeImportService.importFromPath(fixture.path);
final success = importResult as ThemeImportSuccess;

await AppSettings.instance.setThemeImportedColors(success.colors);
await AppSettings.instance.setThemeImportName(success.name);
await AppSettings.instance.setThemeImportPath(success.storedPath);

final legacy = (await registry.loadThemeDefinitions()).singleWhere(
(definition) => definition.source == ThemeSource.legacyImported,
);
final result = await registry.loadTheme(legacy);

expect(result, isA<ThemeLoadSuccess>());
expect(
(result as ThemeLoadSuccess).theme.workbench.editorBackground,
const Color(0xFF1E1E1E),
);
});

test('missing legacy file does not crash scan or load', () async {
await AppSettings.instance.setThemeImportedColors({
'editor.background': '#1e1e1e',
});
await AppSettings.instance.setThemeImportName('Missing Legacy');
await AppSettings.instance.setThemeImportPath(
p.join(themesDir.path, 'missing-imported.json'),
);

final definitions = await registry.loadThemeDefinitions();
final legacy = definitions.singleWhere(
(definition) => definition.source == ThemeSource.legacyImported,
);

expect(legacy.name, 'Missing Legacy');
final result = await registry.loadTheme(legacy);
expect(result, isA<ThemeLoadFailure>());
expect((result as ThemeLoadFailure).message, 'Theme file not found.');
});

test('QueryaThemePreset.imported still applies via ThemeController', () async {
final controller = ThemeController.instance;
final fixture = File('test/fixtures/themes/dark_subset.json');
final result = await controller.importThemeFromFile(fixture.path);

expect(result, isA<ThemeImportSuccess>());
expect(controller.preset, QueryaThemePreset.imported);
expect(controller.hasImportedTheme, isTrue);

final definitions = await registry.loadThemeDefinitions();
expect(
definitions.any(
(definition) => definition.source == ThemeSource.legacyImported,
),
isTrue,
);
});
});
}
Loading