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" + } +}