mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-04 12:33:08 +00:00
feat(desktop): flag already-installed themes in the install pickers
The Cmd-K "Install theme…" palette listed Marketplace themes with no hint that you already had them, and clicking one re-downloaded + re-installed a theme you owned. The Appearance settings grid already detected this, but by parsing theme descriptions inline on every render — plumbing that never made it to the palette. Lift it into one reactive source and reuse it everywhere: - $marketplaceInstalls (computed over $userThemes): extensionId -> installed theme, derived once via marketplaceIdOf and memoized, instead of rebuilding a Set per render. - Both install surfaces now mark owned rows installed and, on click, re-activate the installed theme rather than re-fetching it. - Drops the duplicated description-parsing in settings and the per-session "installed here" state in both surfaces (the store is the source of truth, so previously-installed themes show correctly too).
This commit is contained in:
parent
05ac16778b
commit
04639adace
4 changed files with 122 additions and 38 deletions
|
|
@ -8,6 +8,7 @@
|
|||
* user can grab several.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { Check, Download, Loader2, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { $marketplaceInstalls } from '@/themes/user-themes'
|
||||
|
||||
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
|
|
@ -43,8 +45,8 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
|||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.installTheme
|
||||
const debouncedSearch = useDebounced(search.trim(), 300)
|
||||
const installs = useStore($marketplaceInstalls)
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [installed, setInstalled] = useState<Record<string, true>>({})
|
||||
const [installError, setInstallError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
|
|
@ -53,6 +55,20 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
|||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
// Already installed → just re-activate it; never re-download what we have.
|
||||
const select = (item: DesktopMarketplaceSearchItem) => {
|
||||
const owned = installs.get(item.extensionId)
|
||||
|
||||
if (owned) {
|
||||
triggerHaptic('crisp')
|
||||
onPickTheme(owned.name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void install(item)
|
||||
}
|
||||
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
|
|
@ -65,7 +81,6 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
|||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
|
||||
onPickTheme(theme.name)
|
||||
} catch (error) {
|
||||
setInstallError(error instanceof Error ? error.message : copy.error)
|
||||
|
|
@ -93,7 +108,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
|||
{installError && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installed[item.extensionId]
|
||||
const done = installs.has(item.extensionId)
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -104,7 +119,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
|||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => void install(item)}
|
||||
onClick={() => select(item)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
|||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { getBaseColors, useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { isUserTheme, removeUserTheme } from '@/themes/user-themes'
|
||||
import type { DesktopTheme } from '@/themes/types'
|
||||
import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/user-themes'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { PetSettings } from './pet-settings'
|
||||
|
|
@ -82,18 +83,17 @@ const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', ma
|
|||
*/
|
||||
function MarketplaceThemeResults({
|
||||
query,
|
||||
installedExtIds,
|
||||
installs,
|
||||
onInstalled
|
||||
}: {
|
||||
query: string
|
||||
installedExtIds: Set<string>
|
||||
installs: ReadonlyMap<string, DesktopTheme>
|
||||
onInstalled: (name: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.installTheme
|
||||
const debounced = useDebounced(query.trim(), 300)
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [installedHere, setInstalledHere] = useState<Record<string, true>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const search = useQuery({
|
||||
|
|
@ -103,6 +103,20 @@ function MarketplaceThemeResults({
|
|||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
// Already installed → just re-activate it; never re-download what we have.
|
||||
const select = (item: DesktopMarketplaceSearchItem) => {
|
||||
const owned = installs.get(item.extensionId)
|
||||
|
||||
if (owned) {
|
||||
triggerHaptic('crisp')
|
||||
onInstalled(owned.name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void install(item)
|
||||
}
|
||||
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
|
|
@ -115,7 +129,6 @@ function MarketplaceThemeResults({
|
|||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setInstalledHere(prev => ({ ...prev, [item.extensionId]: true }))
|
||||
onInstalled(theme.name)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : copy.error)
|
||||
|
|
@ -173,7 +186,7 @@ function MarketplaceThemeResults({
|
|||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId)
|
||||
const done = installs.has(item.extensionId)
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -183,7 +196,7 @@ function MarketplaceThemeResults({
|
|||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => void install(item)}
|
||||
onClick={() => select(item)}
|
||||
type="button"
|
||||
>
|
||||
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
|
||||
|
|
@ -220,6 +233,7 @@ export function AppearanceSettings() {
|
|||
const embedMode = useStore($embedMode)
|
||||
const embedAllowed = useStore($embedAllowed)
|
||||
const translucency = useStore($translucency)
|
||||
const installs = useStore($marketplaceInstalls)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
|
|
@ -242,20 +256,6 @@ export function AppearanceSettings() {
|
|||
// Active theme first; stable sort keeps the rest in their original order.
|
||||
.sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName))
|
||||
|
||||
// Marketplace imports describe themselves as "VS Code · <publisher.extension>";
|
||||
// pull those ids back out so search results already imported show as installed.
|
||||
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
|
||||
|
||||
const installedExtIds = new Set(
|
||||
availableThemes
|
||||
.map(theme =>
|
||||
theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
|
||||
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
// Themes save per profile. Surface that only when the user actually has more
|
||||
// than one profile (single-profile installs never see the distinction).
|
||||
const showProfileNote = profiles.length > 1
|
||||
|
|
@ -365,11 +365,7 @@ export function AppearanceSettings() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
<MarketplaceThemeResults
|
||||
installedExtIds={installedExtIds}
|
||||
onInstalled={name => setTheme(name)}
|
||||
query={query}
|
||||
/>
|
||||
<MarketplaceThemeResults installs={installs} onInstalled={name => setTheme(name)} query={query} />
|
||||
</div>
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,27 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
|
||||
import { $userThemes, installUserTheme, isUserTheme, listAllThemes, removeUserTheme, resolveTheme } from './user-themes'
|
||||
import {
|
||||
$marketplaceInstalls,
|
||||
$userThemes,
|
||||
installUserTheme,
|
||||
isUserTheme,
|
||||
listAllThemes,
|
||||
marketplaceIdOf,
|
||||
removeUserTheme,
|
||||
resolveTheme
|
||||
} from './user-themes'
|
||||
import { convertVscodeColorTheme } from './vscode'
|
||||
|
||||
const makeTheme = (label: string) =>
|
||||
convertVscodeColorTheme({
|
||||
name: label,
|
||||
type: 'dark',
|
||||
colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }
|
||||
}).theme
|
||||
const makeTheme = (label: string, source?: string) =>
|
||||
convertVscodeColorTheme(
|
||||
{
|
||||
name: label,
|
||||
type: 'dark',
|
||||
colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }
|
||||
},
|
||||
source ? { source } : undefined
|
||||
).theme
|
||||
|
||||
describe('user theme registry', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -61,3 +73,33 @@ describe('user theme registry', () => {
|
|||
expect(() => installUserTheme(broken)).toThrow(/colors/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('marketplace install tracking', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
$userThemes.set({})
|
||||
})
|
||||
|
||||
it('recovers the extension id only from Marketplace-sourced themes', () => {
|
||||
expect(marketplaceIdOf(makeTheme('Dracula', 'dracula-theme.theme-dracula'))).toBe('dracula-theme.theme-dracula')
|
||||
// A pasted (non-Marketplace) import has no extension id to report.
|
||||
expect(marketplaceIdOf(makeTheme('Pasted'))).toBeNull()
|
||||
})
|
||||
|
||||
it('maps installed Marketplace extension ids to their theme, reactively', () => {
|
||||
expect($marketplaceInstalls.get().size).toBe(0)
|
||||
|
||||
const theme = installUserTheme(makeTheme('Dracula', 'dracula-theme.theme-dracula'))
|
||||
const map = $marketplaceInstalls.get()
|
||||
|
||||
expect(map.get('dracula-theme.theme-dracula')).toEqual(theme)
|
||||
|
||||
removeUserTheme(theme.name)
|
||||
expect($marketplaceInstalls.get().has('dracula-theme.theme-dracula')).toBe(false)
|
||||
})
|
||||
|
||||
it('omits pasted imports (no extension id) from the map', () => {
|
||||
installUserTheme(makeTheme('Pasted'))
|
||||
expect($marketplaceInstalls.get().size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,13 +10,18 @@
|
|||
* React mounts) can resolve a user theme synchronously, same as built-ins.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { BUILTIN_THEMES } from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
|
||||
const USER_THEMES_KEY = 'hermes-desktop-user-themes-v1'
|
||||
|
||||
// Marketplace imports stamp their description "VS Code · <publisher.extension>"
|
||||
// (see `convertVscodeColorTheme`). This is the one place that convention is read
|
||||
// back out, so every install surface can tell what's already installed.
|
||||
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
|
||||
|
||||
// The minimal set of color keys a stored theme must carry to be usable. We keep
|
||||
// this loose — `applyTheme` tolerates missing optionals via fallbacks — but a
|
||||
// theme with no background/foreground/primary is junk and gets dropped.
|
||||
|
|
@ -111,6 +116,32 @@ export function removeUserTheme(name: string): void {
|
|||
|
||||
export const isUserTheme = (name: string): boolean => Boolean($userThemes.get()[name])
|
||||
|
||||
/** The Marketplace extension id an installed theme came from, or null. */
|
||||
export function marketplaceIdOf(theme: DesktopTheme): string | null {
|
||||
return theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
|
||||
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive `extensionId → installed theme` map, so the install UIs can mark
|
||||
* Marketplace rows you already have (and re-activate them without re-downloading)
|
||||
* from one memoized source instead of re-deriving the set on every render.
|
||||
*/
|
||||
export const $marketplaceInstalls = computed($userThemes, themes => {
|
||||
const map = new Map<string, DesktopTheme>()
|
||||
|
||||
for (const theme of Object.values(themes)) {
|
||||
const id = marketplaceIdOf(theme)
|
||||
|
||||
if (id) {
|
||||
map.set(id, theme)
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
/** Resolve a theme by name across the merged registry (built-in + user). */
|
||||
export function resolveTheme(name: string): DesktopTheme | undefined {
|
||||
return BUILTIN_THEMES[name] ?? $userThemes.get()[name]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue