cc-haha/desktop/src/stores/openTargetStore.ts
程序员阿江(Relakkes) 1182c5d9c3 Use native app icons for project opener
The first project opener UI used generic code glyphs for every IDE, which made the menu visually noisy and did not match the native desktop expectation. This changes the macOS path to expose transparent PNG icons generated from each detected local .app bundle and renders them directly in the toolbar/menu, falling back to the existing glyph only when an icon cannot be loaded.

Constraint: Do not redistribute third-party IDE trademark assets in the app bundle.

Constraint: Keep icon detection local to already detected open targets and cache only runtime PNG results.

Rejected: Bundling downloaded IDE logos | creates asset maintenance and trademark redistribution risk.

Rejected: Adding simple-icons as a dependency | it is not a faithful desktop app icon source and still requires brand permission checks.

Confidence: high

Scope-risk: narrow

Directive: Prefer local bundle icons on macOS; add curated official assets only as a cross-platform fallback layer.

Tested: bun test src/server/__tests__/open-target-service.test.ts src/server/__tests__/open-target-api.test.ts

Tested: cd desktop && bun run test -- src/stores/openTargetStore.test.ts src/components/layout/OpenProjectMenu.test.tsx src/components/layout/TabBar.test.tsx

Tested: cd desktop && bun run lint

Tested: bun -e openTargetService.getTargetIcon for vscode and finder returned PNG data

Tested: cd desktop && bun run build
2026-05-13 18:09:35 +08:00

79 lines
2.2 KiB
TypeScript

import { create } from 'zustand'
import { openTargetsApi, type OpenTarget } from '../api/openTargets'
export type { OpenTarget } from '../api/openTargets'
const CLIENT_CACHE_TTL_MS = 60_000
type OpenTargetState = {
targets: OpenTarget[]
platform: string | null
primaryTargetId: string | null
lastSuccessfulTargetId: string | null
loading: boolean
error: string | null
fetchedAt: number
ensureTargets: () => Promise<void>
refreshTargets: () => Promise<void>
openTarget: (targetId: string, path: string) => Promise<void>
}
function choosePrimaryTarget(targets: OpenTarget[], apiPrimary: string | null, lastSuccessful: string | null) {
if (lastSuccessful && targets.some((target) => target.id === lastSuccessful)) return lastSuccessful
if (apiPrimary && targets.some((target) => target.id === apiPrimary)) return apiPrimary
return targets[0]?.id ?? null
}
export const useOpenTargetStore = create<OpenTargetState>((set, get) => ({
targets: [],
platform: null,
primaryTargetId: null,
lastSuccessfulTargetId: null,
loading: false,
error: null,
fetchedAt: 0,
ensureTargets: async () => {
const state = get()
if (state.loading) return
if (state.fetchedAt > 0 && Date.now() - state.fetchedAt < CLIENT_CACHE_TTL_MS) return
await get().refreshTargets()
},
refreshTargets: async () => {
set({ loading: true, error: null })
try {
const result = await openTargetsApi.list()
const primaryTargetId = choosePrimaryTarget(
result.targets,
result.primaryTargetId,
get().lastSuccessfulTargetId,
)
set({
targets: result.targets,
platform: result.platform,
primaryTargetId,
fetchedAt: Date.now(),
loading: false,
error: null,
})
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : String(error),
})
}
},
openTarget: async (targetId, path) => {
try {
await openTargetsApi.open(targetId, path)
set({ lastSuccessfulTargetId: targetId, primaryTargetId: targetId, error: null })
} catch (error) {
await get().refreshTargets()
set({ error: error instanceof Error ? error.message : String(error) })
throw error
}
},
}))