import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; import type { ProjectEntry } from '@/lib/api/types'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { createProjectIdFromPath } from '@/lib/projectId'; import { getSafeStorage } from './utils/safeStorage'; import { useDirectoryStore } from './useDirectoryStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { PROJECT_COLORS } from '@/lib/projectMeta'; /** Pick a color key that's least used among existing projects */ const pickAutoColor = (projects: ProjectEntry[]): string => { const colorKeys = PROJECT_COLORS.map((c) => c.key); const usageCounts = new Map(); for (const key of colorKeys) { usageCounts.set(key, 0); } for (const p of projects) { if (p.color && usageCounts.has(p.color)) { usageCounts.set(p.color, (usageCounts.get(p.color) ?? 0) + 1); } } // Find minimum usage, then pick randomly among those with min usage const minUsage = Math.min(...usageCounts.values()); const candidates = colorKeys.filter((k) => usageCounts.get(k) === minUsage); return candidates[Math.floor(Math.random() * candidates.length)]; }; interface ProjectPathValidationResult { ok: boolean; normalizedPath?: string; reason?: string; } interface ProjectsStore { projects: ProjectEntry[]; activeProjectId: string | null; addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null; removeProject: (id: string) => void; setActiveProject: (id: string) => void; setActiveProjectIdOnly: (id: string) => void; renameProject: (id: string, label: string) => void; updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null; iconBackground?: string | null }) => void; uploadProjectIcon: (id: string, file: File) => Promise<{ ok: boolean; error?: string }>; removeProjectIcon: (id: string) => Promise<{ ok: boolean; error?: string }>; discoverProjectIcon: (id: string, options?: { force?: boolean }) => Promise<{ ok: boolean; skipped?: boolean; reason?: string; error?: string }>; reorderProjects: (fromIndex: number, toIndex: number) => void; validateProjectPath: (path: string) => ProjectPathValidationResult; synchronizeFromSettings: (settings: DesktopSettings) => void; getActiveProject: () => ProjectEntry | null; } const safeStorage = getSafeStorage(); const PROJECTS_STORAGE_KEY = 'projects'; const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId'; const resolveTildePath = (value: string, homeDir?: string | null): string => { const trimmed = value.trim(); if (!trimmed.startsWith('~')) { return trimmed; } if (!homeDir) { return trimmed; } if (trimmed === '~') { return homeDir; } if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { return `${homeDir}${trimmed.slice(1)}`; } return trimmed; }; const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; const normalizeIconBackground = (value: unknown): string | null => { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; }; const normalizeProjectPath = (value: string): string => { const trimmed = value.trim(); if (!trimmed) { return ''; } const homeDirectory = safeStorage.getItem('homeDirectory') || useDirectoryStore.getState().homeDirectory || ''; const expanded = resolveTildePath(trimmed, homeDirectory); const normalized = expanded.replace(/\\/g, '/'); if (normalized === '/') { return '/'; } return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; }; const deriveProjectLabel = (path: string): string => { const normalized = normalizeProjectPath(path); if (!normalized || normalized === '/') { return 'Root'; } const segments = normalized.split('/').filter(Boolean); const raw = segments[segments.length - 1] || normalized; return raw.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); }; const sanitizeProjectIconImage = (value: unknown): ProjectEntry['iconImage'] | undefined => { if (!value || typeof value !== 'object') { return undefined; } const candidate = value as Record; const mime = typeof candidate.mime === 'string' ? candidate.mime.trim() : ''; const updatedAt = typeof candidate.updatedAt === 'number' && Number.isFinite(candidate.updatedAt) ? Math.max(0, Math.round(candidate.updatedAt)) : 0; const source = candidate.source === 'custom' || candidate.source === 'auto' ? candidate.source : null; if (!mime || !updatedAt || !source) { return undefined; } return { mime, updatedAt, source }; }; const resolveUploadMime = (file: File): 'image/png' | 'image/jpeg' | 'image/svg+xml' | null => { const rawType = typeof file.type === 'string' ? file.type.trim().toLowerCase() : ''; if (rawType === 'image/png' || rawType === 'image/jpeg' || rawType === 'image/svg+xml') { return rawType; } const lowerName = file.name.toLowerCase(); if (lowerName.endsWith('.png')) return 'image/png'; if (lowerName.endsWith('.jpg') || lowerName.endsWith('.jpeg')) return 'image/jpeg'; if (lowerName.endsWith('.svg')) return 'image/svg+xml'; return null; }; const readFileAsDataUrl = async (file: File): Promise => { return await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => { reject(new Error('Failed to read icon file')); }; reader.onload = () => { const result = typeof reader.result === 'string' ? reader.result : ''; if (!result) { reject(new Error('Failed to read icon file')); return; } resolve(result); }; reader.readAsDataURL(file); }); }; const sanitizeProjects = (value: unknown): ProjectEntry[] => { if (!Array.isArray(value)) { return []; } const result: ProjectEntry[] = []; const seenIds = new Set(); const seenPaths = new Set(); for (const entry of value) { if (!entry || typeof entry !== 'object') continue; const candidate = entry as Record; const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; if (!rawPath) continue; const normalizedPath = normalizeProjectPath(rawPath); if (!normalizedPath) continue; const id = createProjectIdFromPath(normalizedPath); if (!id) continue; if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue; seenIds.add(id); seenPaths.add(normalizedPath); const project: ProjectEntry = { id, path: normalizedPath, }; if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) { project.label = candidate.label.trim(); } if (typeof candidate.icon === 'string' && candidate.icon.trim().length > 0) { project.icon = candidate.icon.trim(); } if (candidate.iconImage === null) { project.iconImage = null; } else { const iconImage = sanitizeProjectIconImage(candidate.iconImage); if (iconImage) { project.iconImage = iconImage; } } if (typeof candidate.color === 'string' && candidate.color.trim().length > 0) { project.color = candidate.color.trim(); } if (candidate.iconBackground === null) { project.iconBackground = null; } else { const iconBackground = normalizeIconBackground(candidate.iconBackground); if (iconBackground) { project.iconBackground = iconBackground; } } if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) { project.addedAt = candidate.addedAt; } if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) { project.lastOpenedAt = candidate.lastOpenedAt; } if (typeof candidate.sidebarCollapsed === 'boolean') { project.sidebarCollapsed = candidate.sidebarCollapsed; } result.push(project); } return result; }; const readPersistedProjects = (): ProjectEntry[] => { try { const raw = safeStorage.getItem(PROJECTS_STORAGE_KEY); if (!raw) { return []; } return sanitizeProjects(JSON.parse(raw)); } catch { return []; } }; const readPersistedActiveProjectId = (): string | null => { try { const raw = safeStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY); if (typeof raw === 'string' && raw.trim().length > 0) { return raw.trim(); } } catch { return null; } return null; }; const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => { try { safeStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects)); } catch { // ignored } try { if (activeProjectId) { safeStorage.setItem(ACTIVE_PROJECT_STORAGE_KEY, activeProjectId); } else { safeStorage.removeItem(ACTIVE_PROJECT_STORAGE_KEY); } } catch { // ignored } }; const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null) => { cacheProjects(projects, activeProjectId); void updateDesktopSettings({ projects, activeProjectId: activeProjectId ?? undefined }); }; const initialProjects = readPersistedProjects(); const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectId: string | null } | null => { if (typeof window === 'undefined') { return null; } const runtimeApis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }) .__OPENCHAMBER_RUNTIME_APIS__; if (!runtimeApis?.runtime?.isVSCode) { return null; } const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder; if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) { return null; } const normalizedPath = normalizeProjectPath(workspaceFolder); if (!normalizedPath) { return null; } const id = createProjectIdFromPath(normalizedPath); const entry: ProjectEntry = { id, path: normalizedPath, label: deriveProjectLabel(normalizedPath), addedAt: Date.now(), lastOpenedAt: Date.now(), }; if (streamDebugEnabled()) { console.log('[OpenChamber][VSCode][projects] Using workspace fallback project', entry); } return { projects: [entry], activeProjectId: id }; }; // VS Code runtime should behave as a single-project environment scoped to the workspace folder. // Always prefer the workspace project over any persisted multi-project registry. const vscodeWorkspace = getVSCodeWorkspaceProject(); const effectiveInitialProjects = vscodeWorkspace?.projects ?? initialProjects; const persistedInitialActiveProjectId = vscodeWorkspace?.activeProjectId ?? readPersistedActiveProjectId(); const initialActiveProjectId = effectiveInitialProjects.some((project) => project.id === persistedInitialActiveProjectId) ? persistedInitialActiveProjectId : effectiveInitialProjects[0]?.id ?? null; if (vscodeWorkspace) { cacheProjects(effectiveInitialProjects, initialActiveProjectId); } export const useProjectsStore = create()( devtools((set, get) => ({ projects: effectiveInitialProjects, activeProjectId: initialActiveProjectId, validateProjectPath: (path: string): ProjectPathValidationResult => { if (typeof path !== 'string' || path.trim().length === 0) { return { ok: false, reason: 'Provide a directory path.' }; } const normalized = normalizeProjectPath(path); if (!normalized) { return { ok: false, reason: 'Directory path cannot be empty.' }; } return { ok: true, normalizedPath: normalized }; }, addProject: (path: string, options?: { label?: string; id?: string }) => { if (vscodeWorkspace) { return null; } const { validateProjectPath } = get(); const validation = validateProjectPath(path); if (!validation.ok || !validation.normalizedPath) { return null; } const normalizedPath = validation.normalizedPath; const existing = get().projects.find((project) => project.path === normalizedPath); if (existing) { get().setActiveProject(existing.id); return existing; } const now = Date.now(); const label = options?.label?.trim() || deriveProjectLabel(normalizedPath); const id = createProjectIdFromPath(normalizedPath); const entry: ProjectEntry = { id, path: normalizedPath, label, color: pickAutoColor(get().projects), addedAt: now, lastOpenedAt: now, }; const nextProjects = [...get().projects, entry]; set({ projects: nextProjects }); if (streamDebugEnabled()) { console.info('[ProjectsStore] Added project', entry); } get().setActiveProject(entry.id); void get().discoverProjectIcon(entry.id); return entry; }, removeProject: (id: string) => { if (vscodeWorkspace) { return; } const current = get(); const nextProjects = current.projects.filter((project) => project.id !== id); let nextActiveId = current.activeProjectId; if (current.activeProjectId === id) { nextActiveId = nextProjects[0]?.id ?? null; } set({ projects: nextProjects, activeProjectId: nextActiveId }); persistProjects(nextProjects, nextActiveId); if (nextActiveId) { const nextActive = nextProjects.find((project) => project.id === nextActiveId); if (nextActive) { opencodeClient.setDirectory(nextActive.path); useDirectoryStore.getState().setDirectory(nextActive.path, { showOverlay: false }); } } else { void useDirectoryStore.getState().goHome(); } }, setActiveProject: (id: string) => { if (vscodeWorkspace) { return; } const { projects, activeProjectId } = get(); if (activeProjectId === id) { return; } const target = projects.find((project) => project.id === id); if (!target) { return; } const now = Date.now(); const nextProjects = projects.map((project) => project.id === id ? { ...project, lastOpenedAt: now } : project ); set({ projects: nextProjects, activeProjectId: id }); persistProjects(nextProjects, id); opencodeClient.setDirectory(target.path); useDirectoryStore.getState().setDirectory(target.path, { showOverlay: false }); }, setActiveProjectIdOnly: (id: string) => { if (vscodeWorkspace) { return; } const { projects, activeProjectId } = get(); if (activeProjectId === id) { return; } const target = projects.find((project) => project.id === id); if (!target) { return; } const now = Date.now(); const nextProjects = projects.map((project) => project.id === id ? { ...project, lastOpenedAt: now } : project ); set({ projects: nextProjects, activeProjectId: id }); persistProjects(nextProjects, id); }, renameProject: (id: string, label: string) => { if (vscodeWorkspace) { return; } const trimmed = label.trim(); if (!trimmed) { return; } const { projects, activeProjectId } = get(); const nextProjects = projects.map((project) => project.id === id ? { ...project, label: trimmed } : project ); set({ projects: nextProjects }); persistProjects(nextProjects, activeProjectId); }, updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null; iconBackground?: string | null }) => { if (vscodeWorkspace) { return; } const { projects, activeProjectId } = get(); const nextProjects = projects.map((project) => { if (project.id !== id) return project; const updated = { ...project }; if (meta.label !== undefined) { const trimmed = meta.label.trim(); if (trimmed) updated.label = trimmed; } if (meta.icon !== undefined) updated.icon = meta.icon; if (meta.color !== undefined) updated.color = meta.color; if (meta.iconBackground !== undefined) { updated.iconBackground = normalizeIconBackground(meta.iconBackground); } return updated; }); set({ projects: nextProjects }); persistProjects(nextProjects, activeProjectId); }, uploadProjectIcon: async (id: string, file: File) => { if (vscodeWorkspace) { return { ok: false, error: 'Custom icons are not supported in this runtime' }; } const mime = resolveUploadMime(file); if (!mime) { return { ok: false, error: 'Only PNG, JPEG, and SVG are supported' }; } if (!Number.isFinite(file.size) || file.size <= 0) { return { ok: false, error: 'Icon file is empty' }; } if (file.size > 5 * 1024 * 1024) { return { ok: false, error: 'Icon exceeds size limit (5 MB)' }; } try { const dataUrl = await readFileAsDataUrl(file); const normalizedDataUrl = dataUrl.replace(/^data:[^;]+;/i, `data:${mime};`); const response = await fetch(`/api/projects/${encodeURIComponent(id)}/icon`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ dataUrl: normalizedDataUrl }), }); if (!response.ok) { const payload = (await response.json().catch(() => null)) as { error?: string } | null; return { ok: false, error: payload?.error || 'Failed to upload project icon' }; } const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null; if (payload?.settings) { get().synchronizeFromSettings(payload.settings); } return { ok: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, error: message || 'Failed to upload project icon' }; } }, removeProjectIcon: async (id: string) => { if (vscodeWorkspace) { return { ok: false, error: 'Custom icons are not supported in this runtime' }; } try { const response = await fetch(`/api/projects/${encodeURIComponent(id)}/icon`, { method: 'DELETE', headers: { Accept: 'application/json', }, }); if (!response.ok) { const payload = (await response.json().catch(() => null)) as { error?: string } | null; return { ok: false, error: payload?.error || 'Failed to remove project icon' }; } const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null; if (payload?.settings) { get().synchronizeFromSettings(payload.settings); } return { ok: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, error: message || 'Failed to remove project icon' }; } }, discoverProjectIcon: async (id: string, options?: { force?: boolean }) => { if (vscodeWorkspace) { return { ok: false, error: 'Custom icons are not supported in this runtime' }; } try { const response = await fetch(`/api/projects/${encodeURIComponent(id)}/icon/discover`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ force: options?.force === true }), }); const payload = (await response.json().catch(() => null)) as { error?: string; skipped?: boolean; reason?: string; settings?: DesktopSettings; } | null; if (!response.ok) { return { ok: false, error: payload?.error || 'Failed to discover project icon' }; } if (payload?.settings) { get().synchronizeFromSettings(payload.settings); } return { ok: true, skipped: payload?.skipped === true, reason: typeof payload?.reason === 'string' ? payload.reason : undefined, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, error: message || 'Failed to discover project icon' }; } }, reorderProjects: (fromIndex: number, toIndex: number) => { if (vscodeWorkspace) { return; } const { projects, activeProjectId } = get(); if ( fromIndex < 0 || fromIndex >= projects.length || toIndex < 0 || toIndex >= projects.length || fromIndex === toIndex ) { return; } const nextProjects = [...projects]; const [moved] = nextProjects.splice(fromIndex, 1); nextProjects.splice(toIndex, 0, moved); set({ projects: nextProjects }); persistProjects(nextProjects, activeProjectId); }, synchronizeFromSettings: (settings: DesktopSettings) => { if (vscodeWorkspace) { return; } const incomingProjects = sanitizeProjects(settings.projects ?? []); const incomingActive = typeof settings.activeProjectId === 'string' && settings.activeProjectId.trim() ? settings.activeProjectId.trim() : null; const current = get(); const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects); const activeChanged = current.activeProjectId !== incomingActive; if (!projectsChanged && !activeChanged) { return; } set({ projects: incomingProjects, activeProjectId: incomingActive }); cacheProjects(incomingProjects, incomingActive); if (incomingActive) { const activeProject = incomingProjects.find((project) => project.id === incomingActive); if (activeProject) { opencodeClient.setDirectory(activeProject.path); useDirectoryStore.getState().setDirectory(activeProject.path, { showOverlay: false }); } } }, getActiveProject: () => { const { projects, activeProjectId } = get(); if (!activeProjectId) { return null; } return projects.find((project) => project.id === activeProjectId) ?? null; }, }), { name: 'projects-store' }) ); if (typeof window !== 'undefined') { window.addEventListener('openchamber:settings-synced', (event: Event) => { const detail = (event as CustomEvent).detail; if (detail && typeof detail === 'object') { useProjectsStore.getState().synchronizeFromSettings(detail); } }); }