feat: add multi-project support (#110)
* feat: Implement project management store with project path validation and synchronization - Added `useProjectsStore` for managing projects, including adding, removing, renaming, and validating project paths. - Implemented persistence for projects and active project ID using safe storage. - Introduced synchronization from desktop settings to keep project data consistent. - Enhanced session store to manage sessions by directory and added new methods for session management. - Updated todo store to fetch session todos based on the directory context. - Refactored server code to validate and resolve project directories for various API endpoints. - Added project entry validation and sanitization to ensure data integrity. * feat(settings): migrate legacy project settings and update settings loading logic * feat: enhance project management with directory-aware settings and improved agent/command source handling * feat: enhance session and project management with directory-aware settings and improved configuration refresh logic * feat: enhance project management with worktree manager integration and project directory resolution * feat: enhance agent groups store with project directory resolution and loading logic * feat: add heartbeat management and global wrapping for SSE blocks in agent and chat providers * feat: refactor command and project handling in useCommandsStore - Replaced useDirectoryStore with useProjectsStore to manage project paths. - Introduced getRequestDirectory function to determine the active project directory. - Updated command fetching to respect project-level scoping. - Enhanced error handling and logging for command configuration fetching. - Improved command configuration saving and updating to utilize project directory context. feat: enhance project path normalization in useProjectsStore - Added resolveTildePath function to expand paths starting with ~. - Updated normalizeProjectPath to utilize home directory for path expansion. fix: update permission handling in useSessionStore - Changed Permission type to PermissionRequest for clarity. - Updated respondToPermission method to use requestId instead of permissionId. refactor: improve permission utilities - Introduced types for PermissionAction and PermissionRule. - Enhanced getAgentDefinition and resolveConfigStore functions for better type safety. - Added resolvePermissionAction to streamline permission resolution logic. feat: add agent configuration retrieval endpoint - Implemented new API endpoint to fetch agent configuration based on project directory. - Enhanced getAgentPermissionSource to prioritize project-level permissions. chore: update SDK version in package.json files - Bumped @opencode-ai/sdk version to ^1.1.1 across all relevant package.json files. refactor: streamline bridge message handling - Updated handleBridgeMessage to accept directory parameter for agent and command requests. - Improved local API request handling to extract directory from query parameters and headers. feat: enhance project configuration management - Added functions to retrieve and merge project configuration paths. - Improved handling of existing project configuration files for agents and commands. * feat: enhance VSCode integration and session management - Added support for a sticky sidebar header background in light and dark themes. - Introduced functions to read VSCode workspace directory and check if running in VSCode. - Implemented detailed logging for session loading and creation processes. - Enhanced session filtering based on directory structure and canonical paths. - Added a new method to reorder projects and prevent modifications in VSCode workspace. - Improved error handling and logging for app initialization and markdown file parsing. - Updated API checks and health checks to ensure readiness before proceeding. - Refactored code for better readability and maintainability across various modules. * feat: improve agent and branch selection logic, enhance session management, and update multi-run creation response * feat: add worktree management actions in agent group detail and sidebar, including delete and keep only options * fix(ui): share IME guard and cover multi-run * fix(session): reduce maximum visible sessions in group from 7 to 5
This commit is contained in:
committed by
GitHub
parent
8aa379e313
commit
18c5b4c7b5
@@ -0,0 +1,447 @@
|
||||
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 { getSafeStorage } from './utils/safeStorage';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||
|
||||
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;
|
||||
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 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);
|
||||
return segments[segments.length - 1] || normalized;
|
||||
};
|
||||
|
||||
const createProjectId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `proj_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
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 id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
||||
if (!id || !rawPath) continue;
|
||||
|
||||
const normalizedPath = normalizeProjectPath(rawPath);
|
||||
if (!normalizedPath) 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.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;
|
||||
}
|
||||
|
||||
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 = `vscode:${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 initialActiveProjectId = vscodeWorkspace?.activeProjectId
|
||||
?? readPersistedActiveProjectId()
|
||||
?? 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 candidateId = options?.id?.trim();
|
||||
const id = candidateId && !get().projects.some((project) => project.id === candidateId)
|
||||
? candidateId
|
||||
: createProjectId();
|
||||
const entry: ProjectEntry = {
|
||||
id,
|
||||
path: normalizedPath,
|
||||
label,
|
||||
addedAt: now,
|
||||
lastOpenedAt: now,
|
||||
};
|
||||
|
||||
const nextProjects = [...get().projects, entry];
|
||||
set({ projects: nextProjects });
|
||||
|
||||
if (streamDebugEnabled()) {
|
||||
console.info('[ProjectsStore] Added project', entry);
|
||||
}
|
||||
|
||||
get().setActiveProject(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);
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user