Skip to content

fix: persist custom themes across updates & auto-reload on file changes#8664

Open
alexrosepizant wants to merge 2 commits intoAppFlowy-IO:mainfrom
alexrosepizant:fix/theme-persistence-and-hot-reload
Open

fix: persist custom themes across updates & auto-reload on file changes#8664
alexrosepizant wants to merge 2 commits intoAppFlowy-IO:mainfrom
alexrosepizant:fix/theme-persistence-and-hot-reload

Conversation

@alexrosepizant
Copy link
Copy Markdown

@alexrosepizant alexrosepizant commented Apr 13, 2026

Feature Preview

Custom themes now survive app updates and automatically reload when their JSON files are modified on disk — no re-import needed. This enables seamless integration with external theming tools (e.g. matugen, wallpaper scripts).

Theme persistence: Plugins are stored under the platform's app-support directory (Application Support on macOS, AppData\Roaming on Windows, ~/.local/share on Linux) instead of Documents. The legacy Documents folder is kept as a read-only fallback.

Hot-reload: The app watches the active theme's source directory for file-system changes and re-applies the theme automatically with a 500ms debounce.

fixes #8663
fixes #5873

PR Checklist

  • My code adheres to AppFlowy's Conventions
  • I've listed at least one issue that this PR fixes in the description above.
  • I've added a test(s) to validate changes in this PR, or this PR only contains semantic changes.
  • All existing tests are passing.

Store plugins under the platform app-support directory instead of
Documents so they survive application updates. The legacy Documents
folder is kept as a read-only fallback. PluginLocationService now
supports multiple scan locations with deduplication.
…#5873)

Watch the active theme's source directory for file-system changes and
automatically re-decode and emit the updated theme with a 500ms
debounce. The watcher is restarted whenever the user switches theme
and stopped when the cubit is closed.
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 13, 2026

Reviewer's Guide

Updates plugin theme storage to use an app-support directory with a legacy Documents fallback, adds multi-location plugin discovery, and introduces filesystem-based hot-reload for custom themes plus tests covering plugin locations and theme file watching.

Sequence diagram for theme hot-reload on filesystem changes

sequenceDiagram
  actor User
  participant AppearanceSettingsCubit
  participant FlowyPluginService
  participant PluginDirectory as Plugin_source_directory
  participant FileSystem

  User->>AppearanceSettingsCubit: setTheme(themeName)
  AppearanceSettingsCubit->>AppearanceSettingsCubit: _saveAppearanceSettings()
  AppearanceSettingsCubit->>AppTheme: fromName(themeName)
  AppTheme-->>AppearanceSettingsCubit: AppTheme instance
  AppearanceSettingsCubit->>AppearanceSettingsCubit: _startWatchingTheme(appTheme)
  AppearanceSettingsCubit->>AppearanceSettingsCubit: _stopWatchingTheme()
  AppearanceSettingsCubit->>FlowyPluginService: lookup(name: themeName)
  FlowyPluginService-->>AppearanceSettingsCubit: FlowyDynamicPlugin or null
  AppearanceSettingsCubit->>PluginDirectory: plugin.source.watch(recursive: true)
  PluginDirectory-->>AppearanceSettingsCubit: FileSystemEvent stream
  loop On file change
    FileSystem-->>AppearanceSettingsCubit: FileSystemEvent
    AppearanceSettingsCubit->>AppearanceSettingsCubit: cancel existing _themeReloadDebounce
    AppearanceSettingsCubit->>AppearanceSettingsCubit: start 500ms _themeReloadDebounce
    AppearanceSettingsCubit->>AppearanceSettingsCubit: _reloadCurrentTheme()
    AppearanceSettingsCubit->>AppTheme: fromName(currentName)
    AppTheme-->>AppearanceSettingsCubit: AppTheme reloaded
    AppearanceSettingsCubit->>AppearanceSettingsCubit: emit(state.copyWith(appTheme: theme))
  end
  AppearanceSettingsCubit->>AppearanceSettingsCubit: close()
  AppearanceSettingsCubit->>AppearanceSettingsCubit: _stopWatchingTheme()
Loading

Updated class diagram for plugin and theme management

classDiagram
  class PluginLocationService {
    -Future~Directory~ _fallback
    -List~Future~Directory~~ _additionalLocations
    +PluginLocationService(fallback, additionalLocations)
    +Future~Directory~ get fallback
    +Future~Directory~ get location
    +Future~List~Directory~~ get allLocations
  }

  class FlowyPluginService {
    -static FlowyPluginService _instance
    +static FlowyPluginService get instance
    -PluginLocationService _locationService
    +FlowyPluginService._()
    +setLocation(PluginLocationService locationService)
    +Future~Iterable~Directory~~ get _targets
    +Future~FlowyDynamicPlugin?~ lookup(name)
  }

  class AppearanceSettingsCubit {
    -AppearanceSettingsPB _appearanceSettings
    -DateTimeSettingsPB _dateTimeSettings
    -StreamSubscription~FileSystemEvent~? _themeWatcher
    -Timer? _themeReloadDebounce
    -int _watchGeneration
    +AppearanceSettingsCubit(AppTheme appTheme, AppearanceSettingsPB appearanceSettings, DateTimeSettingsPB dateTimeSettings)
    +Future~void~ setTextScaleFactor(textScaleFactor)
    +Future~void~ setTheme(themeName)
    -void _startWatchingTheme(AppTheme theme)
    -Future~void~ _watchThemeByName(themeName, generation)
    -Future~void~ _reloadCurrentTheme()
    -void _stopWatchingTheme()
    +Future~void~ close()
  }

  class AppTheme {
    +String themeName
    +bool builtIn
    +static Future~AppTheme~ fromName(themeName)
  }

  class FlowyDynamicPlugin {
    +Directory source
    +static bool isPlugin(FileSystemEntity entity)
  }

  FlowyPluginService --> PluginLocationService : uses
  FlowyPluginService --> FlowyDynamicPlugin : discovers
  AppearanceSettingsCubit --> FlowyPluginService : calls lookup
  AppearanceSettingsCubit --> AppTheme : loads and reloads
  FlowyDynamicPlugin --> Directory : wraps
  PluginLocationService --> Directory : resolves locations
Loading

File-Level Changes

Change Details Files
Add filesystem-based hot-reload for non-built-in themes in the appearance settings cubit.
  • Start watching the active theme on cubit initialization and whenever the theme changes.
  • Resolve the plugin directory for the current theme via FlowyPluginService and attach a recursive Directory.watch listener.
  • Debounce file change events by 500ms and reload the current theme via AppTheme.fromName, updating cubit state if successful.
  • Track a watcher generation counter to avoid race conditions when switching themes and clean up subscriptions and timers on cubit close.
frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart
Configure plugin storage to use an app-support plugins directory with Documents as a legacy read-only location.
  • Introduce _initPluginLocation in app startup to compute the application support directory, ensure a plugins subdirectory exists, and configure FlowyPluginService with it.
  • Pass a PluginLocationService that uses the app-support plugins directory as fallback and the Documents directory as an additional scan location.
frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart
Extend plugin location service and plugin discovery to support multiple directories and deduplicate plugins.
  • Update PluginLocationService to accept additionalLocations and expose an allLocations getter that returns primary plus additional directories.
  • Change FlowyPluginService._targets to iterate over allLocations, scan for .flowy_plugin directories, skip non-existent locations, and deduplicate plugins by directory basename with first-seen precedence.
  • Make FlowyPluginService._locationService late-initialized with a default pointing at the Documents directory for backwards compatibility.
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/location_service.dart
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/plugin_service.dart
Add tests validating multi-location plugin discovery, legacy directory fallback, and theme hot-reload behavior at the file-system level.
  • Add plugin_location_test to cover PluginLocationService.allLocations behavior, plugin discovery across primary and legacy directories, deduplication, precedence, non-existent directories, and AppTheme.fromName resolution.
  • Add theme_hot_reload_test to validate that FlowyDynamicPlugin.decode re-reads modified theme JSON files and that Directory.watch emits events when theme files change.
  • Introduce test helpers to create minimal .flowy_plugin directories with stub theme JSON to support the new tests.
frontend/appflowy_flutter/test/unit_test/theme/plugin_location_test.dart
frontend/appflowy_flutter/test/unit_test/theme/theme_hot_reload_test.dart

Assessment against linked issues

Issue Objective Addressed Explanation
#5873 Allow custom themes to be reloaded automatically when their JSON/theme files on disk change, so users do not need to manually re-import the theme after external scripts regenerate it.
#5873 Ensure that on app startup the currently selected custom theme is loaded from its latest on-disk definition rather than a stale cached version, so regenerated themes are picked up without manual steps.

Possibly linked issues

  • [Bug] Custom themes are lost after app update #8663: Yes. PR fixes theme loss by using a stable plugins directory, ensuring custom themes persist across app updates.
  • #: PR adds file watchers and hot-reload for custom themes, exactly implementing the requested auto-reload behavior.
  • Theme reloading #5873: PR relocates custom theme/plugins from Documents to app-support directory with Documents as legacy fallback, matching request.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In AppearanceSettingsCubit._watchThemeByName the onError: (_) {} handler silently swallows file watching errors; consider at least logging them so failures in the watcher are visible during debugging and in production logs.
  • The plugin discovery and initialization paths (_initPluginLocation, FlowyPluginService._targets, theme watcher) use multiple synchronous filesystem calls (existsSync, createSync, listSync) on the main isolate; consider switching these to async equivalents to avoid potential UI jank on slower disks or large plugin directories.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `AppearanceSettingsCubit._watchThemeByName` the `onError: (_) {}` handler silently swallows file watching errors; consider at least logging them so failures in the watcher are visible during debugging and in production logs.
- The plugin discovery and initialization paths (`_initPluginLocation`, `FlowyPluginService._targets`, theme watcher) use multiple synchronous filesystem calls (`existsSync`, `createSync`, `listSync`) on the main isolate; consider switching these to async equivalents to avoid potential UI jank on slower disks or large plugin directories.

## Individual Comments

### Comment 1
<location path="frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart" line_range="337" />
<code_context>
+          _reloadCurrentTheme,
+        );
+      },
+      onError: (_) {},
+    );
+  }
</code_context>
<issue_to_address>
**suggestion:** Swallowing watcher errors entirely makes diagnosing theme-related issues harder.

The onError handler currently discards all exceptions. Please at least log the error (e.g., via Log.debug/Log.error) so we can diagnose cases where the theme directory becomes unreadable or the watcher fails. If log volume is a concern, consider restricting to unexpected error types or guarding behind a verbose flag.

Suggested implementation:

```
    _themeWatcher = plugin.source.watch(recursive: true).listen(
      (_) {
        _themeReloadDebounce?.cancel();
        _themeReloadDebounce = Timer(
          const Duration(milliseconds: 500),
          _reloadCurrentTheme,
        );
      },
      onError: (Object error, StackTrace stackTrace) {
        Log.error(
          'Theme watcher encountered an error while watching the theme directory.',
          error,
          stackTrace,
        );
      },

```

```
    );

```

1. Ensure the appropriate logging utility is imported at the top of this file. For example, if your project uses a `Log` utility, add:
   `import 'package:appflowy/core/log.dart';`
   or the correct path used elsewhere in the codebase.
2. If your `Log` API differs (e.g., `Log.debug` or different parameter order), adjust the call in the `onError` handler to match your existing logging conventions.
3. If your codebase prefers conditional/verbose logging for high-frequency errors, you may want to wrap `Log.error` in a verbosity check or filter specific error types before logging.
</issue_to_address>

### Comment 2
<location path="frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/plugin_service.dart" line_range="25-40" />
<code_context>
+    final locations = await _locationService.allLocations;
+    final seen = <String>{};
+    final targets = <Directory>[];
+    for (final location in locations) {
+      if (!location.existsSync()) continue;
+      for (final entity in location.listSync()) {
+        if (FlowyDynamicPlugin.isPlugin(entity)) {
+          final dir = entity as Directory;
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Listing plugin directories without per-location error handling can cause all plugin discovery to fail if a single location is problematic.

Because we now scan multiple directories, a single unreadable/permission-denied location will cause `listSync` to throw and stop discovery entirely. Please wrap the `listSync` call for each location in a try/catch, log the failure, and continue so that other valid plugin locations are still processed, especially for user-controlled `additionalLocations`.

```suggestion
    final locations = await _locationService.allLocations;
    final seen = <String>{};
    final targets = <Directory>[];
    for (final location in locations) {
      if (!location.existsSync()) continue;

      List<FileSystemEntity> entities;
      try {
        entities = location.listSync();
      } on FileSystemException catch (e, stackTrace) {
        // Log and continue so that a single problematic location
        // doesn't prevent discovery in other locations.
        print(
          'Failed to list plugin location at ${location.path}: $e\n$stackTrace',
        );
        continue;
      } catch (e, stackTrace) {
        // Defensive catch-all for unexpected errors.
        print(
          'Unexpected error while listing plugin location at ${location.path}: $e\n$stackTrace',
        );
        continue;
      }

      for (final entity in entities) {
        if (FlowyDynamicPlugin.isPlugin(entity)) {
          final dir = entity as Directory;
          final name = p.basename(dir.path);
          if (seen.add(name)) {
            targets.add(dir);
          }
        }
      }
    }
    return targets;
```
</issue_to_address>

### Comment 3
<location path="frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/location_service.dart" line_range="20" />
<code_context>
+  /// Returns the primary and all additional scan locations.
+  Future<List<Directory>> get allLocations async {
+    final primary = await fallback;
+    final extras = await Future.wait(_additionalLocations);
+    return [primary, ...extras];
+  }
</code_context>
<issue_to_address>
**suggestion:** A failure in one additional location prevents using any of the others.

Because `Future.wait` fails if any future throws (e.g. a location provider not supported on some platforms), one bad additional location will break `allLocations` entirely. If you want best-effort behavior, consider `Future.wait(_additionalLocations, eagerError: false)` and filtering out failures, or awaiting each future in a try/catch so failures in extras don’t prevent returning the primary and other valid locations.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

_reloadCurrentTheme,
);
},
onError: (_) {},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Swallowing watcher errors entirely makes diagnosing theme-related issues harder.

The onError handler currently discards all exceptions. Please at least log the error (e.g., via Log.debug/Log.error) so we can diagnose cases where the theme directory becomes unreadable or the watcher fails. If log volume is a concern, consider restricting to unexpected error types or guarding behind a verbose flag.

Suggested implementation:

    _themeWatcher = plugin.source.watch(recursive: true).listen(
      (_) {
        _themeReloadDebounce?.cancel();
        _themeReloadDebounce = Timer(
          const Duration(milliseconds: 500),
          _reloadCurrentTheme,
        );
      },
      onError: (Object error, StackTrace stackTrace) {
        Log.error(
          'Theme watcher encountered an error while watching the theme directory.',
          error,
          stackTrace,
        );
      },

    );

  1. Ensure the appropriate logging utility is imported at the top of this file. For example, if your project uses a Log utility, add:
    import 'package:appflowy/core/log.dart';
    or the correct path used elsewhere in the codebase.
  2. If your Log API differs (e.g., Log.debug or different parameter order), adjust the call in the onError handler to match your existing logging conventions.
  3. If your codebase prefers conditional/verbose logging for high-frequency errors, you may want to wrap Log.error in a verbosity check or filter specific error types before logging.

Comment on lines +25 to +40
final locations = await _locationService.allLocations;
final seen = <String>{};
final targets = <Directory>[];
for (final location in locations) {
if (!location.existsSync()) continue;
for (final entity in location.listSync()) {
if (FlowyDynamicPlugin.isPlugin(entity)) {
final dir = entity as Directory;
final name = p.basename(dir.path);
if (seen.add(name)) {
targets.add(dir);
}
}
}
}
return targets;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Listing plugin directories without per-location error handling can cause all plugin discovery to fail if a single location is problematic.

Because we now scan multiple directories, a single unreadable/permission-denied location will cause listSync to throw and stop discovery entirely. Please wrap the listSync call for each location in a try/catch, log the failure, and continue so that other valid plugin locations are still processed, especially for user-controlled additionalLocations.

Suggested change
final locations = await _locationService.allLocations;
final seen = <String>{};
final targets = <Directory>[];
for (final location in locations) {
if (!location.existsSync()) continue;
for (final entity in location.listSync()) {
if (FlowyDynamicPlugin.isPlugin(entity)) {
final dir = entity as Directory;
final name = p.basename(dir.path);
if (seen.add(name)) {
targets.add(dir);
}
}
}
}
return targets;
final locations = await _locationService.allLocations;
final seen = <String>{};
final targets = <Directory>[];
for (final location in locations) {
if (!location.existsSync()) continue;
List<FileSystemEntity> entities;
try {
entities = location.listSync();
} on FileSystemException catch (e, stackTrace) {
// Log and continue so that a single problematic location
// doesn't prevent discovery in other locations.
print(
'Failed to list plugin location at ${location.path}: $e\n$stackTrace',
);
continue;
} catch (e, stackTrace) {
// Defensive catch-all for unexpected errors.
print(
'Unexpected error while listing plugin location at ${location.path}: $e\n$stackTrace',
);
continue;
}
for (final entity in entities) {
if (FlowyDynamicPlugin.isPlugin(entity)) {
final dir = entity as Directory;
final name = p.basename(dir.path);
if (seen.add(name)) {
targets.add(dir);
}
}
}
}
return targets;

/// Returns the primary and all additional scan locations.
Future<List<Directory>> get allLocations async {
final primary = await fallback;
final extras = await Future.wait(_additionalLocations);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: A failure in one additional location prevents using any of the others.

Because Future.wait fails if any future throws (e.g. a location provider not supported on some platforms), one bad additional location will break allLocations entirely. If you want best-effort behavior, consider Future.wait(_additionalLocations, eagerError: false) and filtering out failures, or awaiting each future in a try/catch so failures in extras don’t prevent returning the primary and other valid locations.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 13, 2026

CLA assistant check
All committers have signed the CLA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Custom themes are lost after app update Theme reloading

2 participants