Derive project.id from project.path (path_<base64url(path)>), shared helper on both server (lib/projects/project-id.js) and client (lib/projectId.ts). Replace random UUIDs so icons, notes, todos, actions, setup-worktree, plans and scheduledTasks share one id across restarts and reinstalls. Fix read-then-overwrite clobber in project-config.js: scheduled-task writes now merge with the existing project json instead of replacing it, preserving client-written fields that live in the same file. On settings load migrate legacy UUID ids to canonical path ids, moving config json, storage dir contents and icon files, and remap activeProjectId. Scan for orphan non-path_* project configs and merge them into the canonical project when a \$ROOT_PROJECT_PATH/<file> reference resolves on disk, logging any that can't be matched. Client openchamberConfig.writeOpenChamberConfig re-asserts server-owned keys (version, scheduledTasks) on write to defeat the symmetric race. Stores and persistence derive ids from path consistently.
696 lines
23 KiB
TypeScript
696 lines
23 KiB
TypeScript
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<string, number>();
|
|
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<string, unknown>;
|
|
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<string> => {
|
|
return await new Promise<string>((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<string>();
|
|
const seenPaths = new Set<string>();
|
|
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const candidate = entry as Record<string, unknown>;
|
|
|
|
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<ProjectsStore>()(
|
|
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<DesktopSettings>).detail;
|
|
if (detail && typeof detail === 'object') {
|
|
useProjectsStore.getState().synchronizeFromSettings(detail);
|
|
}
|
|
});
|
|
}
|