From b0f10760fed1387e788d2e2ea29ecd407cdaa474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=87=AF90331?= <90331@sangfor.com> Date: Fri, 15 May 2026 16:44:24 +0800 Subject: [PATCH] fix(favorite): sync load/unload state and improve error visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear listFavoriteItems cache after load/unload to ensure UI reflects latest state on next open Filter defaultValue by Active status in cloud-enabled menu so unloaded items are not pre-selected Surface specific error reasons instead of generic failure count in cloud-enabled menu Signed-off-by: 林凯90331 <90331@sangfor.com> Co-authored-by: CoStrict --- src/commands/knowledge-hub/cloud-enabled.tsx | 102 ++++++------ src/costrict/favorite/favorite.ts | 166 ++++++++++++++----- 2 files changed, 171 insertions(+), 97 deletions(-) diff --git a/src/commands/knowledge-hub/cloud-enabled.tsx b/src/commands/knowledge-hub/cloud-enabled.tsx index ddff138d1..7a5d3e177 100644 --- a/src/commands/knowledge-hub/cloud-enabled.tsx +++ b/src/commands/knowledge-hub/cloud-enabled.tsx @@ -1,99 +1,93 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' -import { Box, Text, Dialog } from '@anthropic/ink' -import { SelectMulti } from '../../components/CustomSelect/SelectMulti.js' +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Text, Dialog } from '@anthropic/ink'; +import { SelectMulti } from '../../components/CustomSelect/SelectMulti.js'; import { listFavoriteItems, loadFavoriteItem, unloadFavoriteItem, type FavoriteItemWithStatus, -} from '../../costrict/favorite/favorite.js' -import type { LocalJSXCommandCall } from '../../types/command.js' +} from '../../costrict/favorite/favorite.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; function CloudEnabledMenu({ onDone, }: { - onDone: ( - result?: string, - options?: { display?: 'skip' | 'system' | 'user' }, - ) => void + onDone: (result?: string, options?: { display?: 'skip' | 'system' | 'user' }) => void; }): React.ReactNode { - const [items, setItems] = useState(null) - const previousSlugs = useRef>(new Set()) - const isFirstChange = useRef(true) + const [items, setItems] = useState(null); + const previousSlugs = useRef>(new Set()); + const isFirstChange = useRef(true); useEffect(() => { - let cancelled = false + let cancelled = false; listFavoriteItems() - .then((data) => { - if (cancelled) return + .then(data => { + if (cancelled) return; if (data.length === 0) { - onDone('No cloud favorites found', { display: 'system' }) + onDone('No cloud favorites found', { display: 'system' }); } else { - setItems(data) - previousSlugs.current = new Set(data.map((item) => item.slug)) + setItems(data); + previousSlugs.current = new Set(data.map(item => item.slug)); } }) .catch((error: unknown) => { - if (cancelled) return - const message = error instanceof Error ? error.message : String(error) + if (cancelled) return; + const message = error instanceof Error ? error.message : String(error); onDone(`Failed to load cloud favorites: ${message}`, { display: 'system', - }) - }) + }); + }); return () => { - cancelled = true - } - }, [onDone]) + cancelled = true; + }; + }, [onDone]); const handleCancel = () => { - onDone('Cancelled', { display: 'system' }) - } + onDone('Cancelled', { display: 'system' }); + }; const options = useMemo( () => - (items ?? []).map((item) => ({ + (items ?? []).map(item => ({ label: `[${item.itemType}] ${item.name} (${item.status})`, value: item.slug, })), [items], - ) + ); const defaultValue = useMemo( - () => (items ?? []).map((item) => item.slug), + () => (items ?? []).filter(item => item.status === 'Active').map(item => item.slug), [items], - ) + ); const handleChange = async (selectedSlugs: string[]) => { if (isFirstChange.current) { - isFirstChange.current = false + isFirstChange.current = false; } - const prevSet = previousSlugs.current - const nextSet = new Set(selectedSlugs) + const prevSet = previousSlugs.current; + const nextSet = new Set(selectedSlugs); - const toLoad = (items ?? []).filter( - (item) => nextSet.has(item.slug) && !prevSet.has(item.slug), - ) - const toUnload = (items ?? []).filter( - (item) => !nextSet.has(item.slug) && prevSet.has(item.slug), - ) + const toLoad = (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([ - ...toLoad.map((item) => loadFavoriteItem(item.slug)), - ...toUnload.map((item) => unloadFavoriteItem(item.slug)), - ]) + ...toLoad.map(item => loadFavoriteItem(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) { - 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', - }) + }); } - } + }; if (items === null) { return ( @@ -106,7 +100,7 @@ function CloudEnabledMenu({ Loading cloud favorites... - ) + ); } return ( @@ -124,9 +118,9 @@ function CloudEnabledMenu({ hideIndexes /> - ) + ); } -export const call: LocalJSXCommandCall = async (onDone) => { - return -} +export const call: LocalJSXCommandCall = async onDone => { + return ; +}; diff --git a/src/costrict/favorite/favorite.ts b/src/costrict/favorite/favorite.ts index 25887d475..443f20fc0 100644 --- a/src/costrict/favorite/favorite.ts +++ b/src/costrict/favorite/favorite.ts @@ -1,5 +1,12 @@ 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 { getCoStrictBaseURL } from '../provider/auth.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' @@ -140,7 +147,10 @@ async function fetchWithTimeout( } } -async function listRemoteCandidates(storeType?: string, extraParams?: Record) { +async function listRemoteCandidates( + storeType?: string, + extraParams?: Record, +) { const baseUrl = getCoStrictBaseURL() const costrictFetch = createCoStrictFetch() const result: Array<{ id: string } & Record> = [] @@ -171,7 +181,9 @@ async function listRemoteCandidates(storeType?: string, extraParams?: Record): FavoriteItem | undefined { +function parseFavoriteListItem( + data: Record, +): FavoriteItem | undefined { const storeType = String(data.itemType ?? '') const localType = STORE_TYPE_MAP[storeType] if (!localType) return undefined @@ -190,7 +202,8 @@ function parseFavoriteListItem(data: Record): FavoriteItem | un content: '', category: typeof data.category === 'string' ? data.category : 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, createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, @@ -213,7 +226,9 @@ async function getRemoteItem(id: string): Promise { const storeType = String(data.itemType ?? '') const localType = STORE_TYPE_MAP[storeType] 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 { id: String(data.id), @@ -224,7 +239,8 @@ async function getRemoteItem(id: string): Promise { content: String(data.content ?? ''), category: typeof data.category === 'string' ? data.category : 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, createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, @@ -339,7 +355,7 @@ async function persistInstalledItem(item: FavoriteItem) { ) + '\n', ) - await mutateState((state) => { + await mutateState(state => { state.items[item.slug] = { id: item.id, slug: item.slug, @@ -347,7 +363,8 @@ async function persistInstalledItem(item: FavoriteItem) { itemType: item.itemType, localPath: dir, 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(), } }) @@ -355,7 +372,9 @@ async function persistInstalledItem(item: FavoriteItem) { return dir } -async function hasUsableLocalContent(hit: FavoriteStateRecord): Promise { +async function hasUsableLocalContent( + hit: FavoriteStateRecord, +): Promise { const contentPath = (() => { switch (hit.itemType) { case 'skill': @@ -369,7 +388,9 @@ async function hasUsableLocalContent(hit: FavoriteStateRecord): Promise } })() 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 } catch { return false @@ -378,13 +399,17 @@ async function hasUsableLocalContent(hit: FavoriteStateRecord): Promise async function ensureInstalled(slugOrId: string): Promise { 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 - let remote = hit ? await getRemoteItem(hit.id).catch(() => undefined) : undefined + let remote = hit + ? await getRemoteItem(hit.id).catch(() => undefined) + : undefined if (!remote) { 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}`) remote = await getRemoteItem(listed.id) } @@ -413,11 +438,20 @@ const SUPPORTED_MCP_TYPES = [ 'ws-ide', ] -function convertMcpConfig(config: Record): Record | undefined { - if (typeof config.type === 'string' && SUPPORTED_MCP_TYPES.includes(config.type)) { +function convertMcpConfig( + config: Record, +): Record | undefined { + if ( + typeof config.type === 'string' && + SUPPORTED_MCP_TYPES.includes(config.type) + ) { 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) if (servers.length === 0) return undefined const [, server] = servers[0] @@ -429,7 +463,9 @@ function convertMcpConfig(config: Record): Record): Record | undefined { +function convertSingleMcpServer( + server: Record, +): Record | undefined { const command = server.command const args = server.args if (!command && !args) return undefined @@ -486,8 +522,11 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) { case 'agent': { const mdPath = path.join(localPath, `${item.slug}.md`) const content = await readFile(mdPath, 'utf-8') - const { frontmatter, content: markdownContent } = parseFrontmatter(content, mdPath) - saveGlobalConfig((cfg) => ({ + const { frontmatter, content: markdownContent } = parseFrontmatter( + content, + mdPath, + ) + saveGlobalConfig(cfg => ({ ...cfg, agents: { ...(cfg.agents ?? {}), @@ -502,8 +541,11 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) { case 'command': { const mdPath = path.join(localPath, `${item.slug}.md`) const content = await readFile(mdPath, 'utf-8') - const { frontmatter, content: markdownContent } = parseFrontmatter(content, mdPath) - saveGlobalConfig((cfg) => ({ + const { frontmatter, content: markdownContent } = parseFrontmatter( + content, + mdPath, + ) + saveGlobalConfig(cfg => ({ ...cfg, commands: { ...(cfg.commands ?? {}), @@ -517,7 +559,9 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) { } case 'mcp': { const mcpJsonPath = path.join(localPath, 'mcp.json') - const configJson = JSON.parse(await readFile(mcpJsonPath, 'utf-8')) as Record + const configJson = JSON.parse( + await readFile(mcpJsonPath, 'utf-8'), + ) as Record const converted = convertMcpConfig(configJson) if (!converted) { 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 }`, ) } - saveGlobalConfig((cfg) => ({ + saveGlobalConfig(cfg => ({ ...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) { case 'skill': { const destDir = path.join(skillsDir(), slug) @@ -547,7 +595,7 @@ async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _l break } case 'agent': { - saveGlobalConfig((cfg) => { + saveGlobalConfig(cfg => { const agents = { ...(cfg.agents ?? {}) } delete agents[slug] return { ...cfg, agents } @@ -555,7 +603,7 @@ async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _l break } case 'command': { - saveGlobalConfig((cfg) => { + saveGlobalConfig(cfg => { const commands = { ...(cfg.commands ?? {}) } delete commands[slug] return { ...cfg, commands } @@ -563,7 +611,7 @@ async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _l break } case 'mcp': { - saveGlobalConfig((cfg) => { + saveGlobalConfig(cfg => { const mcpServers = { ...(cfg.mcpServers ?? {}) } delete mcpServers[slug] return { ...cfg, mcpServers } @@ -573,7 +621,9 @@ async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _l } } -async function readItemForConfig(installed: FavoriteStateRecord): Promise { +async function readItemForConfig( + installed: FavoriteStateRecord, +): Promise { const itemMetaPath = path.join(installed.localPath, 'item.json') let itemMeta: Record = {} try { @@ -592,7 +642,9 @@ async function readItemForConfig(installed: FavoriteStateRecord): Promise { +export async function listFavoriteItems( + type?: FavoriteItemType, +): Promise { const cacheKey = type ?? 'all' const now = Date.now() if ( @@ -603,7 +655,9 @@ export async function listFavoriteItems(type?: FavoriteItemType): Promise> = [] @@ -612,7 +666,9 @@ export async function listFavoriteItems(type?: FavoriteItemType): Promise { +export async function viewFavoriteItem( + slugOrId: string, +): Promise { 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}`) const detail = await getRemoteItem(item.id) @@ -694,22 +770,28 @@ export async function loadFavoriteItem(slugOrId: string) { const installed = await ensureInstalled(slugOrId) const itemForConfig = await readItemForConfig(installed) await addItemToConfig(itemForConfig, installed.localPath) - await mutateState((state) => { + await mutateState(state => { const record = state.items[installed.slug] record.lifecycle = 'active' record.updatedAt = new Date().toISOString() }) + _listFavoriteItemsCache = null return installed } export async function unloadFavoriteItem(slugOrId: string) { const installed = await ensureInstalled(slugOrId) - await removeItemFromConfig(installed.itemType, installed.slug, installed.localPath) - await mutateState((state) => { + await removeItemFromConfig( + installed.itemType, + installed.slug, + installed.localPath, + ) + await mutateState(state => { const record = state.items[installed.slug] record.lifecycle = 'unloaded' record.updatedAt = new Date().toISOString() }) + _listFavoriteItemsCache = null return installed } @@ -720,12 +802,10 @@ export async function unloadFavoriteItem(slugOrId: string) { export async function autoEnableCloudFavorites(): Promise { try { 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 - await Promise.allSettled( - toEnable.map((item) => loadFavoriteItem(item.slug)), - ) + await Promise.allSettled(toEnable.map(item => loadFavoriteItem(item.slug))) } catch { // Silently ignore so a flaky cloud API doesn't break startup }