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
22 changes: 22 additions & 0 deletions lib/core/theme/theme_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import 'parser/vscode_theme_manifest.dart';
import 'querya_theme.dart';
import 'querya_theme_preset.dart';
import 'theme_definition.dart';
import 'theme_folder_watcher.dart';
import 'theme_import_service.dart';
import 'theme_load_result.dart';
import 'theme_paths.dart';
import 'theme_registry_service.dart';

/// Active theme state: preset, optional imported colors, user overrides.
Expand Down Expand Up @@ -68,6 +70,7 @@ class ThemeController extends ChangeNotifier {
QueryaTheme? _registryTheme;
bool _registrySelectionFailed = false;
bool _isLoadingAvailableThemes = false;
ThemeFolderWatcher? _themeFolderWatcher;

QueryaTheme? _cachedLightTheme;
QueryaTheme? _cachedDarkTheme;
Expand Down Expand Up @@ -170,6 +173,24 @@ class ThemeController extends ChangeNotifier {
@visibleForTesting
ThemeRegistryService get registryServiceForTest => _registryService;

@visibleForTesting
bool get isThemeFolderWatcherStarted =>
_themeFolderWatcher?.isStarted ?? false;

/// Watches `{appSupport}/themes/` and debounces [loadAvailableThemes].
Future<void> startThemeFolderWatcher() async {
_themeFolderWatcher ??= ThemeFolderWatcher(
themesDirectory: ThemePaths.userThemesDirectory,
onThemesChanged: loadAvailableThemes,
);
await _themeFolderWatcher!.start();
}

/// Stops the themes folder watcher (used in tests and app teardown).
Future<void> stopThemeFolderWatcher() async {
await _themeFolderWatcher?.stop();
}

void _invalidateThemeCache() {
_cachedLightTheme = null;
_cachedDarkTheme = null;
Expand Down Expand Up @@ -217,6 +238,7 @@ class ThemeController extends ChangeNotifier {
await _registryService.loadThemeDefinitions(),
);
await _restoreSelectedRegistryTheme();
await startThemeFolderWatcher();

_loaded = true;
_notifyThemeChanged();
Expand Down
108 changes: 108 additions & 0 deletions lib/core/theme/theme_folder_watcher.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;

/// Watches the user themes directory and notifies when registry files change.
class ThemeFolderWatcher {
ThemeFolderWatcher({
required Future<Directory> Function() themesDirectory,
required Future<void> Function() onThemesChanged,
this.debounce = const Duration(milliseconds: 400),
}) : _themesDirectory = themesDirectory,
_onThemesChanged = onThemesChanged;

final Future<Directory> Function() _themesDirectory;
final Future<void> Function() _onThemesChanged;
final Duration debounce;

StreamSubscription<FileSystemEvent>? _subscription;
Timer? _debounceTimer;
bool _started = false;
bool _refreshInFlight = false;

bool get isStarted => _started;

/// Starts watching if not already active. No-op when the directory is missing
/// and cannot be created.
Future<void> start() async {
if (_started) return;

final directory = await _themesDirectory();
if (!await directory.exists()) {
try {
await directory.create(recursive: true);
} on Object catch (error) {
debugPrint('ThemeFolderWatcher: cannot create themes directory ($error)');
return;
}
}

try {
_subscription = directory.watch(recursive: true).listen(
_onFilesystemEvent,
onError: (Object error) {
debugPrint('ThemeFolderWatcher: watch error ($error)');
},
);
_started = true;
} on Object catch (error) {
debugPrint('ThemeFolderWatcher: watch unavailable ($error)');
}
}

/// Cancels the watcher and pending debounced refresh.
Future<void> stop() async {
_debounceTimer?.cancel();
_debounceTimer = null;
await _subscription?.cancel();
_subscription = null;
_started = false;
_refreshInFlight = false;
}

void _onFilesystemEvent(FileSystemEvent event) {
if (!_isRelevantEvent(event)) return;

_debounceTimer?.cancel();
_debounceTimer = Timer(debounce, () {
unawaited(_triggerRefresh());
});
}

Future<void> _triggerRefresh() async {
if (_refreshInFlight) return;
_refreshInFlight = true;
try {
await _onThemesChanged();
} on Object catch (error) {
debugPrint('ThemeFolderWatcher: refresh failed ($error)');
} finally {
_refreshInFlight = false;
}
}

bool _isRelevantEvent(FileSystemEvent event) {
final path = event.path;
if (path.isEmpty) return false;

final baseName = p.basename(path);
if (baseName.startsWith('.')) return false;
if (baseName.endsWith('.tmp') || baseName.endsWith('~')) return false;

if (_looksLikeThemePath(path)) return true;

if (event.type == FileSystemEvent.create ||
event.type == FileSystemEvent.delete ||
event.type == FileSystemEvent.move) {
return p.extension(path).isEmpty;
}
return false;
}

bool _looksLikeThemePath(String path) {
final lower = path.toLowerCase();
return lower.endsWith('.json') || lower.endsWith('.jsonc');
}
}
1 change: 1 addition & 0 deletions test/core/theme/theme_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ void main() {
});

tearDown(() async {
await ThemeController.instance.stopThemeFolderWatcher();
await AppSettings.instance.clearThemeSettings();
await ThemeImportService.deletePersistedImport();
if (await themesDir.exists()) {
Expand Down
164 changes: 164 additions & 0 deletions test/core/theme/theme_folder_watcher_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/storage/app_settings.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
import 'package:querya_desktop/core/theme/theme_controller.dart';
import 'package:querya_desktop/core/theme/theme_folder_watcher.dart';
import 'package:querya_desktop/core/theme/theme_import_service.dart';
import 'package:querya_desktop/core/theme/theme_registry_service.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.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;

setUpAll(() async {
tempDir =
await Directory.systemTemp.createTemp('querya_theme_folder_watcher_');
PathProviderPlatform.instance = _FakePathProvider(tempDir.path);
await LocalDb.initFfi();
});

setUp(() async {
themesDir = Directory(p.join(tempDir.path, 'themes'));
await Directory(p.join(themesDir.path, 'imported')).create(recursive: true);
});

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

tearDown(() async {
await ThemeController.instance.stopThemeFolderWatcher();
await AppSettings.instance.clearThemeSettings();
await ThemeImportService.deletePersistedImport();
if (await themesDir.exists()) {
await themesDir.delete(recursive: true);
}
ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService());
await ThemeController.instance.load();
});

group('ThemeFolderWatcher', () {
test('start is idempotent and stop cancels pending refresh', () async {
var refreshCount = 0;
final watcher = ThemeFolderWatcher(
themesDirectory: () async => themesDir,
onThemesChanged: () async {
refreshCount++;
},
debounce: const Duration(milliseconds: 80),
);

await watcher.start();
await watcher.start();
expect(watcher.isStarted, isTrue);

await watcher.stop();
expect(watcher.isStarted, isFalse);

await File(p.join(themesDir.path, 'late.json')).writeAsString('{}');
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(refreshCount, 0);
});

test('debounces rapid file events into one refresh', () async {
final refreshGate = Completer<void>();
var refreshCount = 0;
final watcher = ThemeFolderWatcher(
themesDirectory: () async => themesDir,
onThemesChanged: () async {
refreshCount++;
if (!refreshGate.isCompleted) {
refreshGate.complete();
}
},
debounce: const Duration(milliseconds: 100),
);

await watcher.start();

final target = File(p.join(themesDir.path, 'querya_custom_dark.json'));
await _copyFixture('querya_custom_dark.json', target);
await target.writeAsString(await target.readAsString());
await target.writeAsString('${await target.readAsString()}\n');

await refreshGate.future.timeout(const Duration(seconds: 2));
await Future<void>.delayed(const Duration(milliseconds: 150));

expect(refreshCount, 1);
await watcher.stop();
});

test('ignores hidden and temp files', () async {
var refreshCount = 0;
final watcher = ThemeFolderWatcher(
themesDirectory: () async => themesDir,
onThemesChanged: () async {
refreshCount++;
},
debounce: const Duration(milliseconds: 80),
);
await watcher.start();

await File(p.join(themesDir.path, '.hidden.json')).writeAsString('{}');
await File(p.join(themesDir.path, 'draft.tmp')).writeAsString('{}');
await Future<void>.delayed(const Duration(milliseconds: 200));

expect(refreshCount, 0);
await watcher.stop();
});
});

group('ThemeController folder watcher', () {
test('load starts watcher and picks up newly added theme file', () async {
final c = ThemeController.instance;
c.setRegistryServiceForTest(
ThemeRegistryService(
userThemesDirectory: () async => themesDir,
importedThemesDirectory: () async => Directory(
p.join(themesDir.path, 'imported'),
),
),
);

await c.load();
expect(c.isThemeFolderWatcherStarted, isTrue);
final beforeCount = c.availableThemes.length;

await _copyFixture(
'querya_custom_dark.json',
File(p.join(themesDir.path, 'querya_custom_dark.json')),
);

await Future<void>.delayed(const Duration(milliseconds: 700));

expect(c.availableThemes.length, greaterThan(beforeCount));
expect(
c.availableThemes.map((theme) => theme.id),
contains('fixture-custom-dark'),
);
});
});
}
Loading