From 0162d7a13faf5f9564d3be73dad0b32a4bef2d06 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 07:15:49 +0300 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20link=200.4.3=20milestone=20issues?= =?UTF-8?q?=20#159=E2=80=93#163=20in=20planning=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire epic and TP-F1–F4 GitHub issue numbers into planned-0.4.3, theme-parser-github-issues, and roadmap. --- docs/planned-0.4.3.md | 22 +++++++++++----------- docs/roadmap.md | 2 +- docs/theme-parser-github-issues.md | 10 +++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/planned-0.4.3.md b/docs/planned-0.4.3.md index de3a7e2c..13b9425e 100644 --- a/docs/planned-0.4.3.md +++ b/docs/planned-0.4.3.md @@ -1,6 +1,6 @@ # Planned release 0.4.3 — theme and extensions follow-ups -**Status:** planning (not started). +**Status:** planning (GitHub milestone [**0.4.3**](https://github.com/QueryaHub/Querya-Desktop/milestone/2), epic **#159**). **Depends on:** **0.4.2** custom theme registry (TP-01–TP-30, shipped). This document captures work intentionally deferred from the first custom-theme pass. @@ -9,22 +9,22 @@ See also [theme-parser-github-issues.md](theme-parser-github-issues.md) (TP-F1 ## Theme folder and discovery -| ID | Scope | Summary | -|----|--------|---------| -| **TP-F1** | `theme`, `filesystem` | **File watcher** for `{appSupport}/themes/` — auto-refresh the registry when files are added, removed, or renamed. Deferred: OS-specific watcher APIs and app lifecycle edge cases. | +| ID | Issue | Scope | Summary | +|----|-------|--------|---------| +| **TP-F1** | [#160](https://github.com/QueryaHub/Querya-Desktop/issues/160) | `theme`, `filesystem` | **File watcher** for `{appSupport}/themes/` — auto-refresh the registry when files are added, removed, or renamed. Deferred: OS-specific watcher APIs and app lifecycle edge cases. | ## Theme distribution and metadata -| ID | Scope | Summary | -|----|--------|---------| -| **TP-F2** | `theme`, `marketplace` | **Marketplace metadata** on `ThemeDefinition` / manifests — preview image, tags, homepage, license, author. Prerequisite for listing themes in a future Extensions UI. | -| **TP-F4** | `theme`, `network` | **Remote theme install** — download from URL with checksum/trust policy. Requires security review (HTTPS, signatures, user consent). | +| ID | Issue | Scope | Summary | +|----|-------|--------|---------| +| **TP-F2** | [#161](https://github.com/QueryaHub/Querya-Desktop/issues/161) | `theme`, `marketplace` | **Marketplace metadata** on `ThemeDefinition` / manifests — preview image, tags, homepage, license, author. Prerequisite for listing themes in a future Extensions UI. | +| **TP-F4** | [#163](https://github.com/QueryaHub/Querya-Desktop/issues/163) | `theme`, `network` | **Remote theme install** — download from URL with checksum/trust policy. Requires security review (HTTPS, signatures, user consent). | ## Authoring UX -| ID | Scope | Summary | -|----|--------|---------| -| **TP-F3** | `theme`, `settings` | **Visual theme editor** in Preferences — tweak colors, export `querya.theme.v1`. Larger than parser/import; likely multiple PRs. | +| ID | Issue | Scope | Summary | +|----|-------|--------|---------| +| **TP-F3** | [#162](https://github.com/QueryaHub/Querya-Desktop/issues/162) | `theme`, `settings` | **Visual theme editor** in Preferences — tweak colors, export `querya.theme.v1`. Larger than parser/import; likely multiple PRs. | ## Extensions marketplace (optional overlap) diff --git a/docs/roadmap.md b/docs/roadmap.md index a62cc6fb..88049615 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,7 +8,7 @@ Living document for planned work. Not a commitment order; adjust as priorities c highlighting, P0 workbench migration, Preferences, tests, docs — [theme.md](theme.md). - **Shipped in 0.4.1 ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93)):** UI performance — virtual result grid, lazy connection tree, decoupled scale preview, stats polling, MySQL stats dashboard, local `docker/` dev stack — [perf-baseline.md](perf-baseline.md). - **Shipped in 0.4.2 (TP-01–TP-30, #96–#125):** custom theme registry — `querya.theme.v1` + VS Code JSON/JSONC scan, Theme picker (50+), import/refresh, built-in Cyberpunk Neon asset, startup fallback, window chrome sync — [theme-custom-json.md](theme-custom-json.md), [theme-import.md](theme-import.md). -- **Planned 0.4.3:** theme follow-ups (file watcher, marketplace metadata, visual editor, remote install) — [planned-0.4.3.md](planned-0.4.3.md). +- **Planned 0.4.3:** theme follow-ups (file watcher, marketplace metadata, visual editor, remote install) — [planned-0.4.3.md](planned-0.4.3.md), epic [#159](https://github.com/QueryaHub/Querya-Desktop/issues/159), milestone [0.4.3](https://github.com/QueryaHub/Querya-Desktop/milestone/2). - **Optional:** Preferences → **Animate theme changes** (off by default). - **Later:** P2 Mongo/Redis token colors; `re_editor` if perf gap; LSP epic per [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). diff --git a/docs/theme-parser-github-issues.md b/docs/theme-parser-github-issues.md index 6166d340..dbe4add9 100644 --- a/docs/theme-parser-github-issues.md +++ b/docs/theme-parser-github-issues.md @@ -1575,21 +1575,21 @@ Add QA checklist: ## Optional follow-up issues These are intentionally out of the first implementation pass (**shipped in 0.4.2**). -**Target milestone: 0.4.3** — see [planned-0.4.3.md](planned-0.4.3.md). +**Target milestone: 0.4.3** ([milestone](https://github.com/QueryaHub/Querya-Desktop/milestone/2), epic **#159**) — see [planned-0.4.3.md](planned-0.4.3.md). -### TP-F1 — File watcher for user themes folder +### TP-F1 — File watcher for user themes folder ([#160](https://github.com/QueryaHub/Querya-Desktop/issues/160)) Use a filesystem watcher to auto-refresh themes after files are added/removed. Keep as follow-up because watchers differ by OS and can introduce lifecycle bugs. -### TP-F2 — Theme marketplace metadata +### TP-F2 — Theme marketplace metadata ([#161](https://github.com/QueryaHub/Querya-Desktop/issues/161)) Support metadata fields like preview image, tags, homepage, license. Useful only after custom theme format is stable. -### TP-F3 — Visual theme editor +### TP-F3 — Visual theme editor ([#162](https://github.com/QueryaHub/Querya-Desktop/issues/162)) Allow editing theme colors in Preferences and export to `querya.theme.v1`. This is larger than parser/import support. -### TP-F4 — Remote theme install +### TP-F4 — Remote theme install ([#163](https://github.com/QueryaHub/Querya-Desktop/issues/163)) Install theme from URL. Requires network, trust/security decisions, and probably signature/checksum policy. From 0e1f1ce8dfa0a847cafc56b5471e82fa4e59fe84 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 07:36:57 +0300 Subject: [PATCH 2/8] 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'), + ); + }); + }); +} From 1b2c9952f9cb11a518fbb8b858aa55fe417fd26f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 07:43:34 +0300 Subject: [PATCH 3/8] feat(theme): add marketplace metadata to theme registry Extend querya.theme.v1 with homepage, license, preview, and tags; expose ThemeMetadata on ThemeDefinition, show author/tags in the theme picker, and add ExtensionManifest stub aligned with market-tech.md. Closes #161 --- docs/theme-custom-json.md | 10 +- lib/core/market/extension_manifest.dart | 65 ++++++++++ .../theme/parser/querya_theme_manifest.dart | 42 ++++++- lib/core/theme/theme_definition.dart | 8 +- lib/core/theme/theme_metadata.dart | 112 ++++++++++++++++++ lib/core/theme/theme_registry_service.dart | 2 + .../settings/theme_picker_button.dart | 79 ++++++++++-- test/core/market/extension_manifest_test.dart | 64 ++++++++++ .../parser/querya_theme_manifest_test.dart | 12 ++ test/core/theme/theme_metadata_test.dart | 53 +++++++++ .../theme/theme_registry_service_test.dart | 18 +++ .../settings/theme_picker_button_test.dart | 23 ++++ .../themes/querya_custom_metadata.json | 19 +++ 13 files changed, 492 insertions(+), 15 deletions(-) create mode 100644 lib/core/market/extension_manifest.dart create mode 100644 lib/core/theme/theme_metadata.dart create mode 100644 test/core/market/extension_manifest_test.dart create mode 100644 test/core/theme/theme_metadata_test.dart create mode 100644 test/fixtures/themes/querya_custom_metadata.json diff --git a/docs/theme-custom-json.md b/docs/theme-custom-json.md index 924632e5..e744ff13 100644 --- a/docs/theme-custom-json.md +++ b/docs/theme-custom-json.md @@ -44,6 +44,10 @@ registry services described in | `description` | string | Short summary for theme picker / marketplace. | | `author` | string | Author or org name. | | `version` | string | Theme package version (informational). | +| `homepage` | string | Project or theme homepage URL. | +| `license` | string | SPDX or plain-text license id (e.g. `MIT`). | +| `preview` | string | Preview image URL or relative asset path (stored only; not fetched by the registry). | +| `tags` | string array | Search/filter labels (e.g. `dark`, `neon`). | Unknown root fields are ignored. In debug builds the parser may log skipped keys. @@ -274,9 +278,9 @@ derive shadcn tokens from your palette or leave `{}` to use preset defaults. 2. Copy it into the app support **themes folder** (`{appSupport}/themes/`). See [theme-import.md](theme-import.md) for platform-specific paths and the **Open themes folder** button in Preferences. -3. In **Preferences → Appearance**, click **Refresh themes** and select your theme. +3. In **Preferences → Appearance**, click **Refresh themes** (or wait for the folder watcher) and select your theme. -The registry scans `.json` and `.jsonc` files on refresh; there is no live folder watcher. +The registry scans `.json` and `.jsonc` files on refresh and when the themes folder changes. Invalid files are skipped (logged in debug builds). Required fields: `schema`, `id`, `name`, `type`, `shadcn_colors`, `editor_colors` (the color maps may be empty `{}`). @@ -292,7 +296,7 @@ manual installation. | *Selected theme failed to load. Using Querya Dark.* | Persisted theme id points to a missing or broken file | Restore the file under `themes/`, or pick another theme in Preferences. Settings are kept so you can fix the file and **Refresh themes**. | | Colors look wrong or default | Invalid hex for a key | Invalid optional colors are **skipped** (preset fallback used). Check `#RRGGBB` / `#RRGGBBAA` formats in [Color string formats](#color-string-formats). | | Duplicate theme names in picker | Same `id` with different content imported twice | Registry suffixes ids (`my-theme-2`). Rename files or ids to avoid confusion. | -| Dropped file not visible | No folder watcher | Use **Refresh themes** after copying into `themes/` (restart not required). | +| Dropped file not visible | Watcher debounce / invalid file | Wait a moment after copy, or click **Refresh themes**; validate required fields and `.json`/`.jsonc` extension. | ## Related docs diff --git a/lib/core/market/extension_manifest.dart b/lib/core/market/extension_manifest.dart new file mode 100644 index 00000000..be1edec8 --- /dev/null +++ b/lib/core/market/extension_manifest.dart @@ -0,0 +1,65 @@ +import '../theme/theme_definition.dart'; + +/// Marketplace listing model (future `MarketplaceClient` response shape). +/// +/// See [docs/market-tech.md](https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/market-tech.md). +class ExtensionManifest { + const ExtensionManifest({ + required this.id, + required this.name, + required this.type, + required this.version, + required this.downloadUrl, + required this.sha256Checksum, + this.author, + this.description, + this.homepage, + this.license, + this.preview, + this.tags = const [], + }); + + static const typeTheme = 'theme'; + + final String id; + final String name; + final String type; + final String version; + final String downloadUrl; + final String sha256Checksum; + final String? author; + final String? description; + final String? homepage; + final String? license; + final String? preview; + final List tags; + + /// Maps a registry [ThemeDefinition] into marketplace field names. + /// + /// [downloadUrl] and [sha256Checksum] are required for remote install (TP-F4); + /// pass empty strings when building a local-only listing stub. + factory ExtensionManifest.fromThemeDefinition( + ThemeDefinition definition, { + String downloadUrl = '', + String sha256Checksum = '', + String type = typeTheme, + }) { + final metadata = definition.metadata; + return ExtensionManifest( + id: definition.id, + name: definition.name, + type: type, + version: metadata?.version ?? '0.0.0', + downloadUrl: downloadUrl, + sha256Checksum: sha256Checksum.isNotEmpty + ? sha256Checksum + : (definition.contentHash ?? ''), + author: metadata?.author, + description: metadata?.description, + homepage: metadata?.homepage, + license: metadata?.license, + preview: metadata?.preview, + tags: metadata?.tags ?? const [], + ); + } +} diff --git a/lib/core/theme/parser/querya_theme_manifest.dart b/lib/core/theme/parser/querya_theme_manifest.dart index becf6bc0..2fa4b778 100644 --- a/lib/core/theme/parser/querya_theme_manifest.dart +++ b/lib/core/theme/parser/querya_theme_manifest.dart @@ -23,6 +23,10 @@ class QueryaThemeManifest { this.description, this.author, this.version, + this.homepage, + this.license, + this.preview, + this.tags = const [], }); final String schema; @@ -35,6 +39,10 @@ class QueryaThemeManifest { final String? description; final String? author; final String? version; + final String? homepage; + final String? license; + final String? preview; + final List tags; bool get isDark => type == QueryaThemeType.dark; bool get isLight => type == QueryaThemeType.light; @@ -93,9 +101,25 @@ class QueryaThemeManifest { description: _optionalString(json['description']), author: _optionalString(json['author']), version: _optionalString(json['version']), + homepage: _optionalString(json['homepage']), + license: _optionalString(json['license']), + preview: _optionalString(json['preview']), + tags: _parseTags(json['tags']), ); } + static List _parseTags(Object? raw) { + if (raw is! List) return const []; + + final tags = []; + for (final item in raw) { + if (item is! String) continue; + final trimmed = item.trim(); + if (trimmed.isNotEmpty) tags.add(trimmed); + } + return List.unmodifiable(tags); + } + static String _requiredString(Map json, String key) { if (!json.containsKey(key)) { throw QueryaThemeManifestParseException('Missing required field "$key"'); @@ -156,7 +180,11 @@ class QueryaThemeManifest { _listEquals(tokenColors, other.tokenColors) && description == other.description && author == other.author && - version == other.version; + version == other.version && + homepage == other.homepage && + license == other.license && + preview == other.preview && + _stringListEquals(tags, other.tags); @override int get hashCode => Object.hash( @@ -170,6 +198,10 @@ class QueryaThemeManifest { description, author, version, + homepage, + license, + preview, + Object.hashAll(tags), ); static bool _mapEquals(Map a, Map b) { @@ -187,6 +219,14 @@ class QueryaThemeManifest { } return true; } + + static bool _stringListEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } } class QueryaThemeManifestParseException implements Exception { diff --git a/lib/core/theme/theme_definition.dart b/lib/core/theme/theme_definition.dart index d9f5675c..edb94d13 100644 --- a/lib/core/theme/theme_definition.dart +++ b/lib/core/theme/theme_definition.dart @@ -1,3 +1,5 @@ +import 'theme_metadata.dart'; + enum ThemeSource { builtin, imported, @@ -21,6 +23,7 @@ class ThemeDefinition { this.path, this.lastModified, this.contentHash, + this.metadata, }); final String id; @@ -31,6 +34,7 @@ class ThemeDefinition { final String? path; final DateTime? lastModified; final String? contentHash; + final ThemeMetadata? metadata; bool get isFileBacked => source == ThemeSource.filesystem || @@ -51,7 +55,8 @@ class ThemeDefinition { isDark == other.isDark && path == other.path && lastModified == other.lastModified && - contentHash == other.contentHash; + contentHash == other.contentHash && + metadata == other.metadata; @override int get hashCode => Object.hash( @@ -63,5 +68,6 @@ class ThemeDefinition { path, lastModified, contentHash, + metadata, ); } diff --git a/lib/core/theme/theme_metadata.dart b/lib/core/theme/theme_metadata.dart new file mode 100644 index 00000000..8018dc37 --- /dev/null +++ b/lib/core/theme/theme_metadata.dart @@ -0,0 +1,112 @@ +/// Marketplace-oriented metadata carried on [ThemeDefinition]. +class ThemeMetadata { + const ThemeMetadata({ + this.description, + this.author, + this.version, + this.homepage, + this.license, + this.preview, + this.tags = const [], + }); + + final String? description; + final String? author; + final String? version; + final String? homepage; + final String? license; + + /// Relative asset path or HTTPS URL string (not fetched by the registry). + final String? preview; + final List tags; + + bool get hasPickerSubtitle => + (author != null && author!.isNotEmpty) || tags.isNotEmpty; + + /// Subtitle for theme picker rows: author, else comma-separated tags. + String? get pickerSubtitle { + final authorLabel = author?.trim(); + if (authorLabel != null && authorLabel.isNotEmpty) return authorLabel; + if (tags.isEmpty) return null; + return tags.join(', '); + } + + static ThemeMetadata? fromQueryaJson(Map json) { + final description = _optionalString(json['description']); + final author = _optionalString(json['author']); + final version = _optionalString(json['version']); + final homepage = _optionalString(json['homepage']); + final license = _optionalString(json['license']); + final preview = _optionalString(json['preview']); + final tags = _parseTags(json['tags']); + + if (description == null && + author == null && + version == null && + homepage == null && + license == null && + preview == null && + tags.isEmpty) { + return null; + } + + return ThemeMetadata( + description: description, + author: author, + version: version, + homepage: homepage, + license: license, + preview: preview, + tags: tags, + ); + } + + static String? _optionalString(Object? value) { + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + static List _parseTags(Object? raw) { + if (raw is! List) return const []; + + final tags = []; + for (final item in raw) { + if (item is! String) continue; + final trimmed = item.trim(); + if (trimmed.isNotEmpty) tags.add(trimmed); + } + return List.unmodifiable(tags); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ThemeMetadata && + description == other.description && + author == other.author && + version == other.version && + homepage == other.homepage && + license == other.license && + preview == other.preview && + _listEquals(tags, other.tags); + + @override + int get hashCode => Object.hash( + description, + author, + version, + homepage, + license, + preview, + Object.hashAll(tags), + ); + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index 43a65efd..431fd58b 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -16,6 +16,7 @@ import 'querya_theme.dart'; import 'theme_definition.dart'; import 'theme_import_service.dart'; import 'theme_load_result.dart'; +import 'theme_metadata.dart'; import 'theme_paths.dart'; /// Scans theme directories and exposes lightweight [ThemeDefinition] metadata. @@ -491,6 +492,7 @@ class ThemeRegistryService { path: path, lastModified: lastModified, contentHash: contentHash, + metadata: ThemeMetadata.fromQueryaJson(json), ); } diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index 7541cc4d..1e108cd6 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -55,7 +55,13 @@ List filterThemeDefinitions( theme.name.toLowerCase().contains(normalized) || theme.id.toLowerCase().contains(normalized) || theme.source.name.toLowerCase().contains(normalized) || - _sourceBadgeLabel(theme.source).toLowerCase().contains(normalized), + _sourceBadgeLabel(theme.source).toLowerCase().contains(normalized) || + (theme.metadata?.author?.toLowerCase().contains(normalized) ?? + false) || + (theme.metadata?.tags.any( + (tag) => tag.toLowerCase().contains(normalized), + ) ?? + false), ) .toList(growable: false); } @@ -515,15 +521,10 @@ class _ThemePickerRowState extends material.State<_ThemePickerRow> { ), material.SizedBox(width: context.scaled(6)), material.Expanded( - child: material.Text( - widget.definition.name, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: QueryaDropdownTokens.menuItemTextStyle( - context, - cs.popoverForeground, - selected: widget.selected, - ), + child: _ThemePickerRowTitle( + definition: widget.definition, + colorScheme: cs, + selected: widget.selected, ), ), material.SizedBox(width: context.scaled(6)), @@ -542,6 +543,64 @@ class _ThemePickerRowState extends material.State<_ThemePickerRow> { } } +class _ThemePickerRowTitle extends material.StatelessWidget { + const _ThemePickerRowTitle({ + required this.definition, + required this.colorScheme, + required this.selected, + }); + + final ThemeDefinition definition; + final ColorScheme colorScheme; + final bool selected; + + @override + material.Widget build(material.BuildContext context) { + final cs = colorScheme; + final subtitle = definition.metadata?.pickerSubtitle; + + if (subtitle == null) { + return material.Text( + definition.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.menuItemTextStyle( + context, + cs.popoverForeground, + selected: selected, + ), + ); + } + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + definition.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.menuItemTextStyle( + context, + cs.popoverForeground, + selected: selected, + ), + ), + material.Text( + subtitle, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: context.scaled(11), + height: 1.2, + color: cs.mutedForeground, + ), + ), + ], + ); + } +} + class _SourceBadge extends material.StatelessWidget { const _SourceBadge({ required this.label, diff --git a/test/core/market/extension_manifest_test.dart b/test/core/market/extension_manifest_test.dart new file mode 100644 index 00000000..52655997 --- /dev/null +++ b/test/core/market/extension_manifest_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/market/extension_manifest.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/core/theme/theme_metadata.dart'; + +void main() { + group('ExtensionManifest', () { + test('fromThemeDefinition maps registry metadata field names', () { + const definition = ThemeDefinition( + id: 'cyberpunk-neon', + name: 'Cyberpunk Neon', + source: ThemeSource.filesystem, + format: ThemeFormat.queryaCustom, + isDark: true, + contentHash: 'abc12345', + metadata: ThemeMetadata( + author: 'QueryaHub', + description: 'Neon theme', + version: '1.0.0', + homepage: 'https://example.com', + license: 'MIT', + preview: 'https://cdn.example/preview.png', + tags: ['neon'], + ), + ); + + final manifest = ExtensionManifest.fromThemeDefinition( + definition, + downloadUrl: 'https://cdn.example/cyberpunk-neon.json', + sha256Checksum: 'deadbeef', + ); + + expect(manifest.id, 'cyberpunk-neon'); + expect(manifest.name, 'Cyberpunk Neon'); + expect(manifest.type, ExtensionManifest.typeTheme); + expect(manifest.version, '1.0.0'); + expect(manifest.downloadUrl, 'https://cdn.example/cyberpunk-neon.json'); + expect(manifest.sha256Checksum, 'deadbeef'); + expect(manifest.author, 'QueryaHub'); + expect(manifest.description, 'Neon theme'); + expect(manifest.homepage, 'https://example.com'); + expect(manifest.license, 'MIT'); + expect(manifest.preview, 'https://cdn.example/preview.png'); + expect(manifest.tags, ['neon']); + }); + + test('fromThemeDefinition falls back to content hash and 0.0.0 version', () { + const definition = ThemeDefinition( + id: 'minimal', + name: 'Minimal', + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: true, + contentHash: 'ff00aa11', + ); + + final manifest = ExtensionManifest.fromThemeDefinition(definition); + + expect(manifest.version, '0.0.0'); + expect(manifest.sha256Checksum, 'ff00aa11'); + expect(manifest.downloadUrl, isEmpty); + }); + }); +} diff --git a/test/core/theme/parser/querya_theme_manifest_test.dart b/test/core/theme/parser/querya_theme_manifest_test.dart index 832b303b..e073dbaf 100644 --- a/test/core/theme/parser/querya_theme_manifest_test.dart +++ b/test/core/theme/parser/querya_theme_manifest_test.dart @@ -6,6 +6,18 @@ import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; void main() { group('QueryaThemeManifest', () { + test('parses marketplace metadata fields', () { + final raw = File('test/fixtures/themes/querya_custom_metadata.json') + .readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(manifest.homepage, 'https://github.com/QueryaHub/Querya-Desktop'); + expect(manifest.license, 'MIT'); + expect(manifest.preview, + 'https://example.com/previews/fixture-custom-metadata.png'); + expect(manifest.tags, ['cyberpunk', 'neon', 'dark']); + }); + test('parses full dark fixture', () { final raw = File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); diff --git a/test/core/theme/theme_metadata_test.dart b/test/core/theme/theme_metadata_test.dart new file mode 100644 index 00000000..db9e8520 --- /dev/null +++ b/test/core/theme/theme_metadata_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/theme_metadata.dart'; + +void main() { + group('ThemeMetadata', () { + test('fromQueryaJson returns null when no metadata fields present', () { + expect( + ThemeMetadata.fromQueryaJson(const { + 'schema': 'querya.theme.v1', + 'id': 'bare', + 'name': 'Bare', + }), + isNull, + ); + }); + + test('fromQueryaJson parses marketplace fields', () { + final metadata = ThemeMetadata.fromQueryaJson({ + 'description': ' Neon preset ', + 'author': ' QueryaHub ', + 'version': '1.2.3', + 'homepage': 'https://querya.example/themes/neon', + 'license': 'MIT', + 'preview': 'https://cdn.example/preview.png', + 'tags': [' neon ', 'dark', '', 42, 'cyberpunk'], + }); + + expect(metadata, isNotNull); + expect(metadata!.description, 'Neon preset'); + expect(metadata.author, 'QueryaHub'); + expect(metadata.version, '1.2.3'); + expect(metadata.homepage, 'https://querya.example/themes/neon'); + expect(metadata.license, 'MIT'); + expect(metadata.preview, 'https://cdn.example/preview.png'); + expect(metadata.tags, ['neon', 'dark', 'cyberpunk']); + }); + + test('pickerSubtitle prefers author over tags', () { + const metadata = ThemeMetadata( + author: 'QueryaHub', + tags: ['dark', 'neon'], + ); + + expect(metadata.pickerSubtitle, 'QueryaHub'); + }); + + test('pickerSubtitle falls back to tags', () { + const metadata = ThemeMetadata(tags: ['dark', 'neon']); + + expect(metadata.pickerSubtitle, 'dark, neon'); + }); + }); +} diff --git a/test/core/theme/theme_registry_service_test.dart b/test/core/theme/theme_registry_service_test.dart index 2e0734a5..fd56ff44 100644 --- a/test/core/theme/theme_registry_service_test.dart +++ b/test/core/theme/theme_registry_service_test.dart @@ -61,6 +61,24 @@ void main() { }); group('ThemeRegistryService.loadThemeDefinitions', () { + test('preserves marketplace metadata on custom theme scan', () async { + await _copyFixture( + 'querya_custom_metadata.json', + File(p.join(themesDir.path, 'querya_custom_metadata.json')), + ); + + final definitions = await registry.loadThemeDefinitions(); + final metadataTheme = definitions.singleWhere( + (d) => d.id == 'fixture-custom-metadata', + ); + + expect(metadataTheme.metadata, isNotNull); + expect(metadataTheme.metadata!.author, 'Querya Themes'); + expect(metadataTheme.metadata!.license, 'MIT'); + expect(metadataTheme.metadata!.tags, ['cyberpunk', 'neon', 'dark']); + expect(metadataTheme.metadata!.pickerSubtitle, 'Querya Themes'); + }); + test('includes valid custom and VS Code themes, skips broken file', () async { await _copyFixture( 'querya_custom_dark.json', diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index d053b4fd..c6780531 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/querya_theme.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_metadata.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; @@ -578,4 +579,26 @@ void main() { expect(picked, ThemeController.builtinQueryaLightId); }); }); + + group('filterThemeDefinitions metadata', () { + test('matches author and tags', () { + final themes = [ + const ThemeDefinition( + id: 'neon', + name: 'Neon Nights', + source: ThemeSource.filesystem, + format: ThemeFormat.queryaCustom, + isDark: true, + metadata: ThemeMetadata( + author: 'Querya Themes', + tags: ['cyberpunk', 'dark'], + ), + ), + ]; + + expect(filterThemeDefinitions(themes, 'querya themes'), hasLength(1)); + expect(filterThemeDefinitions(themes, 'cyber'), hasLength(1)); + expect(filterThemeDefinitions(themes, 'light'), isEmpty); + }); + }); } diff --git a/test/fixtures/themes/querya_custom_metadata.json b/test/fixtures/themes/querya_custom_metadata.json new file mode 100644 index 00000000..42905a33 --- /dev/null +++ b/test/fixtures/themes/querya_custom_metadata.json @@ -0,0 +1,19 @@ +{ + "schema": "querya.theme.v1", + "id": "fixture-custom-metadata", + "name": "Fixture Custom Metadata", + "type": "dark", + "description": "Theme fixture with marketplace metadata fields.", + "author": "Querya Themes", + "version": "2.1.0", + "homepage": "https://github.com/QueryaHub/Querya-Desktop", + "license": "MIT", + "preview": "https://example.com/previews/fixture-custom-metadata.png", + "tags": ["cyberpunk", "neon", "dark"], + "shadcn_colors": { + "primary": "#38BDF8" + }, + "editor_colors": { + "background": "#0F1117" + } +} From 2df4fe7923c661b4d7ec67e7a38c3cc514cac591 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 08:09:31 +0300 Subject: [PATCH 4/8] feat(theme): add visual theme editor with export in Preferences Introduce ThemeEditorDraft with live preview via ThemeController, MVP color fields in Appearance, querya.theme.v1 JSON export, and manifest serialization helpers for the TP-F3 editor flow. Closes #162 --- docs/theme-custom-json.md | 7 + .../theme/parser/querya_theme_manifest.dart | 38 +++ lib/core/theme/theme_controller.dart | 36 +++ lib/core/theme/theme_editor_draft.dart | 252 ++++++++++++++++ lib/core/theme/theme_editor_loader.dart | 62 ++++ .../preferences_appearance_section.dart | 6 +- .../settings/theme_color_picker_dialog.dart | 37 +++ .../settings/theme_editor_section.dart | 280 ++++++++++++++++++ .../parser/querya_theme_manifest_test.dart | 13 + test/core/theme/theme_controller_test.dart | 1 + test/core/theme/theme_editor_draft_test.dart | 64 ++++ test/core/theme/theme_editor_loader_test.dart | 115 +++++++ .../preferences_appearance_section_test.dart | 7 +- 13 files changed, 912 insertions(+), 6 deletions(-) create mode 100644 lib/core/theme/theme_editor_draft.dart create mode 100644 lib/core/theme/theme_editor_loader.dart create mode 100644 lib/features/settings/theme_color_picker_dialog.dart create mode 100644 lib/features/settings/theme_editor_section.dart create mode 100644 test/core/theme/theme_editor_draft_test.dart create mode 100644 test/core/theme/theme_editor_loader_test.dart diff --git a/docs/theme-custom-json.md b/docs/theme-custom-json.md index e744ff13..8c44df8b 100644 --- a/docs/theme-custom-json.md +++ b/docs/theme-custom-json.md @@ -287,6 +287,13 @@ Invalid files are skipped (logged in debug builds). Required fields: `schema`, ` Built-in bundled themes (under `assets/themes/`) ship with the app and do not require manual installation. +## Visual editor (Preferences) + +Use **Edit theme colors…** in **Preferences → Appearance** to tweak MVP color tokens +with live preview, then **Export theme…** to save a `querya.theme.v1` JSON file. +Built-in themes are exported as copies with a new `id`; import the file via +**Import theme…** or copy into the themes folder. + ## Troubleshooting | Symptom | Likely cause | What to do | diff --git a/lib/core/theme/parser/querya_theme_manifest.dart b/lib/core/theme/parser/querya_theme_manifest.dart index 2fa4b778..074272dd 100644 --- a/lib/core/theme/parser/querya_theme_manifest.dart +++ b/lib/core/theme/parser/querya_theme_manifest.dart @@ -47,6 +47,44 @@ class QueryaThemeManifest { bool get isDark => type == QueryaThemeType.dark; bool get isLight => type == QueryaThemeType.light; + Map toJson() { + final json = { + 'schema': schema, + 'id': id, + 'name': name, + 'type': type.name, + 'shadcn_colors': shadcnColors, + 'editor_colors': editorColors, + }; + + if (tokenColors.isNotEmpty) { + json['tokenColors'] = tokenColors.map(_tokenColorRuleToJson).toList(); + } + if (description != null) json['description'] = description; + if (author != null) json['author'] = author; + if (version != null) json['version'] = version; + if (homepage != null) json['homepage'] = homepage; + if (license != null) json['license'] = license; + if (preview != null) json['preview'] = preview; + if (tags.isNotEmpty) json['tags'] = tags; + + return json; + } + + String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson()); + + static Map _tokenColorRuleToJson(TokenColorRule rule) { + final settings = {}; + if (rule.foreground != null) settings['foreground'] = rule.foreground; + if (rule.background != null) settings['background'] = rule.background; + if (rule.fontStyle != null) settings['fontStyle'] = rule.fontStyle; + + return { + 'scope': rule.scopes.length == 1 ? rule.scopes.first : rule.scopes, + if (settings.isNotEmpty) 'settings': settings, + }; + } + factory QueryaThemeManifest.fromJsonString(String source) { final cleaned = stripJsonc(source); final dynamic decoded; diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 2128597b..cf4d3c6d 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -5,7 +5,9 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'parser/apply_token_colors_to_editor.dart'; import 'parser/color_parser.dart'; +import 'parser/querya_theme_from_manifest.dart'; import 'parser/querya_theme_from_vscode.dart'; +import 'parser/querya_theme_manifest.dart'; import 'parser/vscode_colors_merge.dart'; import 'parser/vscode_theme_manifest.dart'; import 'querya_theme.dart'; @@ -71,6 +73,8 @@ class ThemeController extends ChangeNotifier { bool _registrySelectionFailed = false; bool _isLoadingAvailableThemes = false; ThemeFolderWatcher? _themeFolderWatcher; + bool _editorPreviewActive = false; + String? _editorPreviewRestoreThemeId; QueryaTheme? _cachedLightTheme; QueryaTheme? _cachedDarkTheme; @@ -177,6 +181,38 @@ class ThemeController extends ChangeNotifier { bool get isThemeFolderWatcherStarted => _themeFolderWatcher?.isStarted ?? false; + @visibleForTesting + bool get isEditorPreviewActive => _editorPreviewActive; + + /// Applies [manifest] for live editor preview without persisting selection. + Future previewEditorManifest(QueryaThemeManifest manifest) async { + if (!_editorPreviewActive) { + _editorPreviewRestoreThemeId = effectiveSelectedThemeId; + _editorPreviewActive = true; + } + + _registryTheme = queryaThemeFromManifest(manifest); + _registrySelectionFailed = false; + _selectedThemeLoadError = null; + _themeMode = manifest.isLight ? ThemeMode.light : ThemeMode.dark; + _notifyThemeChanged(); + } + + /// Restores the theme that was active before editor preview. + Future endEditorPreview() async { + if (!_editorPreviewActive) return; + + final restoreId = _editorPreviewRestoreThemeId; + _editorPreviewActive = false; + _editorPreviewRestoreThemeId = null; + + if (restoreId != null) { + await setThemeById(restoreId); + } else { + _notifyThemeChanged(); + } + } + /// Watches `{appSupport}/themes/` and debounces [loadAvailableThemes]. Future startThemeFolderWatcher() async { _themeFolderWatcher ??= ThemeFolderWatcher( diff --git a/lib/core/theme/theme_editor_draft.dart b/lib/core/theme/theme_editor_draft.dart new file mode 100644 index 00000000..63442848 --- /dev/null +++ b/lib/core/theme/theme_editor_draft.dart @@ -0,0 +1,252 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:shadcn_flutter/shadcn_flutter.dart' show ColorScheme; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/theme_metadata.dart'; + +/// One editable color in the theme editor MVP. +class ThemeEditorColorField { + const ThemeEditorColorField({ + required this.section, + required this.key, + required this.label, + }); + + final String section; + final String key; + final String label; +} + +/// MVP color fields for Preferences theme editor (TP-F3). +const themeEditorMvpColorFields = [ + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'primary', + label: 'Primary', + ), + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'background', + label: 'Background', + ), + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'foreground', + label: 'Foreground', + ), + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'card', + label: 'Card', + ), + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'border', + label: 'Border', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'background', + label: 'Editor background', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'foreground', + label: 'Editor foreground', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'selection', + label: 'Editor selection', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'canvas', + label: 'Workbench canvas', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'sidebarBackground', + label: 'Sidebar background', + ), +]; + +/// Mutable draft for editing and exporting `querya.theme.v1`. +class ThemeEditorDraft { + ThemeEditorDraft({ + required this.id, + required this.name, + required this.type, + required Map shadcnColors, + required Map editorColors, + List tokenColors = const [], + this.description, + this.author, + this.version, + this.homepage, + this.license, + this.preview, + List tags = const [], + this.readOnlySource = false, + }) : shadcnColors = Map.from(shadcnColors), + editorColors = Map.from(editorColors), + tokenColors = List.from(tokenColors), + tags = List.from(tags); + + String id; + String name; + QueryaThemeType type; + final Map shadcnColors; + final Map editorColors; + final List tokenColors; + String? description; + String? author; + String? version; + String? homepage; + String? license; + String? preview; + final List tags; + + /// Built-in / asset themes are exported as copies only. + final bool readOnlySource; + + factory ThemeEditorDraft.fromManifest( + QueryaThemeManifest manifest, { + bool readOnlySource = false, + }) { + return ThemeEditorDraft( + id: manifest.id, + name: manifest.name, + type: manifest.type, + shadcnColors: manifest.shadcnColors, + editorColors: manifest.editorColors, + tokenColors: manifest.tokenColors, + description: manifest.description, + author: manifest.author, + version: manifest.version, + homepage: manifest.homepage, + license: manifest.license, + preview: manifest.preview, + tags: manifest.tags, + readOnlySource: readOnlySource, + ); + } + + factory ThemeEditorDraft.fromQueryaTheme({ + required String id, + required String name, + required bool isDark, + required QueryaTheme theme, + ThemeMetadata? metadata, + bool readOnlySource = false, + }) { + final scheme = theme.colorScheme; + final editor = theme.editor; + final workbench = theme.workbench; + + return ThemeEditorDraft( + id: id, + name: name, + type: isDark ? QueryaThemeType.dark : QueryaThemeType.light, + shadcnColors: { + for (final field in themeEditorMvpColorFields) + if (field.section == 'shadcn_colors') + field.key: _colorForShadcnField(field.key, scheme), + }, + editorColors: { + 'background': formatVsCodeColor(editor.background), + 'foreground': formatVsCodeColor(editor.foreground), + 'selection': formatVsCodeColor(editor.selection), + 'canvas': formatVsCodeColor(workbench.canvas), + 'sidebarBackground': formatVsCodeColor(workbench.sidebarBackground), + }, + tokenColors: theme.tokenColors, + description: metadata?.description, + author: metadata?.author, + version: metadata?.version, + homepage: metadata?.homepage, + license: metadata?.license, + preview: metadata?.preview, + tags: metadata?.tags ?? const [], + readOnlySource: readOnlySource, + ); + } + + static String _colorForShadcnField(String key, ColorScheme scheme) { + final color = switch (key) { + 'primary' => scheme.primary, + 'background' => scheme.background, + 'foreground' => scheme.foreground, + 'card' => scheme.card, + 'border' => scheme.border, + _ => scheme.primary, + }; + return formatVsCodeColor(color); + } + + String? colorHex(ThemeEditorColorField field) { + final map = field.section == 'shadcn_colors' ? shadcnColors : editorColors; + return map[field.key]; + } + + void setColorHex(ThemeEditorColorField field, String hex) { + final normalized = hex.trim(); + parseQueryaThemeColor(normalized); + final map = field.section == 'shadcn_colors' ? shadcnColors : editorColors; + map[field.key] = formatVsCodeColor(parseQueryaThemeColor(normalized)); + } + + void setColor(ThemeEditorColorField field, Color color) { + final map = field.section == 'shadcn_colors' ? shadcnColors : editorColors; + map[field.key] = formatVsCodeColor(color); + } + + QueryaThemeManifest toManifest() { + return QueryaThemeManifest( + schema: queryaThemeSchemaV1, + id: id, + name: name, + type: type, + shadcnColors: Map.unmodifiable(shadcnColors), + editorColors: Map.unmodifiable(editorColors), + tokenColors: List.unmodifiable(tokenColors), + description: description, + author: author, + version: version, + homepage: homepage, + license: license, + preview: preview, + tags: List.unmodifiable(tags), + ); + } + + /// JSON export with a unique id when saving a built-in/read-only source. + ThemeEditorDraft forExport({String? exportId}) { + if (!readOnlySource && exportId == null) return this; + final nextId = exportId ?? '$id-edited'; + return ThemeEditorDraft( + id: nextId, + name: '$name (edited)', + type: type, + shadcnColors: shadcnColors, + editorColors: editorColors, + tokenColors: tokenColors, + description: description, + author: author, + version: version ?? '1.0.0', + homepage: homepage, + license: license, + preview: preview, + tags: tags, + readOnlySource: false, + ); + } + + String toExportJsonString() { + return const JsonEncoder.withIndent(' ').convert(toManifest().toJson()); + } +} diff --git a/lib/core/theme/theme_editor_loader.dart b/lib/core/theme/theme_editor_loader.dart new file mode 100644 index 00000000..bfbf843e --- /dev/null +++ b/lib/core/theme/theme_editor_loader.dart @@ -0,0 +1,62 @@ +import 'dart:io'; +import 'dart:ui' show Brightness; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.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_editor_draft.dart'; + +/// Builds a [ThemeEditorDraft] from the active theme selection. +abstract final class ThemeEditorLoader { + static Future fromController(ThemeController controller) async { + final selectedId = controller.effectiveSelectedThemeId; + final definition = _definitionForId(controller, selectedId); + final readOnlySource = definition?.source == ThemeSource.builtin; + + final manifest = await _loadSourceManifest(definition); + if (manifest != null) { + return ThemeEditorDraft.fromManifest( + manifest, + readOnlySource: readOnlySource, + ); + } + + return ThemeEditorDraft.fromQueryaTheme( + id: selectedId, + name: definition?.name ?? selectedId, + isDark: controller.activeTheme.brightness == Brightness.dark, + theme: controller.activeTheme, + metadata: definition?.metadata, + readOnlySource: readOnlySource, + ); + } + + static ThemeDefinition? _definitionForId( + ThemeController controller, + String id, + ) { + for (final definition in controller.availableThemes) { + if (definition.id == id) return definition; + } + return null; + } + + static Future _loadSourceManifest( + ThemeDefinition? definition, + ) async { + if (definition == null || + definition.format != ThemeFormat.queryaCustom || + definition.path == null || + definition.source == ThemeSource.builtin) { + return null; + } + + final file = File(definition.path!); + if (!await file.exists()) return null; + + try { + return QueryaThemeManifest.fromJsonString(await file.readAsString()); + } on Object { + return null; + } + } +} diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 4f5e8159..60c1ec36 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -8,6 +8,7 @@ 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_paths.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/features/settings/theme_editor_section.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -191,10 +192,11 @@ class _PreferencesAppearanceSectionState padding: material.EdgeInsets.only(left: kPreferencesLabelWidth + 12), child: PreferencesHint( 'Themes are loaded from the app support themes folder. ' - 'Drop .json or .jsonc files there, then use Refresh themes. ' - 'The folder is not watched automatically.', + 'Drop .json or .jsonc files there; the folder is watched automatically ' + 'or use Refresh themes.', ), ), + const ThemeEditorSection(), const material.SizedBox(height: 12), const PreferencesFieldRow( label: 'Interface scale', diff --git a/lib/features/settings/theme_color_picker_dialog.dart b/lib/features/settings/theme_color_picker_dialog.dart new file mode 100644 index 00000000..fb6187f9 --- /dev/null +++ b/lib/features/settings/theme_color_picker_dialog.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Simple color picker dialog for the theme editor. +Future showThemeColorPickerDialog({ + required material.BuildContext context, + required Color initial, +}) async { + var picked = ColorDerivative.fromColor(initial); + + return material.showDialog( + context: context, + builder: (dialogContext) { + return material.AlertDialog( + title: const material.Text('Pick color'), + content: material.SizedBox( + width: 320, + height: 360, + child: ColorPicker( + value: picked, + onChanged: (value) => picked = value, + ), + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.pop(dialogContext), + child: const material.Text('Cancel'), + ), + material.TextButton( + onPressed: () => material.Navigator.pop(dialogContext, picked.toColor()), + child: const material.Text('Apply'), + ), + ], + ); + }, + ); +} diff --git a/lib/features/settings/theme_editor_section.dart b/lib/features/settings/theme_editor_section.dart new file mode 100644 index 00000000..c87f8cac --- /dev/null +++ b/lib/features/settings/theme_editor_section.dart @@ -0,0 +1,280 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_editor_draft.dart'; +import 'package:querya_desktop/core/theme/theme_editor_loader.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/features/settings/theme_color_picker_dialog.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// MVP visual theme editor in Preferences → Appearance. +class ThemeEditorSection extends material.StatefulWidget { + const ThemeEditorSection({super.key}); + + @override + material.State createState() => _ThemeEditorSectionState(); +} + +class _ThemeEditorSectionState extends material.State { + final _controller = ThemeController.instance; + ThemeEditorDraft? _draft; + bool _expanded = false; + bool _loading = false; + bool _exporting = false; + String? _error; + Timer? _previewDebounce; + + @override + void dispose() { + _previewDebounce?.cancel(); + unawaited(_controller.endEditorPreview()); + super.dispose(); + } + + Future _loadDraft() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final draft = await ThemeEditorLoader.fromController(_controller); + if (!mounted) return; + setState(() { + _draft = draft; + _loading = false; + }); + if (_expanded) { + unawaited(_controller.previewEditorManifest(draft.toManifest())); + } + } on Object catch (error) { + if (!mounted) return; + setState(() { + _loading = false; + _error = error.toString(); + }); + } + } + + Future _toggleExpanded() async { + final next = !_expanded; + setState(() => _expanded = next); + + if (next) { + if (_draft == null) { + await _loadDraft(); + } else { + unawaited(_controller.previewEditorManifest(_draft!.toManifest())); + } + return; + } + + await _controller.endEditorPreview(); + } + + void _schedulePreview() { + final draft = _draft; + if (draft == null) return; + + _previewDebounce?.cancel(); + _previewDebounce = Timer(const Duration(milliseconds: 150), () { + if (!mounted || _draft == null) return; + unawaited(_controller.previewEditorManifest(_draft!.toManifest())); + }); + } + + Future _pickColor(ThemeEditorColorField field) async { + final draft = _draft; + if (draft == null) return; + + final currentHex = draft.colorHex(field); + Color initial; + try { + initial = currentHex != null + ? parseQueryaThemeColor(currentHex) + : Theme.of(context).colorScheme.primary; + } on FormatException { + initial = Theme.of(context).colorScheme.primary; + } + + final picked = await showThemeColorPickerDialog( + context: context, + initial: initial, + ); + if (picked == null || !mounted) return; + + setState(() { + draft.setColor(field, picked); + }); + _schedulePreview(); + } + + Future _exportDraft() async { + final draft = _draft; + if (draft == null) return; + + setState(() { + _exporting = true; + _error = null; + }); + + try { + final exportDraft = draft.forExport(); + final location = await getSaveLocation( + suggestedName: '${exportDraft.id}.json', + acceptedTypeGroups: const [ + XTypeGroup( + label: 'Querya theme', + extensions: ['json'], + ), + ], + ); + if (location == null) return; + + await File(location.path).writeAsString(exportDraft.toExportJsonString()); + QueryaThemeManifest.fromJsonString(exportDraft.toExportJsonString()); + } on Object catch (error) { + if (!mounted) return; + setState(() => _error = error.toString()); + } finally { + if (mounted) { + setState(() => _exporting = false); + } + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const material.SizedBox(height: 12), + material.Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: _loading ? null : () => unawaited(_toggleExpanded()), + child: material.Text( + _loading + ? 'Loading editor…' + : _expanded + ? 'Hide theme editor' + : 'Edit theme colors…', + ), + ), + if (_expanded && _draft != null) + OutlineButton( + onPressed: _exporting ? null : () => unawaited(_exportDraft()), + child: material.Text( + _exporting ? 'Exporting…' : 'Export theme…', + ), + ), + if (_expanded) + OutlineButton( + onPressed: _loading ? null : () => unawaited(_loadDraft()), + child: const material.Text('Reset from current'), + ), + ], + ), + if (_expanded && _draft?.readOnlySource == true) ...[ + const material.SizedBox(height: 8), + const PreferencesHint( + 'Built-in themes are not modified in place. Export saves a copy ' + 'with a new id that you can import into the themes folder.', + ), + ], + if (_error != null) ...[ + const material.SizedBox(height: 8), + material.Text( + _error!, + style: material.TextStyle(fontSize: 12, color: cs.destructive), + ), + ], + if (_expanded && _draft != null) ...[ + const material.SizedBox(height: 12), + for (final field in themeEditorMvpColorFields) + _ThemeEditorColorRow( + field: field, + hex: _draft!.colorHex(field), + onPick: () => unawaited(_pickColor(field)), + ), + ], + ], + ); + } +} + +class _ThemeEditorColorRow extends material.StatelessWidget { + const _ThemeEditorColorRow({ + required this.field, + required this.hex, + required this.onPick, + }); + + final ThemeEditorColorField field; + final String? hex; + final material.VoidCallback onPick; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + Color? swatchColor; + if (hex != null) { + try { + swatchColor = parseQueryaThemeColor(hex!); + } on FormatException { + swatchColor = null; + } + } + + return material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Row( + children: [ + material.SizedBox( + width: kPreferencesLabelWidth, + child: material.Text( + field.label, + style: material.TextStyle( + fontSize: 13, + color: cs.popoverForeground, + ), + ), + ), + const material.SizedBox(width: 12), + material.InkWell( + onTap: onPick, + borderRadius: material.BorderRadius.circular(6), + child: material.Container( + width: 28, + height: 28, + decoration: material.BoxDecoration( + color: swatchColor ?? cs.muted, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all(color: cs.border), + ), + ), + ), + const material.SizedBox(width: 10), + material.Expanded( + child: material.Text( + hex ?? '—', + style: material.TextStyle( + fontSize: 12, + color: cs.mutedForeground, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + } +} diff --git a/test/core/theme/parser/querya_theme_manifest_test.dart b/test/core/theme/parser/querya_theme_manifest_test.dart index e073dbaf..65d5cc56 100644 --- a/test/core/theme/parser/querya_theme_manifest_test.dart +++ b/test/core/theme/parser/querya_theme_manifest_test.dart @@ -187,6 +187,19 @@ void main() { ); }); + test('serializes to valid JSON for export', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + final exported = manifest.toJsonString(); + final reparsed = QueryaThemeManifest.fromJsonString(exported); + + expect(reparsed.id, manifest.id); + expect(reparsed.shadcnColors['primary'], manifest.shadcnColors['primary']); + expect(reparsed.tokenColors.length, manifest.tokenColors.length); + }); + test('reuses TokenColorRule parsing from VS Code themes', () { const src = ''' { diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 8f3012e9..6992e273 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -91,6 +91,7 @@ void main() { tearDown(() async { await ThemeController.instance.stopThemeFolderWatcher(); + await ThemeController.instance.endEditorPreview(); await AppSettings.instance.clearThemeSettings(); await ThemeImportService.deletePersistedImport(); if (await themesDir.exists()) { diff --git a/test/core/theme/theme_editor_draft_test.dart b/test/core/theme/theme_editor_draft_test.dart new file mode 100644 index 00000000..38539a11 --- /dev/null +++ b/test/core/theme/theme_editor_draft_test.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_from_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/theme_editor_draft.dart'; + +void main() { + group('ThemeEditorDraft', () { + test('fromQueryaTheme captures MVP colors', () { + const theme = QueryaTheme.darkDefault; + final draft = ThemeEditorDraft.fromQueryaTheme( + id: 'querya-dark', + name: 'Querya Dark', + isDark: true, + theme: theme, + ); + + expect(draft.shadcnColors['primary'], formatVsCodeColor(theme.colorScheme.primary)); + expect(draft.editorColors['background'], formatVsCodeColor(theme.editor.background)); + expect(draft.editorColors['canvas'], formatVsCodeColor(theme.workbench.canvas)); + }); + + test('setColor updates manifest and round-trips export', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final source = QueryaThemeManifest.fromJsonString(raw); + final draft = ThemeEditorDraft.fromManifest(source); + + draft.setColorHex( + themeEditorMvpColorFields.first, + '#FF00AA', + ); + + final exported = draft.toExportJsonString(); + final reparsed = QueryaThemeManifest.fromJsonString(exported); + expect(reparsed.shadcnColors['primary'], '#ff00aa'); + + final theme = queryaThemeFromManifest(reparsed); + expect(theme.colorScheme.primary, parseQueryaThemeColor('#FF00AA')); + }); + + test('forExport assigns new id for read-only built-in source', () { + final draft = ThemeEditorDraft.fromQueryaTheme( + id: 'querya-dark', + name: 'Querya Dark', + isDark: true, + theme: QueryaTheme.darkDefault, + readOnlySource: true, + ); + + final exported = draft.forExport(); + expect(exported.id, 'querya-dark-edited'); + expect(exported.name, 'Querya Dark (edited)'); + + final json = jsonDecode(exported.toExportJsonString()) as Map; + expect(json['schema'], queryaThemeSchemaV1); + expect(json['id'], 'querya-dark-edited'); + }); + }); +} diff --git a/test/core/theme/theme_editor_loader_test.dart b/test/core/theme/theme_editor_loader_test.dart new file mode 100644 index 00000000..8d8834f5 --- /dev/null +++ b/test/core/theme/theme_editor_loader_test.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +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/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_editor_loader.dart'; +import 'package:querya_desktop/core/theme/theme_registry_service.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +Future _fixtureAssetLoader(String assetPath) async { + final fileName = p.basename(assetPath); + return File(p.join('test/fixtures/themes', fileName)).readAsString(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory themesDir; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_editor_loader_'); + 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); + ThemeController.instance.setRegistryServiceForTest( + ThemeRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => Directory( + p.join(themesDir.path, 'imported'), + ), + assetLoader: _fixtureAssetLoader, + ), + ); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await ThemeController.instance.stopThemeFolderWatcher(); + await ThemeController.instance.endEditorPreview(); + await AppSettings.instance.clearThemeSettings(); + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService()); + await ThemeController.instance.load(); + }); + + group('ThemeEditorLoader', () { + test('loads custom theme file manifest when available', () async { + final source = File(p.join('test/fixtures/themes', 'querya_custom_dark.json')); + await File(p.join(themesDir.path, 'querya_custom_dark.json')) + .writeAsString(await source.readAsString()); + + final controller = ThemeController.instance; + await controller.load(); + await controller.setThemeById('fixture-custom-dark'); + + final draft = await ThemeEditorLoader.fromController(controller); + expect(draft.id, 'fixture-custom-dark'); + expect(draft.shadcnColors['primary'], '#38BDF8'); + expect(draft.editorColors['background'], '#0F1117'); + }); + }); + + group('ThemeController editor preview', () { + test('previewEditorManifest updates active theme and restores', () async { + final c = ThemeController.instance; + await c.load(); + final beforePrimary = c.activeTheme.colorScheme.primary; + + final manifest = QueryaThemeManifest.fromJsonString(''' +{ + "schema": "querya.theme.v1", + "id": "preview-test", + "name": "Preview Test", + "type": "dark", + "shadcn_colors": { "primary": "#FF00AA" }, + "editor_colors": { "background": "#010203" } +} +'''); + + await c.previewEditorManifest(manifest); + expect(c.isEditorPreviewActive, isTrue); + expect(c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#FF00AA')); + + await c.endEditorPreview(); + expect(c.isEditorPreviewActive, isFalse); + expect(c.activeTheme.colorScheme.primary, beforePrimary); + }); + }); +} diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index 936bd2f1..15ad192e 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -124,16 +124,15 @@ void main() { expect(find.text('Reset appearance'), findsOneWidget); }); - testWidgets('shows themes folder hint without live reload promise', - (tester) async { + testWidgets('shows themes folder hint and theme editor entry', (tester) async { await pumpSection(tester); expect( find.textContaining('Themes are loaded from the app support themes folder'), findsOneWidget, ); - expect(find.textContaining('not watched automatically'), findsOneWidget); - expect(find.textContaining('live reload'), findsNothing); + expect(find.textContaining('watched automatically'), findsOneWidget); + expect(find.text('Edit theme colors…'), findsOneWidget); }); }); } From fc481659755de209fac66ef6f1f94c06c31d4c23 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 12:12:46 +0300 Subject: [PATCH 5/8] feat(theme): install themes from HTTPS URL with checksum verify Add ThemeRemoteInstallService and policy for public HTTPS-only downloads, Preferences Install from URL dialog, MarketplaceClient stub, and tests for success, checksum mismatch, invalid JSON, and deduplicated imports. Closes #163 --- docs/theme-import.md | 17 ++ lib/core/market/marketplace_client.dart | 35 +++ lib/core/theme/theme_controller.dart | 22 ++ .../theme/theme_remote_install_policy.dart | 50 +++++ .../theme/theme_remote_install_service.dart | 137 ++++++++++++ .../preferences_appearance_section.dart | 47 +++- .../settings/theme_remote_install_dialog.dart | 144 +++++++++++++ pubspec.yaml | 1 + .../theme_remote_install_policy_test.dart | 61 ++++++ .../theme_remote_install_service_test.dart | 202 ++++++++++++++++++ .../preferences_appearance_section_test.dart | 1 + 11 files changed, 713 insertions(+), 4 deletions(-) create mode 100644 lib/core/market/marketplace_client.dart create mode 100644 lib/core/theme/theme_remote_install_policy.dart create mode 100644 lib/core/theme/theme_remote_install_service.dart create mode 100644 lib/features/settings/theme_remote_install_dialog.dart create mode 100644 test/core/theme/theme_remote_install_policy_test.dart create mode 100644 test/core/theme/theme_remote_install_service_test.dart diff --git a/docs/theme-import.md b/docs/theme-import.md index d61c36f7..6727d708 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -122,6 +122,23 @@ Built-in preset defaults apply for keys not present in the merged map. API: `ThemeController.setWorkbenchColor(key, color?)`, `ThemeController.clearColorOverrides()` (user layer only). +## Remote install from URL (0.4.3+) + +**Preferences → Appearance → Install from URL…** downloads a theme over **HTTPS only** +and imports it into `{appSupport}/themes/` using the same deduplication rules as +**Import theme…**. + +- Public HTTPS URLs only (no `http://`, no private/loopback hosts in release builds). +- Optional **SHA-256** checksum in the dialog or as `?sha256=` on the URL. +- Invalid JSON or checksum mismatch aborts install; nothing is written to the themes folder. +- No silent background downloads — install runs only after you confirm in the dialog. + +Trust model: treat remote theme URLs like any untrusted file; prefer checksums from a +known publisher. Signature verification is not implemented yet. + +Implementation: `lib/core/theme/theme_remote_install_service.dart`, +`lib/core/market/marketplace_client.dart` (stub for future Explore UI). + ## Sample themes (manual import) - `themes/samples/cyberpunk-neon.json` — cyberpunk dark preset for UI + SQL/JSON tokens diff --git a/lib/core/market/marketplace_client.dart b/lib/core/market/marketplace_client.dart new file mode 100644 index 00000000..247ce9d4 --- /dev/null +++ b/lib/core/market/marketplace_client.dart @@ -0,0 +1,35 @@ +import 'extension_manifest.dart'; + +/// Future marketplace API client (mockable until backend exists). +/// +/// See [docs/market-tech.md](https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/market-tech.md). +abstract class MarketplaceClient { + Future> searchExtensions({ + required String query, + String? type, + }); +} + +/// In-memory placeholder for local development and tests. +class MockMarketplaceClient implements MarketplaceClient { + MockMarketplaceClient({List? seed}) + : _items = List.from(seed ?? const []); + + final List _items; + + @override + Future> searchExtensions({ + required String query, + String? type, + }) async { + final normalized = query.trim().toLowerCase(); + return _items + .where((item) { + if (type != null && item.type != type) return false; + if (normalized.isEmpty) return true; + return item.name.toLowerCase().contains(normalized) || + item.id.toLowerCase().contains(normalized); + }) + .toList(growable: false); + } +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index cf4d3c6d..6a4f498a 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -18,6 +18,7 @@ import 'theme_import_service.dart'; import 'theme_load_result.dart'; import 'theme_paths.dart'; import 'theme_registry_service.dart'; +import 'theme_remote_install_service.dart'; /// Active theme state: preset, optional imported colors, user overrides. class ThemeController extends ChangeNotifier { @@ -438,6 +439,27 @@ class ThemeController extends ChangeNotifier { String path, ) async { final result = await _registryService.importThemeFile(path); + return _applyRegistryImportResult(result); + } + + /// Downloads a theme from [url] and activates it when import succeeds. + Future importRegistryThemeFromUrl( + String url, { + String? sha256Checksum, + ThemeRemoteInstallService? remoteInstallService, + }) async { + final installer = remoteInstallService ?? + ThemeRemoteInstallService(_registryService); + final result = await installer.installFromUrl( + url, + sha256Checksum: sha256Checksum, + ); + return _applyRegistryImportResult(result); + } + + Future _applyRegistryImportResult( + ThemeDefinitionImportResult result, + ) async { switch (result) { case ThemeDefinitionImportSuccess(:final definition): _availableThemes = _mergeBuiltinThemes( diff --git a/lib/core/theme/theme_remote_install_policy.dart b/lib/core/theme/theme_remote_install_policy.dart new file mode 100644 index 00000000..f41aa758 --- /dev/null +++ b/lib/core/theme/theme_remote_install_policy.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; + +/// HTTPS trust rules for remote theme install (TP-F4). +abstract final class ThemeRemoteInstallPolicy { + /// Returns true when [uri] may be used for theme download. + static bool isAllowedUrl(Uri uri, {bool allowLocalhostInDebug = kDebugMode}) { + if (uri.scheme != 'https') return false; + if (!uri.hasAuthority || uri.host.isEmpty) return false; + + final host = uri.host.toLowerCase(); + if (host == 'localhost' || host == '0.0.0.0') { + return allowLocalhostInDebug; + } + if (host == '::1' || host.endsWith('.local')) { + return allowLocalhostInDebug; + } + + final ipv4 = _parseIpv4(host); + if (ipv4 != null) { + if (_isLoopbackIpv4(ipv4) || _isPrivateIpv4(ipv4) || _isLinkLocalIpv4(ipv4)) { + return allowLocalhostInDebug; + } + } + + return true; + } + + static List? _parseIpv4(String host) { + final parts = host.split('.'); + if (parts.length != 4) return null; + final bytes = []; + for (final part in parts) { + final value = int.tryParse(part); + if (value == null || value < 0 || value > 255) return null; + bytes.add(value); + } + return bytes; + } + + static bool _isLoopbackIpv4(List ip) => ip[0] == 127; + + static bool _isLinkLocalIpv4(List ip) => ip[0] == 169 && ip[1] == 254; + + static bool _isPrivateIpv4(List ip) { + if (ip[0] == 10) return true; + if (ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31) return true; + if (ip[0] == 192 && ip[1] == 168) return true; + return false; + } +} diff --git a/lib/core/theme/theme_remote_install_service.dart b/lib/core/theme/theme_remote_install_service.dart new file mode 100644 index 00000000..0939fb22 --- /dev/null +++ b/lib/core/theme/theme_remote_install_service.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; + +import 'theme_import_service.dart'; +import 'theme_registry_service.dart'; +import 'theme_remote_install_policy.dart'; + +/// HTTP response shape used by [ThemeRemoteInstallService] (mockable in tests). +class RemoteThemeHttpResponse { + const RemoteThemeHttpResponse({ + required this.statusCode, + required this.body, + }); + + final int statusCode; + final String body; +} + +/// Downloads a theme from HTTPS and imports it via [ThemeRegistryService]. +class ThemeRemoteInstallService { + ThemeRemoteInstallService( + this._registry, { + Future Function(Uri uri)? httpGet, + Duration timeout = const Duration(seconds: 30), + bool allowLocalhostInDebug = true, + }) : _httpGet = httpGet ?? _defaultHttpGet, + _timeout = timeout, + _allowLocalhostInDebug = allowLocalhostInDebug; + + final ThemeRegistryService _registry; + final Future Function(Uri uri) _httpGet; + final Duration _timeout; + final bool _allowLocalhostInDebug; + + static Future _defaultHttpGet(Uri uri) async { + final response = await http.get(uri).timeout(const Duration(seconds: 30)); + return RemoteThemeHttpResponse( + statusCode: response.statusCode, + body: response.body, + ); + } + + /// Downloads [url] and imports into the user themes directory. + /// + /// [sha256Checksum] may be passed explicitly or via `?sha256=` on the URL. + Future installFromUrl( + String url, { + String? sha256Checksum, + }) async { + final trimmed = url.trim(); + if (trimmed.isEmpty) { + return const ThemeDefinitionImportFailure('Theme URL is required.'); + } + + final uri = Uri.tryParse(trimmed); + if (uri == null) { + return const ThemeDefinitionImportFailure('Invalid theme URL.'); + } + + if (!ThemeRemoteInstallPolicy.isAllowedUrl( + uri, + allowLocalhostInDebug: _allowLocalhostInDebug, + )) { + return const ThemeDefinitionImportFailure( + 'Only public HTTPS theme URLs are allowed.', + ); + } + + final expectedChecksum = _normalizeSha256( + sha256Checksum ?? uri.queryParameters['sha256'], + ); + + File? tempFile; + try { + final response = await _httpGet(uri).timeout(_timeout); + if (response.statusCode != 200) { + return ThemeDefinitionImportFailure( + 'Download failed (HTTP ${response.statusCode}).', + ); + } + + final body = response.body; + if (body.trim().isEmpty) { + return const ThemeDefinitionImportFailure('Downloaded theme file is empty.'); + } + + final actualChecksum = sha256.convert(utf8.encode(body)).toString(); + if (expectedChecksum != null && expectedChecksum != actualChecksum) { + return const ThemeDefinitionImportFailure( + 'Checksum mismatch. Theme was not installed.', + ); + } + + final tempDir = Directory.systemTemp.createTempSync('querya_theme_remote_'); + tempFile = File(p.join(tempDir.path, 'remote-theme.json')); + await tempFile.writeAsString(body); + + return await _registry.importThemeFile(tempFile.path); + } on TimeoutException { + return const ThemeDefinitionImportFailure('Download timed out.'); + } on SocketException catch (error) { + return ThemeDefinitionImportFailure('Network error: ${error.message}'); + } on HttpException catch (error) { + return ThemeDefinitionImportFailure('Network error: ${error.message}'); + } on IOException catch (error) { + return ThemeDefinitionImportFailure(error.toString()); + } on Object catch (error) { + return ThemeDefinitionImportFailure(error.toString()); + } finally { + if (tempFile != null) { + try { + final parent = tempFile.parent; + if (await tempFile.exists()) { + await tempFile.delete(); + } + if (await parent.exists()) { + await parent.delete(recursive: true); + } + } on Object { + // Best-effort temp cleanup. + } + } + } + } + + static String? _normalizeSha256(String? raw) { + if (raw == null) return null; + final trimmed = raw.trim().toLowerCase(); + if (trimmed.isEmpty) return null; + return trimmed.replaceAll(RegExp(r'[^0-9a-f]'), ''); + } +} diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 60c1ec36..0daff22d 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/features/settings/theme_editor_section.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; +import 'package:querya_desktop/features/settings/theme_remote_install_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Appearance / theme controls for [PreferencesDialog]. @@ -28,6 +29,7 @@ class _PreferencesAppearanceSectionState String? _importError; String? _folderOpenError; bool _importing = false; + bool _installingFromUrl = false; bool _openingThemesFolder = false; @override @@ -94,6 +96,33 @@ class _PreferencesAppearanceSectionState } } + Future _installThemeFromUrl() async { + final request = await showThemeRemoteInstallDialog(context); + if (request == null) return; + + setState(() { + _installingFromUrl = true; + _importError = null; + }); + try { + final result = await _controller.importRegistryThemeFromUrl( + request.url, + sha256Checksum: request.sha256Checksum, + ); + if (!mounted) return; + switch (result) { + case ThemeDefinitionImportSuccess(): + setState(() => _importError = null); + case ThemeDefinitionImportFailure(:final message): + setState(() => _importError = message); + } + } finally { + if (mounted) { + setState(() => _installingFromUrl = false); + } + } + } + Future _resetAppearance() async { await _controller.resetToDefaults(); if (mounted) setState(() => _importError = null); @@ -233,12 +262,21 @@ class _PreferencesAppearanceSectionState runSpacing: 8, children: [ OutlineButton( - onPressed: - _importing ? null : () => unawaited(_pickAndImportTheme()), + onPressed: (_importing || _installingFromUrl) + ? null + : () => unawaited(_pickAndImportTheme()), child: material.Text(_importing ? 'Importing…' : 'Import theme…'), ), OutlineButton( - onPressed: (_importing || refreshingThemes) + onPressed: (_importing || _installingFromUrl || refreshingThemes) + ? null + : () => unawaited(_installThemeFromUrl()), + child: material.Text( + _installingFromUrl ? 'Installing…' : 'Install from URL…', + ), + ), + OutlineButton( + onPressed: (_importing || _installingFromUrl || refreshingThemes) ? null : () => unawaited(_refreshThemes()), child: material.Text( @@ -246,7 +284,7 @@ class _PreferencesAppearanceSectionState ), ), OutlineButton( - onPressed: (_importing || _openingThemesFolder) + onPressed: (_importing || _installingFromUrl || _openingThemesFolder) ? null : () => unawaited(_openThemesFolder()), child: material.Text( @@ -282,6 +320,7 @@ class _PreferencesAppearanceSectionState const material.SizedBox(height: 4), const PreferencesHint( 'Import copies a theme into the themes folder. ' + 'Install from URL requires HTTPS and optional SHA-256 verification. ' 'VS Code JSON/JSONC (.colors subset) and Querya custom JSON are supported.', ), ], diff --git a/lib/features/settings/theme_remote_install_dialog.dart b/lib/features/settings/theme_remote_install_dialog.dart new file mode 100644 index 00000000..e8d294ef --- /dev/null +++ b/lib/features/settings/theme_remote_install_dialog.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Dialog for installing a theme from a public HTTPS URL. +Future showThemeRemoteInstallDialog( + material.BuildContext context, +) async { + return material.showDialog( + context: context, + builder: (dialogContext) => const _ThemeRemoteInstallDialog(), + ); +} + +class ThemeRemoteInstallRequest { + const ThemeRemoteInstallRequest({ + required this.url, + this.sha256Checksum, + }); + + final String url; + final String? sha256Checksum; +} + +class _ThemeRemoteInstallDialog extends material.StatefulWidget { + const _ThemeRemoteInstallDialog(); + + @override + material.State<_ThemeRemoteInstallDialog> createState() => + _ThemeRemoteInstallDialogState(); +} + +class _ThemeRemoteInstallDialogState + extends material.State<_ThemeRemoteInstallDialog> { + final _urlController = material.TextEditingController(); + final _checksumController = material.TextEditingController(); + String? _validationError; + + @override + void dispose() { + _urlController.dispose(); + _checksumController.dispose(); + super.dispose(); + } + + void _submit() { + final url = _urlController.text.trim(); + if (url.isEmpty) { + setState(() => _validationError = 'Enter a theme URL.'); + return; + } + + final uri = Uri.tryParse(url); + if (uri == null || uri.host.isEmpty) { + setState(() => _validationError = 'Enter a valid HTTPS URL.'); + return; + } + if (uri.scheme != 'https') { + setState(() => _validationError = 'Only HTTPS URLs are allowed.'); + return; + } + + final checksum = _checksumController.text.trim(); + material.Navigator.pop( + context, + ThemeRemoteInstallRequest( + url: url, + sha256Checksum: checksum.isEmpty ? null : checksum, + ), + ); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final host = Uri.tryParse(_urlController.text.trim())?.host; + + return material.AlertDialog( + title: const material.Text('Install theme from URL'), + content: material.SizedBox( + width: 420, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const material.Text( + 'Download runs only after you confirm. Use public HTTPS links.', + ), + if (host != null && host.isNotEmpty) ...[ + const material.SizedBox(height: 8), + material.Text( + 'Host: $host', + style: material.TextStyle( + fontSize: 12, + color: cs.mutedForeground, + ), + ), + ], + const material.SizedBox(height: 12), + material.TextField( + controller: _urlController, + decoration: const material.InputDecoration( + labelText: 'Theme URL', + hintText: 'https://example.com/themes/my-theme.json', + ), + keyboardType: material.TextInputType.url, + autocorrect: false, + onChanged: (_) => setState(() => _validationError = null), + ), + const material.SizedBox(height: 12), + material.TextField( + controller: _checksumController, + decoration: const material.InputDecoration( + labelText: 'SHA-256 checksum (optional)', + hintText: 'hex digest or ?sha256= on URL', + ), + autocorrect: false, + ), + if (_validationError != null) ...[ + const material.SizedBox(height: 8), + material.Text( + _validationError!, + style: material.TextStyle( + fontSize: 12, + color: cs.destructive, + ), + ), + ], + ], + ), + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.pop(context), + child: const material.Text('Cancel'), + ), + PrimaryButton( + onPressed: _submit, + child: const material.Text('Install'), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 83da6093..5cb98878 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: flutter: sdk: flutter http: ^1.2.2 + crypto: ^3.0.6 shadcn_flutter: ^0.0.52 bitsdojo_window: ^0.1.6 path: ^1.9.0 diff --git a/test/core/theme/theme_remote_install_policy_test.dart b/test/core/theme/theme_remote_install_policy_test.dart new file mode 100644 index 00000000..3114256b --- /dev/null +++ b/test/core/theme/theme_remote_install_policy_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/theme_remote_install_policy.dart'; + +void main() { + group('ThemeRemoteInstallPolicy', () { + test('allows public https URLs', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://cdn.example.com/themes/neon.json'), + allowLocalhostInDebug: false, + ), + isTrue, + ); + }); + + test('rejects http URLs', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('http://example.com/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('rejects localhost unless debug override', () { + final localhost = Uri.parse('https://localhost/theme.json'); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + localhost, + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + localhost, + allowLocalhostInDebug: true, + ), + isTrue, + ); + }); + + test('rejects private IPv4 addresses', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://192.168.1.10/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://10.0.0.5/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + }); +} diff --git a/test/core/theme/theme_remote_install_service_test.dart b/test/core/theme/theme_remote_install_service_test.dart new file mode 100644 index 00000000..8836ed7e --- /dev/null +++ b/test/core/theme/theme_remote_install_service_test.dart @@ -0,0 +1,202 @@ +import 'dart:io'; + +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/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_import_service.dart'; +import 'package:querya_desktop/core/theme/theme_registry_service.dart'; +import 'package:querya_desktop/core/theme/theme_remote_install_service.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +Future _fixtureAssetLoader(String assetPath) async { + final fileName = p.basename(assetPath); + return File(p.join('test/fixtures/themes', fileName)).readAsString(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory themesDir; + late ThemeRegistryService registry; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_remote_install_'); + 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); + registry = ThemeRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => Directory( + p.join(themesDir.path, 'imported'), + ), + assetLoader: _fixtureAssetLoader, + ); + ThemeController.instance.setRegistryServiceForTest(registry); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await ThemeController.instance.stopThemeFolderWatcher(); + await ThemeController.instance.endEditorPreview(); + await AppSettings.instance.clearThemeSettings(); + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService()); + await ThemeController.instance.load(); + }); + + group('ThemeRemoteInstallService', () { + test('installs valid HTTPS theme content', () async { + final raw = + await File('test/fixtures/themes/querya_custom_dark.json').readAsString(); + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => RemoteThemeHttpResponse( + statusCode: 200, + body: raw, + ), + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + ); + + expect(result, isA()); + final success = result as ThemeDefinitionImportSuccess; + expect(success.definition.id, 'fixture-custom-dark'); + expect( + await File(p.join(themesDir.path, 'fixture-custom-dark.json')).exists(), + isTrue, + ); + }); + + test('rejects checksum mismatch and does not write theme file', () async { + final raw = + await File('test/fixtures/themes/querya_custom_dark.json').readAsString(); + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => RemoteThemeHttpResponse( + statusCode: 200, + body: raw, + ), + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: 'deadbeef', + ); + + expect(result, isA()); + expect( + (result as ThemeDefinitionImportFailure).message, + contains('Checksum mismatch'), + ); + expect(await themesDir.list().length, 1); + }); + + test('rejects invalid JSON without writing to themes folder', () async { + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => const RemoteThemeHttpResponse( + statusCode: 200, + body: '{ not valid json', + ), + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/broken.json', + ); + + expect(result, isA()); + final jsonFiles = await themesDir + .list() + .where((entity) => entity.path.endsWith('.json')) + .toList(); + expect(jsonFiles, isEmpty); + }); + + test('rejects non-https URLs', () async { + final service = ThemeRemoteInstallService(registry); + final result = await service.installFromUrl('http://example.com/a.json'); + expect(result, isA()); + }); + + test('reuses existing file when remote content hash matches', () async { + final raw = + await File('test/fixtures/themes/querya_custom_dark.json').readAsString(); + await File(p.join(themesDir.path, 'fixture-custom-dark.json')) + .writeAsString(raw); + + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => RemoteThemeHttpResponse( + statusCode: 200, + body: raw, + ), + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + ); + + expect(result, isA()); + expect((result as ThemeDefinitionImportSuccess).reusedExisting, isTrue); + }); + }); + + group('ThemeController remote install', () { + test('importRegistryThemeFromUrl activates imported theme', () async { + final raw = + await File('test/fixtures/themes/querya_custom_dark.json').readAsString(); + final controller = ThemeController.instance; + await controller.load(); + + final result = await controller.importRegistryThemeFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + remoteInstallService: ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => RemoteThemeHttpResponse( + statusCode: 200, + body: raw, + ), + ), + ); + + expect(result, isA()); + expect(controller.selectedThemeId, 'fixture-custom-dark'); + expect( + controller.activeTheme.colorScheme.primary, + parseQueryaThemeColor('#38BDF8'), + ); + }); + }); +} diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index 15ad192e..11661ee9 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -119,6 +119,7 @@ void main() { await pumpSection(tester); expect(find.text('Import theme…'), findsOneWidget); + expect(find.text('Install from URL…'), findsOneWidget); expect(find.text('Refresh themes'), findsOneWidget); expect(find.text('Open themes folder'), findsOneWidget); expect(find.text('Reset appearance'), findsOneWidget); From ff4119b4450c970f3f6ee44a42104fb8e9c0c784 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 12:31:35 +0300 Subject: [PATCH 6/8] chore(release): prepare 0.4.3 theme follow-ups release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalize changelog, release checklist, and docs for TP-F1–F4 (#159–#163). Keep pubspec at 0.4.1+7 on dev for post-merge auto version-bump to 0.4.3+9. --- CHANGELOG.md | 13 +++++++++++-- README.md | 2 +- docs/README.md | 4 +++- docs/planned-0.4.3.md | 2 +- docs/release-checklist.md | 17 ++++++++++++----- docs/roadmap.md | 4 +++- docs/theme-import.md | 10 ++++++---- docs/theme-parser-github-issues.md | 2 +- 8 files changed, 38 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f817cab..c27748a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Planned (0.4.3) +## [0.4.3] - 2026-06-15 -Theme and extensions follow-ups — see [docs/planned-0.4.3.md](docs/planned-0.4.3.md): file watcher (TP-F1), marketplace metadata (TP-F2), visual theme editor (TP-F3), remote theme install (TP-F4). +Theme follow-ups release (TP-F1–TP-F4, GitHub issues **#159–#163**). Git tag **`0.4.3`**. + +### Added + +- **Theme folder watcher (TP-F1)** — debounced `Directory.watch` on `{appSupport}/themes/` auto-refreshes the registry when files are added, removed, or renamed. +- **Marketplace metadata (TP-F2)** — optional manifest fields (`homepage`, `license`, `preview`, `tags`); theme picker shows author/tags; `ExtensionManifest` stub in `lib/core/market/` for future Explore UI. +- **Visual theme editor (TP-F3)** — Preferences section to tweak workbench colors with live preview and export `querya.theme.v1` JSON. +- **Remote theme install (TP-F4)** — **Install from URL…** (HTTPS-only, public hosts, optional SHA-256); `ThemeRemoteInstallService` with checksum verify before import. +- **Docs / QA** — remote install section in [theme-import.md](docs/theme-import.md); 0.4.3 items in [release-checklist.md](docs/release-checklist.md). +- **Tests** — file watcher, remote install policy/service, theme editor and metadata coverage. ## [0.4.2] - 2026-06-14 diff --git a/README.md b/README.md index 89d384d5..8e7085a2 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ a clean, dark UI inspired by tools like pgAdmin. statement timeouts, query history, and CSV/JSON export. - **Object browsing** — connection tree with databases, tables, views, and server stats. -- **Themeable** — dark/light/system, **VS Code theme import**, custom `querya.theme.v1` registry, and bundled themes (0.4.2). +- **Themeable** — dark/light/system, **VS Code theme import**, custom `querya.theme.v1` registry, visual editor, remote install, and bundled themes (0.4.3). - **Scalable UI** — global interface scaling for high-DPI and accessibility. - **Secure by default** — passwords and connection strings live in the OS secure store, never in plaintext. diff --git a/docs/README.md b/docs/README.md index 52811ce3..f47d50be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,7 +26,9 @@ Index of Querya Desktop documentation, grouped by audience. ## Planning - [Roadmap](roadmap.md) — current direction and follow-ups. -- [Planned 0.4.3](planned-0.4.3.md) — deferred theme/extensions follow-ups after 0.4.2. +- [Planned 0.4.3](planned-0.4.3.md) — shipped theme follow-ups (TP-F1–TP-F4). +- [Planned 0.4.4](planned-0.4.4.md) — UI motion polish and high refresh rate. +- [Motion and high refresh rate](motion-and-high-refresh.md) — 0.4.4 research, audit, and per-platform Hz design. - [Custom theme parser requirements](scheme-parcer.md) — JSON theme format and scaling spec. - [Theme parser implementation plan](theme-parser-implementation-tasks.md) — task breakdown and architecture. - [Theme parser GitHub issues](theme-parser-github-issues.md) — issue templates for epic #96–#125. diff --git a/docs/planned-0.4.3.md b/docs/planned-0.4.3.md index 13b9425e..14d95ebe 100644 --- a/docs/planned-0.4.3.md +++ b/docs/planned-0.4.3.md @@ -1,6 +1,6 @@ # Planned release 0.4.3 — theme and extensions follow-ups -**Status:** planning (GitHub milestone [**0.4.3**](https://github.com/QueryaHub/Querya-Desktop/milestone/2), epic **#159**). +**Status:** **shipped in 0.4.3** (GitHub milestone [**0.4.3**](https://github.com/QueryaHub/Querya-Desktop/milestone/2), epic **#159** closed). **Depends on:** **0.4.2** custom theme registry (TP-01–TP-30, shipped). This document captures work intentionally deferred from the first custom-theme pass. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 5dd8f7ce..98df2eb6 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,6 +1,6 @@ -# Pre-release checklist (release **0.4.2**) +# Pre-release checklist (release **0.4.3**) -Use this before tagging **`0.4.2`** or running the **Release** workflow. +Use this before tagging **`0.4.3`** or running the **Release** workflow. See [tags-and-releases.md](tags-and-releases.md) and [CHANGELOG.md](../CHANGELOG.md). ## Product smoke (manual) @@ -16,7 +16,7 @@ See [tags-and-releases.md](tags-and-releases.md) and [CHANGELOG.md](../CHANGELOG Use **Preferences → Appearance** unless noted. Fixtures for copy/import tests live under `test/fixtures/themes/`; bundled built-in sample: **Querya Cyberpunk Neon** in the theme picker. -- [ ] **Import valid custom dark** — import `test/fixtures/themes/querya_custom_dark.json` (or copy to themes folder + **Refresh themes**). Theme appears in picker; UI uses custom primary (`#38BDF8`). +- [ ] **Import valid custom dark** — import `test/fixtures/themes/querya_custom_dark.json` (or copy to themes folder). Theme appears in picker; UI uses custom primary (`#38BDF8`). - [ ] **Import valid custom light** — import `test/fixtures/themes/querya_custom_light.json`. App switches to light brightness; readable text on cards and sidebar. - [ ] **Import VS Code JSONC** — import `test/fixtures/themes/querya_custom_jsonc.jsonc` or `themes/samples/cyberpunk-neon.jsonc`. Parser accepts comments/trailing commas; theme applies without crash. - [ ] **Picker with many themes** — install 50+ themes (copy fixtures with unique ids, or duplicate renamed files) → open theme picker: no overflow, list scrolls, search filters rows. @@ -25,6 +25,13 @@ Use **Preferences → Appearance** unless noted. Fixtures for copy/import tests - [ ] **Title bar / window controls** — switch Querya Dark, Querya Light, Cyberpunk Neon, and a custom theme: title bar background and minimize/maximize/close hover colors track the active theme. - [ ] **SQL / JSON syntax** — open SQL editor with a theme that defines `tokenColors` (e.g. cyberpunk sample): comments, keywords, and strings use distinct colors; changing theme updates highlighting after editor refresh. +## Theme follow-ups 0.4.3 (manual QA) + +- [ ] **File watcher (TP-F1)** — copy a valid theme JSON into `{appSupport}/themes/` via file manager (no **Refresh themes**): new theme appears in picker within a few seconds. +- [ ] **Marketplace metadata (TP-F2)** — theme with `author` / `tags` in manifest shows subtitle in picker; search matches tag text. +- [ ] **Visual theme editor (TP-F3)** — open **Theme editor**, change a workbench color, confirm live preview; **Export** writes valid `querya.theme.v1` JSON; import exported file applies the same colors. +- [ ] **Remote install (TP-F4)** — **Install from URL…** with a public HTTPS theme JSON (optional SHA-256): theme imports and appears in picker; `http://` or localhost URL is rejected with a clear error. + ## Automated - [ ] `flutter analyze` — clean (on Linux, if the analyzer crashes with **Too many open files**, try `ulimit -n 8192`; see [CONTRIBUTING.md](../CONTRIBUTING.md)). @@ -33,8 +40,8 @@ Use **Preferences → Appearance** unless noted. Fixtures for copy/import tests ## Versioning and release -- [ ] `pubspec.yaml` on **`dev`** is **`0.4.1+7`** before merging to `main` (auto version-bump sets **`0.4.2+8`** on `main`). -- [ ] After merge, confirm GitHub Action **Auto Version Bump** committed **`0.4.2+…`** on `main`. +- [ ] `pubspec.yaml` on **`dev`** is **`0.4.1+7`** before merging to `main` (auto version-bump sets **`0.4.3+9`** on `main`). +- [ ] After merge, confirm GitHub Action **Auto Version Bump** committed **`0.4.3+…`** on `main`. - [ ] **Tag** is placed on the **commit that includes all fixes** you want in binaries (a tag does not auto-include later commits; see [CONTRIBUTING.md](../CONTRIBUTING.md)). - [ ] Run the **Release** workflow from GitHub Actions (see [tags-and-releases.md](tags-and-releases.md)). - [ ] Verify **Linux** and **Windows** zip artifacts and `SHA256SUMS.txt` on the GitHub Release. diff --git a/docs/roadmap.md b/docs/roadmap.md index 88049615..9107896e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,7 +8,9 @@ Living document for planned work. Not a commitment order; adjust as priorities c highlighting, P0 workbench migration, Preferences, tests, docs — [theme.md](theme.md). - **Shipped in 0.4.1 ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93)):** UI performance — virtual result grid, lazy connection tree, decoupled scale preview, stats polling, MySQL stats dashboard, local `docker/` dev stack — [perf-baseline.md](perf-baseline.md). - **Shipped in 0.4.2 (TP-01–TP-30, #96–#125):** custom theme registry — `querya.theme.v1` + VS Code JSON/JSONC scan, Theme picker (50+), import/refresh, built-in Cyberpunk Neon asset, startup fallback, window chrome sync — [theme-custom-json.md](theme-custom-json.md), [theme-import.md](theme-import.md). -- **Planned 0.4.3:** theme follow-ups (file watcher, marketplace metadata, visual editor, remote install) — [planned-0.4.3.md](planned-0.4.3.md), epic [#159](https://github.com/QueryaHub/Querya-Desktop/issues/159), milestone [0.4.3](https://github.com/QueryaHub/Querya-Desktop/milestone/2). +- **Shipped in 0.4.3 (TP-F1–TP-F4, #159–#163):** theme folder watcher, marketplace metadata on manifests, visual theme editor with export, HTTPS remote install with checksum — [planned-0.4.3.md](planned-0.4.3.md). +- **Planned 0.4.4:** UI motion polish + high refresh rate (90/120/144 Hz) — [planned-0.4.4.md](planned-0.4.4.md), [motion-and-high-refresh.md](motion-and-high-refresh.md). +- **Planned 0.4.5+:** Extensions sidebar and marketplace Explore UI — [market-tech.md](market-tech.md). - **Optional:** Preferences → **Animate theme changes** (off by default). - **Later:** P2 Mongo/Redis token colors; `re_editor` if perf gap; LSP epic per [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). diff --git a/docs/theme-import.md b/docs/theme-import.md index 6727d708..b01e1189 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -59,7 +59,9 @@ In **Preferences → Appearance**: - **Theme mode** — Dark / Light / System - **Theme** — built-in presets, bundled themes, and themes from the user themes folder - **Import theme…** — pick `.json` / `.jsonc` and copy into the themes folder -- **Refresh themes** — rescan the themes folder (no live file watcher) +- **Refresh themes** — rescan the themes folder (also runs automatically when files change; see below) +- **Install from URL…** — download a theme over HTTPS with optional SHA-256 checksum +- **Theme editor** — tweak workbench colors with live preview and export `querya.theme.v1` - **Open themes folder** — reveal the app support `themes/` directory in the file manager - **Reset appearance** — clears overrides and returns to Querya Dark @@ -83,9 +85,9 @@ Querya loads themes from the **application support** directory (see | macOS | `~/Library/Application Support/com.example.querya_desktop/themes` | | Windows | `%APPDATA%\com.example.querya_desktop\themes` | -**Workflow:** copy or import a theme file into `themes/`, then click **Refresh themes** -in Preferences. The app does **not** watch the folder; a restart is not required after -refresh. +**Workflow:** copy or import a theme file into `themes/`. A **file watcher** (0.4.3+) +debounces changes under `themes/` and refreshes the registry automatically; use +**Refresh themes** if the list looks stale. A restart is not required after refresh. **Accepted extensions:** `.json`, `.jsonc` (comments and trailing commas stripped before parse). diff --git a/docs/theme-parser-github-issues.md b/docs/theme-parser-github-issues.md index dbe4add9..55c9dcc6 100644 --- a/docs/theme-parser-github-issues.md +++ b/docs/theme-parser-github-issues.md @@ -1575,7 +1575,7 @@ Add QA checklist: ## Optional follow-up issues These are intentionally out of the first implementation pass (**shipped in 0.4.2**). -**Target milestone: 0.4.3** ([milestone](https://github.com/QueryaHub/Querya-Desktop/milestone/2), epic **#159**) — see [planned-0.4.3.md](planned-0.4.3.md). +**Shipped in 0.4.3** ([milestone](https://github.com/QueryaHub/Querya-Desktop/milestone/2), epic **#159**) — see [planned-0.4.3.md](planned-0.4.3.md). ### TP-F1 — File watcher for user themes folder ([#160](https://github.com/QueryaHub/Querya-Desktop/issues/160)) From 88258b41169f41d4c844d11217e7ea2f18d0f5d0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 12:31:37 +0300 Subject: [PATCH 7/8] docs: add 0.4.4 motion polish and high refresh rate research MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document current animation audit, per-platform Hz behavior, and UI-A1–A6 plan for smoother transitions and 90/120/144 Hz support. --- docs/motion-and-high-refresh.md | 144 ++++++++++++++++++++++++++++++++ docs/planned-0.4.4.md | 38 +++++++++ 2 files changed, 182 insertions(+) create mode 100644 docs/motion-and-high-refresh.md create mode 100644 docs/planned-0.4.4.md diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md new file mode 100644 index 00000000..09ddb00c --- /dev/null +++ b/docs/motion-and-high-refresh.md @@ -0,0 +1,144 @@ +# Motion system and high-refresh-rate support (0.4.4 research) + +**Status:** research / design for **0.4.4**. +**Scope:** Querya Desktop is **desktop-only** (Linux, Windows, macOS — see [`linux/`](../linux), [`windows/`](../windows), [`macos/`](../macos)). Toolchain at time of writing: **Flutter 3.41.6 stable**, Impeller engine. + +Goal of 0.4.4: make every animation **smooth and pleasant**, driven by a single motion system, and make the app actually render at the display's **native refresh rate (90/120/144 Hz)** on each OS instead of being capped at 60. + +--- + +## 1. Why this matters + +Two independent problems are often confused: + +1. **Frame rate (Hz)** — how many frames the engine renders per second. If the app is locked to 60 Hz on a 120 Hz monitor, *every* animation looks half as smooth no matter how good the curves are. +2. **Motion design** — the durations, curves, and choreography of each animation. Even at 120 Hz, a linear 100 ms snap feels cheap; a well-tuned eased 180 ms feels premium. + +0.4.4 must fix **both**. High-Hz is the multiplier; the motion system is the quality. + +Important Flutter fact: animations are **vsync/ticker driven and frame-rate independent**. A `Duration(milliseconds: 200)` plays over 200 ms of wall-clock time and is interpolated **per frame**. So at 120 Hz the *same* animation simply gets twice as many in-between frames — no code change to durations is needed for high-Hz smoothness. The only requirement is that the engine is told it may render faster than 60. + +--- + +## 2. Current state audit (codebase) + +There is **no central motion system** today. Animations are scattered, with inconsistent durations and curves, and **no reduced-motion / accessibility handling** anywhere (`grep` for `disableAnimations` / `accessibleNavigation` → 0 matches). + +| Location | What animates | Duration | Curve | +|----------|---------------|----------|-------| +| `lib/shared/widgets/app_dialog.dart` | Dialog fade + scale (0.92→1.0) + backdrop blur (8σ) | 200 ms | `easeOutCubic` | +| `lib/shared/widgets/querya_dropdown.dart` | Trigger + menu hover background | `QueryaDropdownTokens.hoverAnimationMs` | `easeOut` | +| `lib/features/settings/theme_picker_button.dart` | Hover containers; preview debounce 120 ms | 120 ms / debounce | `easeOut` | +| `lib/features/main_screen/workspace_panel.dart` | Tab/area container + `AnimatedScale` | 120 ms / 100 ms | `easeOut` | +| `lib/features/connections/connections_panel_pg_tree.dart` | Chevron `AnimatedRotation`; tooltip wait 450 ms | 100 ms | (default) | +| `lib/features/connections/connections_panel_{sidebar,mysql,mongo,redis,postgres_connection}.dart` | Row hover containers | 100 ms | (default/`easeOut`) | +| `lib/features/{mysql,postgresql}/*_workspace_home.dart` | Card hover containers | 120 ms | `easeOut` | +| `lib/features/connections/new_connection_dialog.dart` | Type-card hover | 120 ms | `easeOut` | +| `lib/core/theme/theme_controller.dart` | `ShadcnAnimatedTheme` (theme cross-fade) | engine default | — | +| `lib/features/main_screen/result_grid_view.dart` | Tooltip wait 400 ms | — | — | + +### Findings + +- **Inconsistent durations:** 100 ms vs 120 ms vs 200 ms for conceptually similar interactions (hover, expand, dialog). +- **Curve monoculture:** almost everything is `Curves.easeOut`; no distinction between *enter* (decelerate), *exit* (accelerate), and *emphasized* motion. +- **No tokens:** magic `Duration(...)` literals repeated ~20 places. Only `QueryaDropdownTokens` partially tokenizes one widget. +- **No reduced-motion support:** users who set "reduce motion" at the OS level still get all animations. +- **Theme switch** is the only "big" transition and it is off by default (`themeAnimationEnabled = false`). +- **No expand/collapse height animation** on tree nodes — they pop in/out (`if (_expanded) ...`), only the chevron rotates. + +--- + +## 3. How high refresh rate works in Flutter (per platform) + +Summary of current engine behavior (Flutter 3.24+ / Impeller). Sources in §7. + +| Platform | Renders at display Hz by default? | How to unlock > 60 Hz | Notes | +|----------|-----------------------------------|------------------------|-------| +| **Windows** | Usually yes (follows monitor via DWM) | No app API needed | Verify on a 120/144 Hz monitor; engine vsyncs to the compositor. | +| **Linux** | Depends on compositor (GTK embedder) | No app API; compositor-dependent | Wayland compositors with VRR may need monitor config; X11 follows monitor. | +| **macOS** | Capped to 60 on some setups | macOS 14+ can opt into ProMotion/high-Hz | Historically Flutter macOS did not always hit ProMotion; needs verification. | +| **iOS** (future) | No — capped to 60 | `CADisableMinimumFrameDurationOnPhone=true` in `Info.plist` | Not applicable today (no `ios/`), document for when mobile lands. | +| **Android** (future) | No — often picks 60 | `flutter_displaymode` / `Surface.setFrameRate()` | Not applicable today (no `android/`). | + +Root cause (engine): per `flutter/flutter#160952`, the engine "can render at 120 Hz (Impeller since 3.24) but never tells the OS compositor it can handle more" on several platforms. Community package **`refresh_rate`** (pub.dev) works around this and additionally provides **query / live FPS overlay / benchmark** on all six platforms, with actual *unlock* on Android, iOS 15+, and macOS 14+. + +### Practical implication for Querya (desktop) + +- **Windows / Linux:** most likely already render at monitor Hz; the job is to **measure and verify**, then ensure no app-side code caps frames (e.g. heavy `setState`, unbounded rebuilds during animation). +- **macOS:** the real high-Hz work — confirm ProMotion behavior; unlock via `refresh_rate` if capped at 60. +- Use `refresh_rate` (or a thin wrapper) primarily for **diagnostics**: a debug-only FPS/Hz overlay and a benchmark to prove smoothness on each machine, plus the macOS unlock call in `main()`. + +--- + +## 4. Proposed motion design system + +Introduce `lib/core/motion/` with a single source of truth for durations and curves, scaled by accessibility settings. + +### 4.1 Duration tokens (`QueryaMotion`) + +| Token | Value | Use | +|-------|-------|-----| +| `instant` | 0 ms | reduced-motion / disabled | +| `fast` | 120 ms | hover, small state changes | +| `standard` | 200 ms | dialogs, menus, expand/collapse | +| `slow` | 320 ms | emphasized / large surfaces, theme cross-fade | + +### 4.2 Curve tokens + +| Token | Curve | Use | +|-------|-------|-----| +| `enter` | `easeOutCubic` | elements appearing (decelerate) | +| `exit` | `easeInCubic` | elements leaving (accelerate) | +| `standard` | `easeInOutCubic` | move/resize in place | +| `emphasized` | `Curves.easeInOutCubicEmphasized` | hero / theme transitions | + +### 4.3 Reduced motion / accessibility + +- Read `MediaQuery.disableAnimationsOf(context)` (OS "reduce motion") and an optional in-app Preferences toggle. +- When reduced: collapse all durations to `instant` (or a short cross-fade), never fully remove feedback. +- Provide a helper `context.motion(Token)` that returns the effective duration after applying the reduced-motion factor. + +### 4.4 Targeted animation upgrades + +- **Dialogs** (`app_dialog.dart`): keep the blur+scale pattern, retune to `standard`/`enter`; ensure backdrop and card share one curve. +- **Dropdowns / menus**: add an enter scale+fade (currently only hover color), `standard`/`enter`. +- **Tree expand/collapse**: animate height with `AnimatedSize` (+ chevron rotation already present) instead of pop-in. +- **Tab / workspace switches**: cross-fade content via `AnimatedSwitcher` with `standard`. +- **Hover states**: unify all row/card hovers to `fast`/`standard` curve. +- **Theme switch**: enable a tasteful `emphasized` cross-fade and consider making it on-by-default. +- **List/grid item insertion** (results, history): subtle staggered fade-in for first paint only (no per-scroll cost). + +--- + +## 5. Implementation plan (proposed issues) + +Milestone **0.4.4** (see [planned-0.4.4.md](planned-0.4.4.md)). + +1. **UI-A1 — Motion tokens core.** `lib/core/motion/` with `QueryaMotion` durations/curves + `context.motion()` reduced-motion helper. Unit tests. +2. **UI-A2 — Adopt tokens across widgets.** Replace magic `Duration(...)`/`Curves.easeOut` literals in dialogs, dropdowns, tree, workspace panel, connection forms. No behavior regressions in layout tests. +3. **UI-A3 — Smoother transitions.** Dialog retune, dropdown enter animation, tree `AnimatedSize` expand/collapse, tab `AnimatedSwitcher`. +4. **UI-A4 — High-refresh-rate enablement.** Add `refresh_rate` (or wrapper); unlock on macOS 14+ in `main()`; query active Hz; debug-only FPS/Hz overlay behind a flag. +5. **UI-A5 — Reduced-motion + Preferences.** Honor OS "reduce motion"; add **Preferences → Appearance → Motion** (Full / Reduced / Off) wired to the motion helper. +6. **UI-A6 — Measurement & docs.** DevTools timeline checklist in [perf-baseline.md](perf-baseline.md); per-OS Hz verification table; update this doc with measured results. + +Suggested order: A1 → A2 → A3 in parallel with A4; then A5; A6 closes the milestone. + +--- + +## 6. Testing & measurement + +- **DevTools → Performance / Frame chart:** confirm frame build/raster times stay under the budget at the monitor's Hz (8.3 ms @ 120 Hz, 6.9 ms @ 144 Hz). +- **`refresh_rate` overlay / benchmark:** prove the real on-device Hz before/after; capture numbers per OS. +- **Reduced-motion test:** widget test that durations collapse when `disableAnimations: true` is injected via `MediaQuery`. +- **Regression:** existing layout/overflow tests must stay green; animations must not change final layout geometry. + +--- + +## 7. References + +- Flutter engine — high refresh rate gap: `flutter/flutter#160952`, `#90675` (ProMotion scrolling), `#94508` (`CADisableMinimumFrameDurationOnPhone` default). +- `refresh_rate` package (query/unlock/overlay/benchmark, all platforms): https://pub.dev/packages/refresh_rate +- `flutter_displaymode` (Android high-Hz): https://pub.dev/packages/flutter_displaymode +- Apple — Optimizing for ProMotion: https://developer.apple.com/documentation/quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays +- Flutter blog — iOS variable refresh rate (Flutter 3): https://blog.flutter.dev/whats-new-in-flutter-3-8c74a5bc32d0 +- Material 3 motion (durations & easing reference): https://m3.material.io/styles/motion/overview diff --git a/docs/planned-0.4.4.md b/docs/planned-0.4.4.md new file mode 100644 index 00000000..ebd9c3ba --- /dev/null +++ b/docs/planned-0.4.4.md @@ -0,0 +1,38 @@ +# Planned release 0.4.4 — UI motion polish and high refresh rate + +**Status:** planning. +**Depends on:** **0.4.3** theme follow-ups (shipped). +**Design doc:** [motion-and-high-refresh.md](motion-and-high-refresh.md) — research, current-state audit, and per-platform Hz behavior. + +Theme: make every animation **smooth and pleasant** through one motion system, and make the app render at the display's **native refresh rate (90/120/144 Hz)** on Linux, Windows, and macOS instead of being capped at 60. + +## Why + +- Animations today are ad-hoc: inconsistent durations (100 / 120 / 200 ms), a single `easeOut` curve everywhere, ~20 magic `Duration(...)` literals, and **no reduced-motion handling**. +- Flutter animations are vsync-driven and frame-rate independent, so the smoothness win comes from (a) telling the engine it may exceed 60 Hz and (b) a cohesive motion design — see [motion-and-high-refresh.md §1–3](motion-and-high-refresh.md). + +## Scope + +| ID | Scope | Summary | +|----|-------|---------| +| **UI-A1** | `motion`, `core` | **Motion tokens** — `lib/core/motion/` with `QueryaMotion` durations/curves and a `context.motion()` reduced-motion helper. | +| **UI-A2** | `motion`, `ui` | **Adopt tokens** — replace magic durations/curves in dialogs, dropdowns, tree, workspace panel, connection forms (no layout regressions). | +| **UI-A3** | `motion`, `ui` | **Smoother transitions** — dialog retune, dropdown enter animation, `AnimatedSize` tree expand/collapse, `AnimatedSwitcher` tab/content cross-fade. | +| **UI-A4** | `performance`, `platform` | **High refresh rate** — `refresh_rate` (or wrapper): unlock on macOS 14+ in `main()`, query active Hz, debug-only FPS/Hz overlay. Verify Windows/Linux follow the monitor. | +| **UI-A5** | `accessibility`, `settings` | **Reduced motion** — honor OS "reduce motion"; **Preferences → Appearance → Motion** (Full / Reduced / Off). | +| **UI-A6** | `docs`, `performance` | **Measurement & docs** — DevTools budget checklist (8.3 ms @120 Hz), per-OS Hz table, update design doc with measured results. | + +## Suggested PR order + +1. UI-A1 — motion tokens core (+ tests) +2. UI-A2 — adopt tokens across widgets +3. UI-A3 — smoother transitions (parallel with A4) +4. UI-A4 — high-refresh-rate enablement + debug overlay +5. UI-A5 — reduced-motion + Preferences toggle +6. UI-A6 — measurement, perf-baseline update, close milestone + +## Out of scope for 0.4.4 + +- **Extensions sidebar and marketplace Explore UI** — deferred to **0.4.5+** (`ExtensionManifest` stub and `ThemeRemoteInstallService` from 0.4.3 remain the foundation); see [market-tech.md](market-tech.md). +- Non-theme extension types (drivers, SQL snippets). +- Mobile (iOS/Android) high-Hz setup — documented for the future in [motion-and-high-refresh.md §3](motion-and-high-refresh.md) but no `ios/`/`android/` targets exist yet. From c4c99f1e9b236bf81b1867a12fabb7906243b91f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 12:33:23 +0300 Subject: [PATCH 8/8] ci(release): auto-attach CHANGELOG section to GitHub Release Extract the matching ## [X.Y.Z] block from CHANGELOG.md during publish and append downloads/build info. Release fails if the section is missing. --- .github/workflows/release.yml | 35 +++++++++++++++++----------- docs/release-checklist.md | 2 +- docs/tags-and-releases.md | 24 ++++++++++++++----- scripts/extract-changelog-section.sh | 33 ++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 20 deletions(-) create mode 100755 scripts/extract-changelog-section.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d53fbed7..040d7816 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -192,6 +192,27 @@ jobs: cd dist sha256sum *.zip | tee SHA256SUMS.txt + - name: Build release notes from CHANGELOG.md + run: | + set -euo pipefail + VERSION="${{ needs.build-windows.outputs.version }}" + ./scripts/extract-changelog-section.sh "$VERSION" > release-notes.md + { + echo "" + echo "---" + echo "" + echo "### Downloads" + echo "- **Linux**: \`Querya-Desktop-${VERSION}-linux.zip\`" + echo "- **Windows**: \`Querya-Desktop-${VERSION}-windows.zip\`" + echo "- **macOS**: \`Querya-Desktop-${VERSION}-macos.zip\` (unsigned \`.app\` in zip; right-click → Open on first launch)" + echo "" + echo "Verify checksums: \`SHA256SUMS.txt\`" + echo "" + echo "### Build info" + echo "- **pubspec**: ${{ needs.build-windows.outputs.full_version }}" + echo "- **Commit**: ${{ github.sha }}" + } >> release-notes.md + - name: Release tag name id: rel run: | @@ -206,19 +227,7 @@ jobs: with: tag_name: ${{ steps.rel.outputs.tag }} name: Querya Desktop ${{ needs.build-windows.outputs.version }} - body: | - ## Querya Desktop ${{ needs.build-windows.outputs.version }} - - ### Downloads - - **Linux**: `Querya-Desktop-${{ needs.build-windows.outputs.version }}-linux.zip` - - **Windows**: `Querya-Desktop-${{ needs.build-windows.outputs.version }}-windows.zip` - - **macOS**: `Querya-Desktop-${{ needs.build-windows.outputs.version }}-macos.zip` (unsigned `.app` in zip; right-click → Open on first launch) - - Verify checksums: `SHA256SUMS.txt` - - ### Build info - - **pubspec**: ${{ needs.build-windows.outputs.full_version }} - - **Commit**: ${{ github.sha }} + body_path: release-notes.md fail_on_unmatched_files: true files: | dist/*.zip diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 98df2eb6..61cf017a 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -48,6 +48,6 @@ Use **Preferences → Appearance** unless noted. Fixtures for copy/import tests ## Docs +- [ ] [CHANGELOG.md](../CHANGELOG.md) has a **`## [X.Y.Z]`** section for the release version (CI copies it into the GitHub Release body). - [ ] [security.md](security.md) still matches behavior if storage changed. -- [ ] [README.md](../README.md) prerequisites (e.g. Linux deps) still accurate. - [ ] [roadmap.md](roadmap.md) updated if you are communicating upcoming themes externally. diff --git a/docs/tags-and-releases.md b/docs/tags-and-releases.md index 105ce0b7..362912c6 100644 --- a/docs/tags-and-releases.md +++ b/docs/tags-and-releases.md @@ -25,15 +25,27 @@ - Имена архивов: `Querya-Desktop-X.Y.Z-linux.zip`, `Querya-Desktop-X.Y.Z-windows.zip`, `Querya-Desktop-X.Y.Z-macos.zip` (внутри неподписанный `.app`). - Версия для имён и тега — **semver из pubspec**; build `+N` попадает в текст релиза как **полный pubspec version**. -## Changelog (git-cliff) +## Changelog в GitHub Release -- В репозитории есть [cliff.toml](../cliff.toml) — его можно использовать **локально** для черновика release notes: +При публикации релиза workflow **[Release](../.github/workflows/release.yml)** автоматически берёт секцию из [CHANGELOG.md](../CHANGELOG.md) для semver из `pubspec` (например `## [0.4.3]`). Скрипт: [scripts/extract-changelog-section.sh](../scripts/extract-changelog-section.sh). - ```bash - git-cliff --latest --strip header - ``` +Перед тегом убедитесь, что в `CHANGELOG.md` есть секция для этой версии — иначе job **Publish GitHub Release** упадёт. -- Генерация changelog **не подключена** к `release.yml`; при необходимости вставьте вывод git-cliff в описание релиза вручную на GitHub или расширьте workflow. +Локальная проверка: + +```bash +./scripts/extract-changelog-section.sh 0.4.3 +``` + +### git-cliff (черновик) + +Для черновика release notes из коммитов можно использовать [cliff.toml](../cliff.toml) локально: + +```bash +git-cliff --latest --strip header +``` + +Генерация git-cliff **не подключена** к `release.yml`; источник правды для релизов — **CHANGELOG.md**. ## Где смотреть настройки diff --git a/scripts/extract-changelog-section.sh b/scripts/extract-changelog-section.sh new file mode 100755 index 00000000..5781511f --- /dev/null +++ b/scripts/extract-changelog-section.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Extract one Keep a Changelog section from CHANGELOG.md by semver (e.g. 0.4.3 or v0.4.3). +set -euo pipefail + +VERSION="${1:?usage: extract-changelog-section.sh [changelog-file]}" +CHANGELOG="${2:-CHANGELOG.md}" + +VERSION="${VERSION#v}" + +if [[ ! -f "$CHANGELOG" ]]; then + echo "error: changelog not found: $CHANGELOG" >&2 + exit 1 +fi + +awk -v ver="$VERSION" ' +BEGIN { found = 0; capture = 0 } +/^## \[/ { + if (capture) exit + if ($0 ~ "^## \\[" ver "\\]") { + found = 1 + capture = 1 + print + next + } +} +capture { print } +END { + if (!found) { + print "error: no changelog section for version " ver " in " FILENAME > "/dev/stderr" + exit 1 + } +} +' "$CHANGELOG"