From 5c6674d739e578f9fff37504fb9cd6e6c22f599d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 9 Jun 2026 17:36:04 +0200 Subject: [PATCH] persist the chosen theme per repo across loads (T-293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemeController.select() applied live but never persisted, so every restart reset to the first bundled theme. Add wireThemePersistence (facade): on theme change write app.theme (global default) + project.theme (the open repo's .clide/settings.yaml); on settings change — notably when a repo opens and its project values load — restore the most specific saved theme (project then app). The name encodes the -hc variant so high-contrast persists; an unknown/removed theme is ignored so a stale value can't wedge startup. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 + lib/kernel/src/facade.dart | 2 + lib/kernel/src/theme/theme_persistence.dart | 40 ++++++++ test/kernel/src/theme_persistence_test.dart | 106 ++++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 lib/kernel/src/theme/theme_persistence.dart create mode 100644 test/kernel/src/theme_persistence_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c920ef..b5613964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- **The chosen theme now persists across restarts**, per repo. Picking a theme + (status-bar switcher or Settings) writes it to the repo's + `.clide/settings.yaml` (and a global default), and reopening the repo restores + it — including the high-contrast variant. A removed theme falls back to the + default instead of resetting silently. (T-293) - Folded activity and agent-run cards in the Claude conversation now use the same bottom spacing as the prose cards around them, instead of sitting cramped 3px below the next card. (T-282) diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 18f36c1f..bd359b3c 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -5,6 +5,7 @@ import 'package:clide/kernel/src/clipboard.dart'; import 'package:clide/kernel/src/commands/keybindings.dart'; import 'package:clide/kernel/src/keymap/keymap_service.dart'; import 'package:clide/kernel/src/text_zoom.dart'; +import 'package:clide/kernel/src/theme/theme_persistence.dart'; import 'package:clide/kernel/src/toast.dart'; import 'package:clide/kernel/src/commands/palette.dart'; import 'package:clide/kernel/src/commands/registry.dart'; @@ -158,6 +159,7 @@ class KernelServices { } final theme = ThemeController(bundled: bundledThemes); + wireThemePersistence(theme, settings); final panels = PanelRegistry(); final arrangement = LayoutArrangement(); final commands = CommandRegistry(); diff --git a/lib/kernel/src/theme/theme_persistence.dart b/lib/kernel/src/theme/theme_persistence.dart new file mode 100644 index 00000000..62eb9625 --- /dev/null +++ b/lib/kernel/src/theme/theme_persistence.dart @@ -0,0 +1,40 @@ +/// Persist the chosen theme and restore it across loads (T-293). +/// +/// clide runs a single live [ThemeController]; this bridges it to the +/// [SettingsStore] so a choice survives a restart. On every theme change we +/// write `app.theme` (the global last-used default) and, when a repo is open, +/// `project.theme` in that repo's `.clide/settings.yaml`. Whenever the settings +/// change — notably when a repo opens and its project values load — we restore +/// the most specific saved theme: `project.theme`, else the global `app.theme`. +/// The current name already encodes the high-contrast variant (`-hc`), so +/// the toggle persists for free. An unknown/removed theme name is ignored, so a +/// stale value can't wedge startup. +library; + +import 'dart:async'; + +import 'package:clide/kernel/src/settings.dart'; +import 'package:clide/kernel/src/theme/controller.dart'; + +void wireThemePersistence(ThemeController theme, SettingsStore settings) { + theme.addListener(() { + final name = theme.currentName; + // Global default — lets a brand-new / unthemed repo inherit the last choice. + if (settings.get('app.theme') != name) { + unawaited(settings.set('app.theme', name)); + } + // Per-repo, when one is open. + if (settings.projectDir != null && settings.get('project.theme') != name) { + unawaited(settings.set('project.theme', name)); + } + }); + + void restore() { + final saved = settings.get('project.theme') ?? settings.get('app.theme'); + if (saved == null || saved == theme.currentName) return; + if (theme.available.any((d) => d.name == saved)) theme.select(saved); + } + + settings.addListener(restore); + restore(); // apply the global default already loaded at boot +} diff --git a/test/kernel/src/theme_persistence_test.dart b/test/kernel/src/theme_persistence_test.dart new file mode 100644 index 00000000..0710be6a --- /dev/null +++ b/test/kernel/src/theme_persistence_test.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/kernel/src/theme/theme_persistence.dart'; +import 'package:flutter/widgets.dart' show Color; +import 'package:flutter_test/flutter_test.dart'; + +ThemeDefinition _def(String name) => ThemeDefinition( + name: name, + displayName: name, + dark: true, + palette: Palette(const { + 'primary': Color(0xFF00A3D2), + 'accent': Color(0xFFFA5F8B), + 'background': Color(0xFF21262F), + 'surface': Color(0xFF393E48), + 'panel': Color(0xFF292E38), + 'foreground': Color(0xFFE2E8F5), + 'success': Color(0xFF00AB9A), + 'warning': Color(0xFFD08447), + 'error': Color(0xFFF06C6F), + }), + ); + +void main() { + group('wireThemePersistence (T-293)', () { + late Directory appTmp; + late Directory repoTmp; + late SettingsStore settings; + late ThemeController theme; + + setUp(() async { + appTmp = await Directory.systemTemp.createTemp('clide_theme_app_'); + repoTmp = await Directory.systemTemp.createTemp('clide_theme_repo_'); + settings = SettingsStore(appDir: appTmp); + await settings.load(); + theme = ThemeController(bundled: [_def('summer-night'), _def('forest'), _def('paper'), _def('paper-hc')]); + wireThemePersistence(theme, settings); + }); + + tearDown(() async { + await pumpEventQueue(); // flush any in-flight unawaited set() file writes + settings.dispose(); + theme.dispose(); + for (final d in [appTmp, repoTmp]) { + if (await d.exists()) { + try { + await d.delete(recursive: true); + } catch (_) {} + } + } + }); + + test('selecting with no repo open persists app.theme (global default)', () async { + theme.select('forest'); + expect(settings.get('app.theme'), 'forest'); + expect(settings.get('project.theme'), isNull); + }); + + test('selecting with a repo open persists project.theme into that repo', () async { + await settings.setProjectDir(repoTmp); + theme.select('forest'); + expect(settings.get('project.theme'), 'forest'); + await pumpEventQueue(); // let the unawaited file write flush + expect(await File('${repoTmp.path}/.clide/settings.yaml').exists(), isTrue); + }); + + test('the high-contrast variant persists (the name encodes -hc)', () async { + theme.select('paper-hc'); + expect(settings.get('app.theme'), 'paper-hc'); + }); + + test('opening a repo restores project.theme over the global app.theme', () async { + await settings.set('app.theme', 'forest'); + await File('${repoTmp.path}/.clide/settings.yaml').create(recursive: true); + await File('${repoTmp.path}/.clide/settings.yaml').writeAsString('project:\n theme: paper\n'); + + await settings.setProjectDir(repoTmp); // loads project values + notifies → restore + + expect(theme.currentName, 'paper'); + }); + + test('restores the global app.theme at wiring time (boot)', () async { + await File('${appTmp.path}/settings.yaml').writeAsString('app:\n theme: forest\n'); + final s2 = SettingsStore(appDir: appTmp); + await s2.load(); + final t2 = ThemeController(bundled: [_def('summer-night'), _def('forest')]); + expect(t2.currentName, 'summer-night'); // bundled.first before wiring + + wireThemePersistence(t2, s2); + + expect(t2.currentName, 'forest'); // restored from app.theme + s2.dispose(); + t2.dispose(); + }); + + test('an unknown saved theme is ignored (no throw, keeps current)', () async { + await File('${repoTmp.path}/.clide/settings.yaml').create(recursive: true); + await File('${repoTmp.path}/.clide/settings.yaml').writeAsString('project:\n theme: gone-theme\n'); + + await settings.setProjectDir(repoTmp); + + expect(theme.currentName, 'summer-night'); // unchanged + }); + }); +}