From 0e1f1ce8dfa0a847cafc56b5471e82fa4e59fe84 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 07:36:57 +0300 Subject: [PATCH] feat(theme): auto-refresh registry when themes folder changes Add ThemeFolderWatcher with debounced Directory.watch on {appSupport}/themes/, wire it into ThemeController.load(), and cover start/stop, debounce, and integration refresh behavior in tests. Closes #160 --- lib/core/theme/theme_controller.dart | 22 +++ lib/core/theme/theme_folder_watcher.dart | 108 ++++++++++++ test/core/theme/theme_controller_test.dart | 1 + .../core/theme/theme_folder_watcher_test.dart | 164 ++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 lib/core/theme/theme_folder_watcher.dart create mode 100644 test/core/theme/theme_folder_watcher_test.dart diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 9c15218c..2128597b 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -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. @@ -68,6 +70,7 @@ class ThemeController extends ChangeNotifier { QueryaTheme? _registryTheme; bool _registrySelectionFailed = false; bool _isLoadingAvailableThemes = false; + ThemeFolderWatcher? _themeFolderWatcher; QueryaTheme? _cachedLightTheme; QueryaTheme? _cachedDarkTheme; @@ -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 startThemeFolderWatcher() async { + _themeFolderWatcher ??= ThemeFolderWatcher( + themesDirectory: ThemePaths.userThemesDirectory, + onThemesChanged: loadAvailableThemes, + ); + await _themeFolderWatcher!.start(); + } + + /// Stops the themes folder watcher (used in tests and app teardown). + Future stopThemeFolderWatcher() async { + await _themeFolderWatcher?.stop(); + } + void _invalidateThemeCache() { _cachedLightTheme = null; _cachedDarkTheme = null; @@ -217,6 +238,7 @@ class ThemeController extends ChangeNotifier { await _registryService.loadThemeDefinitions(), ); await _restoreSelectedRegistryTheme(); + await startThemeFolderWatcher(); _loaded = true; _notifyThemeChanged(); diff --git a/lib/core/theme/theme_folder_watcher.dart b/lib/core/theme/theme_folder_watcher.dart new file mode 100644 index 00000000..b8217bfb --- /dev/null +++ b/lib/core/theme/theme_folder_watcher.dart @@ -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 Function() themesDirectory, + required Future Function() onThemesChanged, + this.debounce = const Duration(milliseconds: 400), + }) : _themesDirectory = themesDirectory, + _onThemesChanged = onThemesChanged; + + final Future Function() _themesDirectory; + final Future Function() _onThemesChanged; + final Duration debounce; + + StreamSubscription? _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 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 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 _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'); + } +} diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 2830361b..8f3012e9 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -90,6 +90,7 @@ void main() { }); tearDown(() async { + await ThemeController.instance.stopThemeFolderWatcher(); await AppSettings.instance.clearThemeSettings(); await ThemeImportService.deletePersistedImport(); if (await themesDir.exists()) { diff --git a/test/core/theme/theme_folder_watcher_test.dart b/test/core/theme/theme_folder_watcher_test.dart new file mode 100644 index 00000000..ef958f81 --- /dev/null +++ b/test/core/theme/theme_folder_watcher_test.dart @@ -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 getApplicationSupportPath() async => _root; +} + +Future _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.delayed(const Duration(milliseconds: 200)); + expect(refreshCount, 0); + }); + + test('debounces rapid file events into one refresh', () async { + final refreshGate = Completer(); + 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.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.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.delayed(const Duration(milliseconds: 700)); + + expect(c.availableThemes.length, greaterThan(beforeCount)); + expect( + c.availableThemes.map((theme) => theme.id), + contains('fixture-custom-dark'), + ); + }); + }); +}