Merge pull request #112 from y574444354/opt/mcp-detector

fix(favorite): sync load/unload state and improve error visibility
This commit is contained in:
linkai0924 2026-05-15 16:45:49 +08:00 committed by GitHub
commit d98b4d0cde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 171 additions and 97 deletions

View File

@ -1,99 +1,93 @@
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text, Dialog } from '@anthropic/ink' import { Box, Text, Dialog } from '@anthropic/ink';
import { SelectMulti } from '../../components/CustomSelect/SelectMulti.js' import { SelectMulti } from '../../components/CustomSelect/SelectMulti.js';
import { import {
listFavoriteItems, listFavoriteItems,
loadFavoriteItem, loadFavoriteItem,
unloadFavoriteItem, unloadFavoriteItem,
type FavoriteItemWithStatus, type FavoriteItemWithStatus,
} from '../../costrict/favorite/favorite.js' } from '../../costrict/favorite/favorite.js';
import type { LocalJSXCommandCall } from '../../types/command.js' import type { LocalJSXCommandCall } from '../../types/command.js';
function CloudEnabledMenu({ function CloudEnabledMenu({
onDone, onDone,
}: { }: {
onDone: ( onDone: (result?: string, options?: { display?: 'skip' | 'system' | 'user' }) => void;
result?: string,
options?: { display?: 'skip' | 'system' | 'user' },
) => void
}): React.ReactNode { }): React.ReactNode {
const [items, setItems] = useState<FavoriteItemWithStatus[] | null>(null) const [items, setItems] = useState<FavoriteItemWithStatus[] | null>(null);
const previousSlugs = useRef<Set<string>>(new Set()) const previousSlugs = useRef<Set<string>>(new Set());
const isFirstChange = useRef(true) const isFirstChange = useRef(true);
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false;
listFavoriteItems() listFavoriteItems()
.then((data) => { .then(data => {
if (cancelled) return if (cancelled) return;
if (data.length === 0) { if (data.length === 0) {
onDone('No cloud favorites found', { display: 'system' }) onDone('No cloud favorites found', { display: 'system' });
} else { } else {
setItems(data) setItems(data);
previousSlugs.current = new Set(data.map((item) => item.slug)) previousSlugs.current = new Set(data.map(item => item.slug));
} }
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (cancelled) return if (cancelled) return;
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error);
onDone(`Failed to load cloud favorites: ${message}`, { onDone(`Failed to load cloud favorites: ${message}`, {
display: 'system', display: 'system',
}) });
}) });
return () => { return () => {
cancelled = true cancelled = true;
} };
}, [onDone]) }, [onDone]);
const handleCancel = () => { const handleCancel = () => {
onDone('Cancelled', { display: 'system' }) onDone('Cancelled', { display: 'system' });
} };
const options = useMemo( const options = useMemo(
() => () =>
(items ?? []).map((item) => ({ (items ?? []).map(item => ({
label: `[${item.itemType}] ${item.name} (${item.status})`, label: `[${item.itemType}] ${item.name} (${item.status})`,
value: item.slug, value: item.slug,
})), })),
[items], [items],
) );
const defaultValue = useMemo( const defaultValue = useMemo(
() => (items ?? []).map((item) => item.slug), () => (items ?? []).filter(item => item.status === 'Active').map(item => item.slug),
[items], [items],
) );
const handleChange = async (selectedSlugs: string[]) => { const handleChange = async (selectedSlugs: string[]) => {
if (isFirstChange.current) { if (isFirstChange.current) {
isFirstChange.current = false isFirstChange.current = false;
} }
const prevSet = previousSlugs.current const prevSet = previousSlugs.current;
const nextSet = new Set(selectedSlugs) const nextSet = new Set(selectedSlugs);
const toLoad = (items ?? []).filter( const toLoad = (items ?? []).filter(item => nextSet.has(item.slug) && !prevSet.has(item.slug));
(item) => nextSet.has(item.slug) && !prevSet.has(item.slug), const toUnload = (items ?? []).filter(item => !nextSet.has(item.slug) && prevSet.has(item.slug));
)
const toUnload = (items ?? []).filter(
(item) => !nextSet.has(item.slug) && prevSet.has(item.slug),
)
previousSlugs.current = nextSet previousSlugs.current = nextSet;
if (toLoad.length === 0 && toUnload.length === 0) return if (toLoad.length === 0 && toUnload.length === 0) return;
const results = await Promise.allSettled([ const results = await Promise.allSettled([
...toLoad.map((item) => loadFavoriteItem(item.slug)), ...toLoad.map(item => loadFavoriteItem(item.slug)),
...toUnload.map((item) => unloadFavoriteItem(item.slug)), ...toUnload.map(item => unloadFavoriteItem(item.slug)),
]) ]);
const failures = results.filter((r) => r.status === 'rejected') const failures = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[];
if (failures.length > 0) { if (failures.length > 0) {
onDone(`Cloud enabled items updated with ${failures.length} errors`, { const reasons = failures.map(r => (r.reason instanceof Error ? r.reason.message : String(r.reason)));
onDone(`Cloud enabled items updated with ${failures.length} errors: ${reasons.join('; ')}`, {
display: 'system', display: 'system',
}) });
} }
} };
if (items === null) { if (items === null) {
return ( return (
@ -106,7 +100,7 @@ function CloudEnabledMenu({
<Text>Loading cloud favorites...</Text> <Text>Loading cloud favorites...</Text>
</Box> </Box>
</Dialog> </Dialog>
) );
} }
return ( return (
@ -124,9 +118,9 @@ function CloudEnabledMenu({
hideIndexes hideIndexes
/> />
</Dialog> </Dialog>
) );
} }
export const call: LocalJSXCommandCall = async (onDone) => { export const call: LocalJSXCommandCall = async onDone => {
return <CloudEnabledMenu onDone={onDone} /> return <CloudEnabledMenu onDone={onDone} />;
} };

View File

@ -1,5 +1,12 @@
import path from 'node:path' import path from 'node:path'
import { mkdir, readFile, readdir, rm, writeFile, copyFile } from 'node:fs/promises' import {
mkdir,
readFile,
readdir,
rm,
writeFile,
copyFile,
} from 'node:fs/promises'
import { createCoStrictFetch } from '../provider/fetch.js' import { createCoStrictFetch } from '../provider/fetch.js'
import { getCoStrictBaseURL } from '../provider/auth.js' import { getCoStrictBaseURL } from '../provider/auth.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
@ -140,7 +147,10 @@ async function fetchWithTimeout(
} }
} }
async function listRemoteCandidates(storeType?: string, extraParams?: Record<string, string>) { async function listRemoteCandidates(
storeType?: string,
extraParams?: Record<string, string>,
) {
const baseUrl = getCoStrictBaseURL() const baseUrl = getCoStrictBaseURL()
const costrictFetch = createCoStrictFetch() const costrictFetch = createCoStrictFetch()
const result: Array<{ id: string } & Record<string, unknown>> = [] const result: Array<{ id: string } & Record<string, unknown>> = []
@ -171,7 +181,9 @@ async function listRemoteCandidates(storeType?: string, extraParams?: Record<str
return result return result
} }
function parseFavoriteListItem(data: Record<string, unknown>): FavoriteItem | undefined { function parseFavoriteListItem(
data: Record<string, unknown>,
): FavoriteItem | undefined {
const storeType = String(data.itemType ?? '') const storeType = String(data.itemType ?? '')
const localType = STORE_TYPE_MAP[storeType] const localType = STORE_TYPE_MAP[storeType]
if (!localType) return undefined if (!localType) return undefined
@ -190,7 +202,8 @@ function parseFavoriteListItem(data: Record<string, unknown>): FavoriteItem | un
content: '', content: '',
category: typeof data.category === 'string' ? data.category : undefined, category: typeof data.category === 'string' ? data.category : undefined,
version: typeof data.version === 'string' ? data.version : undefined, version: typeof data.version === 'string' ? data.version : undefined,
favoriteCount: typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined, favoriteCount:
typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined,
favorited, favorited,
createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined,
createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined,
@ -213,7 +226,9 @@ async function getRemoteItem(id: string): Promise<FavoriteItem> {
const storeType = String(data.itemType ?? '') const storeType = String(data.itemType ?? '')
const localType = STORE_TYPE_MAP[storeType] const localType = STORE_TYPE_MAP[storeType]
if (!localType) { if (!localType) {
throw new Error(`Unsupported item type: ${storeType} (${String(data.slug ?? id)})`) throw new Error(
`Unsupported item type: ${storeType} (${String(data.slug ?? id)})`,
)
} }
return { return {
id: String(data.id), id: String(data.id),
@ -224,7 +239,8 @@ async function getRemoteItem(id: string): Promise<FavoriteItem> {
content: String(data.content ?? ''), content: String(data.content ?? ''),
category: typeof data.category === 'string' ? data.category : undefined, category: typeof data.category === 'string' ? data.category : undefined,
version: typeof data.version === 'string' ? data.version : undefined, version: typeof data.version === 'string' ? data.version : undefined,
favoriteCount: typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined, favoriteCount:
typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined,
favorited: typeof data.favorited === 'boolean' ? data.favorited : undefined, favorited: typeof data.favorited === 'boolean' ? data.favorited : undefined,
createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined,
createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined,
@ -339,7 +355,7 @@ async function persistInstalledItem(item: FavoriteItem) {
) + '\n', ) + '\n',
) )
await mutateState((state) => { await mutateState(state => {
state.items[item.slug] = { state.items[item.slug] = {
id: item.id, id: item.id,
slug: item.slug, slug: item.slug,
@ -347,7 +363,8 @@ async function persistInstalledItem(item: FavoriteItem) {
itemType: item.itemType, itemType: item.itemType,
localPath: dir, localPath: dir,
lifecycle: state.items[item.slug]?.lifecycle ?? 'downloaded', lifecycle: state.items[item.slug]?.lifecycle ?? 'downloaded',
installedAt: state.items[item.slug]?.installedAt ?? new Date().toISOString(), installedAt:
state.items[item.slug]?.installedAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
}) })
@ -355,7 +372,9 @@ async function persistInstalledItem(item: FavoriteItem) {
return dir return dir
} }
async function hasUsableLocalContent(hit: FavoriteStateRecord): Promise<boolean> { async function hasUsableLocalContent(
hit: FavoriteStateRecord,
): Promise<boolean> {
const contentPath = (() => { const contentPath = (() => {
switch (hit.itemType) { switch (hit.itemType) {
case 'skill': case 'skill':
@ -369,7 +388,9 @@ async function hasUsableLocalContent(hit: FavoriteStateRecord): Promise<boolean>
} }
})() })()
try { try {
const { size } = await import('node:fs/promises').then((m) => m.stat(contentPath)) const { size } = await import('node:fs/promises').then(m =>
m.stat(contentPath),
)
return size > 0 return size > 0
} catch { } catch {
return false return false
@ -378,13 +399,17 @@ async function hasUsableLocalContent(hit: FavoriteStateRecord): Promise<boolean>
async function ensureInstalled(slugOrId: string): Promise<FavoriteStateRecord> { async function ensureInstalled(slugOrId: string): Promise<FavoriteStateRecord> {
const state = await readState() const state = await readState()
const hit = state.items[slugOrId] ?? Object.values(state.items).find((item) => item.id === slugOrId) const hit =
state.items[slugOrId] ??
Object.values(state.items).find(item => item.id === slugOrId)
if (hit && (await hasUsableLocalContent(hit))) return hit if (hit && (await hasUsableLocalContent(hit))) return hit
let remote = hit ? await getRemoteItem(hit.id).catch(() => undefined) : undefined let remote = hit
? await getRemoteItem(hit.id).catch(() => undefined)
: undefined
if (!remote) { if (!remote) {
const favorites = await listFavoriteItems() const favorites = await listFavoriteItems()
const listed = favorites.find((f) => f.slug === slugOrId || f.id === slugOrId) const listed = favorites.find(f => f.slug === slugOrId || f.id === slugOrId)
if (!listed) throw new Error(`Favorite item not found: ${slugOrId}`) if (!listed) throw new Error(`Favorite item not found: ${slugOrId}`)
remote = await getRemoteItem(listed.id) remote = await getRemoteItem(listed.id)
} }
@ -413,11 +438,20 @@ const SUPPORTED_MCP_TYPES = [
'ws-ide', 'ws-ide',
] ]
function convertMcpConfig(config: Record<string, unknown>): Record<string, unknown> | undefined { function convertMcpConfig(
if (typeof config.type === 'string' && SUPPORTED_MCP_TYPES.includes(config.type)) { config: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (
typeof config.type === 'string' &&
SUPPORTED_MCP_TYPES.includes(config.type)
) {
return config return config
} }
if (config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)) { if (
config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
) {
const servers = Object.entries(config.mcpServers as Record<string, unknown>) const servers = Object.entries(config.mcpServers as Record<string, unknown>)
if (servers.length === 0) return undefined if (servers.length === 0) return undefined
const [, server] = servers[0] const [, server] = servers[0]
@ -429,7 +463,9 @@ function convertMcpConfig(config: Record<string, unknown>): Record<string, unkno
return convertSingleMcpServer(config) return convertSingleMcpServer(config)
} }
function convertSingleMcpServer(server: Record<string, unknown>): Record<string, unknown> | undefined { function convertSingleMcpServer(
server: Record<string, unknown>,
): Record<string, unknown> | undefined {
const command = server.command const command = server.command
const args = server.args const args = server.args
if (!command && !args) return undefined if (!command && !args) return undefined
@ -486,8 +522,11 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
case 'agent': { case 'agent': {
const mdPath = path.join(localPath, `${item.slug}.md`) const mdPath = path.join(localPath, `${item.slug}.md`)
const content = await readFile(mdPath, 'utf-8') const content = await readFile(mdPath, 'utf-8')
const { frontmatter, content: markdownContent } = parseFrontmatter(content, mdPath) const { frontmatter, content: markdownContent } = parseFrontmatter(
saveGlobalConfig((cfg) => ({ content,
mdPath,
)
saveGlobalConfig(cfg => ({
...cfg, ...cfg,
agents: { agents: {
...(cfg.agents ?? {}), ...(cfg.agents ?? {}),
@ -502,8 +541,11 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
case 'command': { case 'command': {
const mdPath = path.join(localPath, `${item.slug}.md`) const mdPath = path.join(localPath, `${item.slug}.md`)
const content = await readFile(mdPath, 'utf-8') const content = await readFile(mdPath, 'utf-8')
const { frontmatter, content: markdownContent } = parseFrontmatter(content, mdPath) const { frontmatter, content: markdownContent } = parseFrontmatter(
saveGlobalConfig((cfg) => ({ content,
mdPath,
)
saveGlobalConfig(cfg => ({
...cfg, ...cfg,
commands: { commands: {
...(cfg.commands ?? {}), ...(cfg.commands ?? {}),
@ -517,7 +559,9 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
} }
case 'mcp': { case 'mcp': {
const mcpJsonPath = path.join(localPath, 'mcp.json') const mcpJsonPath = path.join(localPath, 'mcp.json')
const configJson = JSON.parse(await readFile(mcpJsonPath, 'utf-8')) as Record<string, unknown> const configJson = JSON.parse(
await readFile(mcpJsonPath, 'utf-8'),
) as Record<string, unknown>
const converted = convertMcpConfig(configJson) const converted = convertMcpConfig(configJson)
if (!converted) { if (!converted) {
throw new Error( throw new Error(
@ -526,7 +570,7 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
`Supported formats: opencode native, VS Code / Claude Desktop style, or simplified { command, args }`, `Supported formats: opencode native, VS Code / Claude Desktop style, or simplified { command, args }`,
) )
} }
saveGlobalConfig((cfg) => ({ saveGlobalConfig(cfg => ({
...cfg, ...cfg,
mcpServers: { mcpServers: {
...(cfg.mcpServers ?? {}), ...(cfg.mcpServers ?? {}),
@ -538,7 +582,11 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
} }
} }
async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _localPath: string) { async function removeItemFromConfig(
itemType: FavoriteItemType,
slug: string,
_localPath: string,
) {
switch (itemType) { switch (itemType) {
case 'skill': { case 'skill': {
const destDir = path.join(skillsDir(), slug) const destDir = path.join(skillsDir(), slug)
@ -547,7 +595,7 @@ async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _l
break break
} }
case 'agent': { case 'agent': {
saveGlobalConfig((cfg) => { saveGlobalConfig(cfg => {
const agents = { ...(cfg.agents ?? {}) } const agents = { ...(cfg.agents ?? {}) }
delete agents[slug] delete agents[slug]
return { ...cfg, agents } return { ...cfg, agents }
@ -555,7 +603,7 @@ async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _l
break break
} }
case 'command': { case 'command': {
saveGlobalConfig((cfg) => { saveGlobalConfig(cfg => {
const commands = { ...(cfg.commands ?? {}) } const commands = { ...(cfg.commands ?? {}) }
delete commands[slug] delete commands[slug]
return { ...cfg, commands } return { ...cfg, commands }
@ -563,7 +611,7 @@ async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _l
break break
} }
case 'mcp': { case 'mcp': {
saveGlobalConfig((cfg) => { saveGlobalConfig(cfg => {
const mcpServers = { ...(cfg.mcpServers ?? {}) } const mcpServers = { ...(cfg.mcpServers ?? {}) }
delete mcpServers[slug] delete mcpServers[slug]
return { ...cfg, mcpServers } return { ...cfg, mcpServers }
@ -573,7 +621,9 @@ async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _l
} }
} }
async function readItemForConfig(installed: FavoriteStateRecord): Promise<FavoriteItem> { async function readItemForConfig(
installed: FavoriteStateRecord,
): Promise<FavoriteItem> {
const itemMetaPath = path.join(installed.localPath, 'item.json') const itemMetaPath = path.join(installed.localPath, 'item.json')
let itemMeta: Record<string, unknown> = {} let itemMeta: Record<string, unknown> = {}
try { try {
@ -592,7 +642,9 @@ async function readItemForConfig(installed: FavoriteStateRecord): Promise<Favori
} }
} }
export async function listFavoriteItems(type?: FavoriteItemType): Promise<FavoriteItemWithStatus[]> { export async function listFavoriteItems(
type?: FavoriteItemType,
): Promise<FavoriteItemWithStatus[]> {
const cacheKey = type ?? 'all' const cacheKey = type ?? 'all'
const now = Date.now() const now = Date.now()
if ( if (
@ -603,7 +655,9 @@ export async function listFavoriteItems(type?: FavoriteItemType): Promise<Favori
return _listFavoriteItemsCache.promise return _listFavoriteItemsCache.promise
} }
const storeTypes = type ? [LOCAL_TO_STORE_TYPE[type]] : Object.values(LOCAL_TO_STORE_TYPE) const storeTypes = type
? [LOCAL_TO_STORE_TYPE[type]]
: Object.values(LOCAL_TO_STORE_TYPE)
const errors: Error[] = [] const errors: Error[] = []
const candidates: Array<{ id: string } & Record<string, unknown>> = [] const candidates: Array<{ id: string } & Record<string, unknown>> = []
@ -612,7 +666,9 @@ export async function listFavoriteItems(type?: FavoriteItemType): Promise<Favori
const page = await listRemoteCandidates(st, { favorited: 'true' }) const page = await listRemoteCandidates(st, { favorited: 'true' })
candidates.push(...page) candidates.push(...page)
} catch (error) { } catch (error) {
logForDebugging(`failed to fetch remote favorite candidates: type=${st}, error=${error instanceof Error ? error.message : String(error)}`) logForDebugging(
`failed to fetch remote favorite candidates: type=${st}, error=${error instanceof Error ? error.message : String(error)}`,
)
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
} }
} }
@ -622,7 +678,13 @@ export async function listFavoriteItems(type?: FavoriteItemType): Promise<Favori
throw new Error(`Cloud service unavailable (${errors[0].message})`) throw new Error(`Cloud service unavailable (${errors[0].message})`)
} }
const [activeSkillSlugs, activeAgentNames, activeCommandNames, activeMcpNames, state] = await Promise.all([ const [
activeSkillSlugs,
activeAgentNames,
activeCommandNames,
activeMcpNames,
state,
] = await Promise.all([
getActiveSkillSlugs(), getActiveSkillSlugs(),
getActiveAgentNames(), getActiveAgentNames(),
getActiveCommandNames(), getActiveCommandNames(),
@ -642,7 +704,13 @@ export async function listFavoriteItems(type?: FavoriteItemType): Promise<Favori
const local = state.items[item.slug] const local = state.items[item.slug]
result.push({ result.push({
...item, ...item,
status: deriveStatus(local, activeSkillSlugs, activeAgentNames, activeCommandNames, activeMcpNames), status: deriveStatus(
local,
activeSkillSlugs,
activeAgentNames,
activeCommandNames,
activeMcpNames,
),
localPath: local?.localPath, localPath: local?.localPath,
}) })
} }
@ -659,7 +727,13 @@ export async function listFavoriteItems(type?: FavoriteItemType): Promise<Favori
description: '', description: '',
itemType: record.itemType, itemType: record.itemType,
content: '', content: '',
status: deriveStatus(record, activeSkillSlugs, activeAgentNames, activeCommandNames, activeMcpNames), status: deriveStatus(
record,
activeSkillSlugs,
activeAgentNames,
activeCommandNames,
activeMcpNames,
),
localPath: record.localPath, localPath: record.localPath,
}) })
} }
@ -677,9 +751,11 @@ export async function listFavoriteItems(type?: FavoriteItemType): Promise<Favori
return sorted return sorted
} }
export async function viewFavoriteItem(slugOrId: string): Promise<FavoriteItemWithStatus> { export async function viewFavoriteItem(
slugOrId: string,
): Promise<FavoriteItemWithStatus> {
const favorites = await listFavoriteItems() const favorites = await listFavoriteItems()
const item = favorites.find((f) => f.slug === slugOrId || f.id === slugOrId) const item = favorites.find(f => f.slug === slugOrId || f.id === slugOrId)
if (!item) throw new Error(`Favorite item not found: ${slugOrId}`) if (!item) throw new Error(`Favorite item not found: ${slugOrId}`)
const detail = await getRemoteItem(item.id) const detail = await getRemoteItem(item.id)
@ -694,22 +770,28 @@ export async function loadFavoriteItem(slugOrId: string) {
const installed = await ensureInstalled(slugOrId) const installed = await ensureInstalled(slugOrId)
const itemForConfig = await readItemForConfig(installed) const itemForConfig = await readItemForConfig(installed)
await addItemToConfig(itemForConfig, installed.localPath) await addItemToConfig(itemForConfig, installed.localPath)
await mutateState((state) => { await mutateState(state => {
const record = state.items[installed.slug] const record = state.items[installed.slug]
record.lifecycle = 'active' record.lifecycle = 'active'
record.updatedAt = new Date().toISOString() record.updatedAt = new Date().toISOString()
}) })
_listFavoriteItemsCache = null
return installed return installed
} }
export async function unloadFavoriteItem(slugOrId: string) { export async function unloadFavoriteItem(slugOrId: string) {
const installed = await ensureInstalled(slugOrId) const installed = await ensureInstalled(slugOrId)
await removeItemFromConfig(installed.itemType, installed.slug, installed.localPath) await removeItemFromConfig(
await mutateState((state) => { installed.itemType,
installed.slug,
installed.localPath,
)
await mutateState(state => {
const record = state.items[installed.slug] const record = state.items[installed.slug]
record.lifecycle = 'unloaded' record.lifecycle = 'unloaded'
record.updatedAt = new Date().toISOString() record.updatedAt = new Date().toISOString()
}) })
_listFavoriteItemsCache = null
return installed return installed
} }
@ -720,12 +802,10 @@ export async function unloadFavoriteItem(slugOrId: string) {
export async function autoEnableCloudFavorites(): Promise<void> { export async function autoEnableCloudFavorites(): Promise<void> {
try { try {
const items = await listFavoriteItems() const items = await listFavoriteItems()
const toEnable = items.filter((item) => item.status !== 'Active') const toEnable = items.filter(item => item.status !== 'Active')
if (toEnable.length === 0) return if (toEnable.length === 0) return
await Promise.allSettled( await Promise.allSettled(toEnable.map(item => loadFavoriteItem(item.slug)))
toEnable.map((item) => loadFavoriteItem(item.slug)),
)
} catch { } catch {
// Silently ignore so a flaky cloud API doesn't break startup // Silently ignore so a flaky cloud API doesn't break startup
} }