feat(vscode): support multi-root workspaces (#1493)

Add proper VS Code multi-root workspace support. New sessions now start in the workspace folder the user chooses instead of always using the first folder.

The VS Code sidebar now shows one shared flat session list for the currently opened workspace folders, keeps that list synced when folders are added or removed, and excludes sessions from worktrees unless that worktree is opened as a workspace folder.

Also keep the OpenCode server process independent from a specific workspace folder so changing the selected folder does not restart or interrupt existing sessions.
This commit is contained in:
Maksym Mospanenko
2026-06-10 23:58:40 +03:00
committed by GitHub
parent 49a1424e5f
commit 7b33805ea0
15 changed files with 646 additions and 96 deletions
+200 -30
View File
@@ -38,6 +38,11 @@ interface ProjectPathValidationResult {
reason?: string;
}
interface VSCodeWorkspaceFolderConfig {
name?: string;
path: string;
}
interface ProjectsStore {
projects: ProjectEntry[];
activeProjectId: string | null;
@@ -55,6 +60,7 @@ interface ProjectsStore {
resetForRuntimeSwitch: () => void;
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
syncVSCodeWorkspaceFolders: (folders: VSCodeWorkspaceFolderConfig[], activePath?: string | null) => ProjectEntry | null;
getActiveProject: () => ProjectEntry | null;
}
@@ -322,7 +328,46 @@ const persistProjects = (projects: ProjectEntry[], activeProjectId: string | nul
};
const initialProjects = readPersistedProjects();
const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectId: string | null } | null => {
const normalizeVSCodeWorkspaceFolders = (folders: VSCodeWorkspaceFolderConfig[]): VSCodeWorkspaceFolderConfig[] => {
const result: VSCodeWorkspaceFolderConfig[] = [];
const seen = new Set<string>();
for (const folder of folders) {
const normalizedPath = normalizeProjectPath(folder.path);
if (!normalizedPath || seen.has(normalizedPath)) {
continue;
}
seen.add(normalizedPath);
result.push({
name: folder.name?.trim(),
path: normalizedPath,
});
}
return result;
};
const createVSCodeWorkspaceProject = (
folder: VSCodeWorkspaceFolderConfig,
existing: ProjectEntry | null,
now: number,
activePath: string | null,
): ProjectEntry | null => {
const normalizedPath = normalizeProjectPath(folder.path);
if (!normalizedPath) {
return null;
}
const id = createProjectIdFromPath(normalizedPath);
const isActive = activePath === normalizedPath;
return {
...existing,
id,
path: normalizedPath,
label: deriveProjectLabel(normalizedPath),
addedAt: existing?.addedAt ?? now,
lastOpenedAt: isActive ? now : existing?.lastOpenedAt ?? now,
};
};
const getVSCodeWorkspaceFolders = (): VSCodeWorkspaceFolderConfig[] | null => {
if (typeof window === 'undefined') {
return null;
}
@@ -332,37 +377,127 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI
return null;
}
const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder;
const config = (window as unknown as {
__VSCODE_CONFIG__?: {
workspaceFolder?: unknown;
workspaceFolders?: unknown;
};
}).__VSCODE_CONFIG__;
const folders = Array.isArray(config?.workspaceFolders)
? config.workspaceFolders
.map((entry) => {
const candidate = entry as { name?: unknown; path?: unknown };
const path = typeof candidate.path === 'string' ? candidate.path.trim() : '';
if (!path) return null;
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
return { name, path };
})
.filter((entry): entry is { name: string; path: string } => entry !== null)
: [];
if (folders.length > 0) {
return normalizeVSCodeWorkspaceFolders(folders);
}
const workspaceFolder = config?.workspaceFolder;
if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) {
return null;
}
const normalizedPath = normalizeProjectPath(workspaceFolder);
if (!normalizedPath) {
return normalizeVSCodeWorkspaceFolders([{ path: workspaceFolder }]);
};
const createVSCodeWorkspaceProjects = (
folders: VSCodeWorkspaceFolderConfig[],
existingProjects: ProjectEntry[],
activePath?: string | null,
): { projects: ProjectEntry[]; activeProjectId: string | null; activeProject: ProjectEntry | null } | null => {
const normalizedFolders = normalizeVSCodeWorkspaceFolders(folders);
const normalizedActivePath = activePath ? normalizeProjectPath(activePath) : null;
const effectiveFolders = normalizedFolders.length === 0 && normalizedActivePath
? [{ path: normalizedActivePath }]
: normalizedActivePath && !normalizedFolders.some((folder) => folder.path === normalizedActivePath)
? [...normalizedFolders, { path: normalizedActivePath }]
: normalizedFolders;
if (effectiveFolders.length === 0) {
return null;
}
const now = Date.now();
const projects = effectiveFolders
.map((folder) => createVSCodeWorkspaceProject(
folder,
existingProjects.find((project) => project.path === folder.path) ?? null,
now,
normalizedActivePath,
))
.filter((project): project is ProjectEntry => project !== null);
if (projects.length === 0) {
return null;
}
const id = createProjectIdFromPath(normalizedPath);
const entry: ProjectEntry = {
id,
path: normalizedPath,
label: deriveProjectLabel(normalizedPath),
addedAt: Date.now(),
lastOpenedAt: Date.now(),
};
const activeProject = normalizedActivePath
? projects.find((project) => project.path === normalizedActivePath) ?? null
: projects[0] ?? null;
const activeProjectId = activeProject?.id ?? projects[0]?.id ?? null;
if (streamDebugEnabled()) {
console.log('[OpenChamber][VSCode][projects] Using workspace fallback project', entry);
console.log('[OpenChamber][VSCode][projects] Using workspace projects', projects);
}
return { projects: [entry], activeProjectId: id };
return { projects, activeProjectId, activeProject: activeProject ?? projects[0] ?? null };
};
// 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 projectIconImagesEqual = (
left: ProjectEntry['iconImage'],
right: ProjectEntry['iconImage'],
): boolean => {
if (left === right) return true;
if (!left || !right) return left === right;
return left.mime === right.mime
&& left.updatedAt === right.updatedAt
&& left.source === right.source;
};
const vscodeWorkspaceProjectsEqual = (left: ProjectEntry[], right: ProjectEntry[]): boolean => {
if (left.length !== right.length) return false;
return left.every((leftProject, index) => {
const rightProject = right[index];
if (!rightProject) return false;
return leftProject.id === rightProject.id
&& leftProject.path === rightProject.path
&& leftProject.label === rightProject.label
&& leftProject.icon === rightProject.icon
&& leftProject.color === rightProject.color
&& leftProject.iconBackground === rightProject.iconBackground
&& leftProject.addedAt === rightProject.addedAt
&& leftProject.lastOpenedAt === rightProject.lastOpenedAt
&& leftProject.sidebarCollapsed === rightProject.sidebarCollapsed
&& projectIconImagesEqual(leftProject.iconImage, rightProject.iconImage);
});
};
const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectId: string | null } | null => {
const folders = getVSCodeWorkspaceFolders();
if (!folders) {
return null;
}
const result = createVSCodeWorkspaceProjects(folders, []);
if (!result) {
return null;
}
return { projects: result.projects, activeProjectId: result.activeProjectId };
};
// VS Code runtime is scoped to the workspace folders opened in VS Code.
// Always prefer the VS Code workspace projects over any persisted multi-project registry.
const vscodeWorkspace = getVSCodeWorkspaceProject();
const effectiveInitialProjects = vscodeWorkspace?.projects ?? initialProjects;
const persistedInitialActiveProjectId = vscodeWorkspace?.activeProjectId ?? readPersistedActiveProjectId();
const isVSCodeProjectsRuntime = (() => {
if (typeof window === 'undefined') return false;
return Boolean(getRegisteredRuntimeAPIs()?.runtime?.isVSCode);
})();
const effectiveInitialProjects = vscodeWorkspace?.projects ?? (isVSCodeProjectsRuntime ? [] : initialProjects);
const persistedInitialActiveProjectId = vscodeWorkspace?.activeProjectId ?? (isVSCodeProjectsRuntime ? null : readPersistedActiveProjectId());
const initialActiveProjectId = effectiveInitialProjects.some((project) => project.id === persistedInitialActiveProjectId)
? persistedInitialActiveProjectId
: effectiveInitialProjects[0]?.id ?? null;
@@ -390,7 +525,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
addProject: (path: string, options?: { label?: string; id?: string }) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return null;
}
const { validateProjectPath } = get();
@@ -431,7 +566,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
removeProject: (id: string) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const current = get();
@@ -468,7 +603,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
setActiveProject: (id: string) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const { projects, activeProjectId } = get();
@@ -493,7 +628,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
setActiveProjectIdOnly: (id: string) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const { projects, activeProjectId } = get();
@@ -515,7 +650,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
renameProject: (id: string, label: string) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const trimmed = label.trim();
@@ -532,7 +667,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null; iconBackground?: string | null }) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const { projects, activeProjectId } = get();
@@ -555,7 +690,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
uploadProjectIcon: async (id: string, file: File) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return { ok: false, error: 'Custom icons are not supported in this runtime' };
}
@@ -600,7 +735,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
removeProjectIcon: async (id: string) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return { ok: false, error: 'Custom icons are not supported in this runtime' };
}
@@ -629,7 +764,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
discoverProjectIcon: async (id: string, options?: { force?: boolean }) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return { ok: false, error: 'Custom icons are not supported in this runtime' };
}
@@ -670,7 +805,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
reorderProjects: (fromIndex: number, toIndex: number) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const { projects, activeProjectId } = get();
@@ -693,7 +828,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
resetForRuntimeSwitch: () => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const projects = readPersistedProjects();
@@ -705,7 +840,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
synchronizeFromSettings: (settings: DesktopSettings) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const incomingProjects = sanitizeProjects(settings.projects ?? []);
@@ -752,6 +887,41 @@ export const useProjectsStore = create<ProjectsStore>()(
}
},
syncVSCodeWorkspaceFolders: (folders, activePath) => {
if (!isVSCodeProjectsRuntime) {
return null;
}
const current = get();
const currentActiveProject = current.activeProjectId
? current.projects.find((project) => project.id === current.activeProjectId) ?? null
: null;
const targetActivePath = activePath ?? currentActiveProject?.path ?? null;
const result = createVSCodeWorkspaceProjects(folders, current.projects, targetActivePath);
if (!result) {
if (folders.length === 0 && !activePath && current.projects.length > 0) {
set({ projects: [], activeProjectId: null });
cacheProjects([], null);
}
return null;
}
const projectsChanged = !vscodeWorkspaceProjectsEqual(current.projects, result.projects);
const activeChanged = current.activeProjectId !== result.activeProjectId;
if (projectsChanged || activeChanged) {
set({ projects: result.projects, activeProjectId: result.activeProjectId });
cacheProjects(result.projects, result.activeProjectId);
}
if (result.activeProject) {
opencodeClient.setDirectory(result.activeProject.path);
useDirectoryStore.getState().setDirectory(result.activeProject.path, { showOverlay: false });
}
return result.activeProject;
},
getActiveProject: () => {
const { projects, activeProjectId } = get();
if (!activeProjectId) {