diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9a9353..e34108a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file. - Desktop: browser pages served from a self-signed loopback HTTPS address now load instead of being blocked by the certificate warning. - Browser: typing a comment on a page no longer triggers app shortcuts. - Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech). +- VSCode: Add Project now adds the chosen folder to the workspace instead of showing a "Failed to add project" toast. ## [1.18.4] - 2026-08-14 diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 56b5dcab..3166bb41 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -417,11 +417,11 @@ export const DirectoryExplorerDialog: React.FC = ( handleClose(); }, [handleClose, isMobile, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); - const handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => { + const handleQuickAdd = React.useCallback(async (event: React.MouseEvent, path: string) => { event.stopPropagation(); const normalized = normalizeDirectoryPath(path); if (normalized && addedProjectPaths.has(normalized)) return; - const project = addProject(path); + const project = await addProject(path); if (!project) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), @@ -455,7 +455,7 @@ export const DirectoryExplorerDialog: React.FC = ( } else if (shouldCreateSelection) { await opencodeClient.createDirectory(target, { asProject: true }); } - const project = addProject(selectedTarget); + const project = await addProject(selectedTarget); if (!project) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 58f126cb..f45f0356 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -772,6 +772,8 @@ export interface VSCodeAPI { pickFiles?(options?: { extensions?: string[] }): Promise; saveImage?(payload: unknown): Promise; saveMarkdown?(payload: unknown): Promise; + /** Add a directory as a VS Code workspace folder; resolves with the full folder list after the add. */ + addWorkspaceFolder?(path: string): Promise>; } export interface PushSubscribePayload { diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 0f37f137..358c138a 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -49,7 +49,7 @@ interface ProjectsStore { activeProjectId: string | null; manualProjectOrder: string[]; - addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null; + addProject: (path: string, options?: { label?: string; id?: string }) => Promise; removeProject: (id: string) => void; setActiveProject: (id: string) => void; setActiveProjectIdOnly: (id: string) => void; @@ -166,6 +166,13 @@ const normalizeProjectPath = (value: string): string => { return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; }; +// VS Code workspace folder paths come from the extension host with uppercase +// drive letters (see resolveWorkspaceFolders in packages/vscode), while paths +// typed or browsed in the webview keep the lowercase drive of fsPath. Normalize +// to the workspace form so dedupe and active-path matching agree on Windows. +const normalizeVSCodeWorkspacePath = (value: string): string => + value.replace(/^([a-z]):/, (_, letter: string) => letter.toUpperCase() + ':'); + // Folder names are shown verbatim: title-casing them turned `.ssh` into `.Ssh` // and made every project look like a name the user never chose. const deriveProjectLabel = (path: string): string => { @@ -575,8 +582,29 @@ export const useProjectsStore = create()( return { ok: true, normalizedPath: normalized }; }, - addProject: (path: string, options?: { label?: string; id?: string }) => { + addProject: async (path: string, options?: { label?: string; id?: string }) => { if (isVSCodeProjectsRuntime) { + // Projects are scoped to VS Code workspace folders in this runtime. + // Adding a folder through the extension host makes the project appear + // in the workspace and the new folder is synced back as a project. + const validation = get().validateProjectPath(path); + if (!validation.ok || !validation.normalizedPath) { + return null; + } + const normalizedPath = normalizeVSCodeWorkspacePath(validation.normalizedPath); + const existing = get().projects.find((project) => project.path === normalizedPath); + if (existing) { + return existing; + } + const runtimeApis = getRegisteredRuntimeAPIs(); + if (runtimeApis?.vscode?.addWorkspaceFolder) { + try { + const folders = await runtimeApis.vscode.addWorkspaceFolder(normalizedPath); + return get().syncVSCodeWorkspaceFolders(folders, normalizedPath); + } catch { + return null; + } + } return null; } const { validateProjectPath } = get(); diff --git a/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts b/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts new file mode 100644 index 00000000..eb32663a --- /dev/null +++ b/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts @@ -0,0 +1,136 @@ +// Regression test for issue #2582: "Add Project" in the VS Code extension +// always failed with the "Failed to add project" toast because +// useProjectsStore.addProject() returned null unconditionally in the VS Code +// runtime (projects are scoped to VS Code workspace folders). The fix makes +// addProject() add the chosen directory as a workspace folder through the +// extension host and sync the new folder back as a project. + +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +// VS Code runtime detection reads window.__VSCODE_CONFIG__ at module load time; +// bun test has no browser window, so install a test window before importing the +// store (mirrors packages/vscode/src/webviewHtml.ts which sets the config). +Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + __VSCODE_CONFIG__: { + workspaceFolder: '/workspace/project-one', + workspaceFolders: [{ name: 'project-one', path: '/workspace/project-one' }], + }, + __OPENCHAMBER_LOCAL_ORIGIN__: '', + addEventListener: () => {}, + removeEventListener: () => {}, + }, +}); + +// Transitive imports read location.search / navigator / localStorage as bare +// globals at module load time. +Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { href: 'https://example.test/', search: '', pathname: '/', hash: '' }, +}); +Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { platform: 'linux', userAgent: 'bun-test', language: 'en-US', maxTouchPoints: 0 }, +}); +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: (() => { + const store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, String(value)); }, + removeItem: (key: string) => { store.delete(key); }, + clear: () => { store.clear(); }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { return store.size; }, + }; + })(), +}); + +const noop = () => {}; +const opencodeClientStub = new Proxy( + { + setDirectory: noop, + getDirectory: () => null, + getFilesystemHome: async () => null, + getSystemInfo: async () => null, + listLocalDirectory: async () => [], + cloneRepository: async () => ({}), + createDirectory: async () => {}, + }, + { + get(target, prop) { + if (prop in target) { + // SAFETY: `prop in target` was just checked, so the key exists on the + // stub object and the cast narrows to its known key type. + return target[prop as keyof typeof target]; + } + return noop; + }, + }, +); +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: opencodeClientStub, +})); +mock.module('@/lib/persistence', () => ({ + updateDesktopSettings: async () => {}, +})); + +const addWorkspaceFolderCalls: string[] = []; +let addWorkspaceFolderError: Error | null = null; + +// SAFETY: the store only needs the vscode capability plus the runtime flag; +// everything else on RuntimeAPIs is never reached by the addProject path. +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: () => ({ + runtime: { platform: 'vscode', isDesktop: false, isVSCode: true, label: 'VS Code Extension' }, + vscode: { + async addWorkspaceFolder(path: string) { + addWorkspaceFolderCalls.push(path); + if (addWorkspaceFolderError) { + throw addWorkspaceFolderError; + } + return [ + { name: 'project-one', path: '/workspace/project-one' }, + { name: 'my-project', path }, + ]; + }, + }, + }), + registerRuntimeAPIs: () => {}, +})); + +const { useProjectsStore } = await import('@/stores/useProjectsStore'); + +beforeEach(() => { + addWorkspaceFolderCalls.length = 0; + addWorkspaceFolderError = null; +}); + +describe('issue #2582: addProject in the VS Code runtime', () => { + test('adds the directory as a workspace folder and syncs it as a project', async () => { + const added = await useProjectsStore.getState().addProject('/home/user/my-project'); + + expect(addWorkspaceFolderCalls).toEqual(['/home/user/my-project']); + expect(added).not.toBeNull(); + expect(added?.path).toBe('/home/user/my-project'); + expect(useProjectsStore.getState().projects.find((p) => p.path === '/home/user/my-project')).toBeTruthy(); + }); + + test('returns the existing project for a folder already in the workspace without calling the host', async () => { + const existing = await useProjectsStore.getState().addProject('/workspace/project-one'); + + expect(addWorkspaceFolderCalls).toEqual([]); + expect(existing?.path).toBe('/workspace/project-one'); + }); + + test('returns null when the extension host cannot add the folder', async () => { + addWorkspaceFolderError = new Error('cancelled'); + + const added = await useProjectsStore.getState().addProject('/other/path'); + + expect(added).toBeNull(); + expect(useProjectsStore.getState().projects.find((p) => p.path === '/other/path')).toBeFalsy(); + }); +}); diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 4c91c676..cc65d097 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -11,6 +11,7 @@ - Attachments: extracted Office and OpenDocument content is now capped and presented more compactly, preventing large documents and their images from overwhelming the message context. - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude`; names you renamed yourself are kept. - Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech). +- Add Project now adds the chosen folder to the workspace instead of showing a "Failed to add project" toast. ## [1.18.4] - 2026-08-14 diff --git a/packages/vscode/src/bridge-system-runtime.test.js b/packages/vscode/src/bridge-system-runtime.test.js index eadf82ca..716f9298 100644 --- a/packages/vscode/src/bridge-system-runtime.test.js +++ b/packages/vscode/src/bridge-system-runtime.test.js @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; const executeCommand = mock(async () => undefined); +const updateWorkspaceFolders = mock(async (start, deleteCount, ...foldersToAdd) => { + for (const folder of foldersToAdd) { + currentWorkspaceFolders = [...currentWorkspaceFolders, { name: folder.uri.fsPath.split('/').pop(), uri: folder.uri }]; + } + return true; +}); +let currentWorkspaceFolders = []; class Position { constructor(line, character) { @@ -19,7 +26,10 @@ class Range { mock.module('vscode', () => ({ commands: { executeCommand }, workspace: { - workspaceFolders: [], + get workspaceFolders() { + return currentWorkspaceFolders; + }, + updateWorkspaceFolders, }, Uri: { file: (fsPath) => ({ scheme: 'file', fsPath }), @@ -65,6 +75,8 @@ const deps = { describe('VS Code system bridge editor:openFile', () => { beforeEach(() => { executeCommand.mockClear(); + updateWorkspaceFolders.mockClear(); + currentWorkspaceFolders = []; }); test('uses vscode.open so VS Code can select the notebook editor', async () => { @@ -97,3 +109,113 @@ describe('VS Code system bridge editor:openFile', () => { ); }); }); + +describe('VS Code system bridge api:workspace:addFolder', () => { + beforeEach(() => { + updateWorkspaceFolders.mockClear(); + currentWorkspaceFolders = []; + }); + + test('adds a folder to the workspace and returns the folder list', async () => { + currentWorkspaceFolders = [{ name: 'project-one', uri: { fsPath: '/workspace/project-one' } }]; + + const response = await handleSystemBridgeMessage({ + id: 'add-folder', + type: 'api:workspace:addFolder', + payload: { path: '/home/user/my-project' }, + }, undefined, deps); + + expect(response).toEqual({ + id: 'add-folder', + type: 'api:workspace:addFolder', + success: true, + data: { + workspaceFolders: [ + { name: 'my-project', path: '/home/user/my-project' }, + { name: 'project-one', path: '/workspace/project-one' }, + ], + }, + }); + expect(updateWorkspaceFolders).toHaveBeenCalledWith( + 1, + null, + { uri: { scheme: 'file', fsPath: '/home/user/my-project' } }, + ); + }); + + test('does not duplicate an already-open workspace folder', async () => { + currentWorkspaceFolders = [{ name: 'project-one', uri: { fsPath: '/workspace/project-one' } }]; + + const response = await handleSystemBridgeMessage({ + id: 'add-existing', + type: 'api:workspace:addFolder', + payload: { path: '/workspace/project-one' }, + }, undefined, deps); + + expect(response).toEqual({ + id: 'add-existing', + type: 'api:workspace:addFolder', + success: true, + data: { + workspaceFolders: [{ name: 'project-one', path: '/workspace/project-one' }], + }, + }); + expect(updateWorkspaceFolders).not.toHaveBeenCalled(); + }); + + test('returns an error when VS Code rejects the folder add', async () => { + updateWorkspaceFolders.mockResolvedValue(false); + + const response = await handleSystemBridgeMessage({ + id: 'add-rejected', + type: 'api:workspace:addFolder', + payload: { path: '/home/user/other' }, + }, undefined, deps); + + expect(response).toEqual({ + id: 'add-rejected', + type: 'api:workspace:addFolder', + success: false, + error: 'Failed to add workspace folder', + }); + }); + + test('dedupes an already-open folder with a lowercase Windows drive letter', async () => { + // VS Code reports workspace folder paths with lowercase drive letters + // (d:\...), while the bridge normalizes the incoming path to uppercase + // (D:\...). The comparison must normalize both sides. + currentWorkspaceFolders = [{ name: 'project-one', uri: { fsPath: 'd:\\work\\project-one' } }]; + + const response = await handleSystemBridgeMessage({ + id: 'add-win-dedupe', + type: 'api:workspace:addFolder', + payload: { path: 'D:\\work\\project-one' }, + }, undefined, deps); + + expect(response).toEqual({ + id: 'add-win-dedupe', + type: 'api:workspace:addFolder', + success: true, + data: { + workspaceFolders: [{ name: 'project-one', path: 'D:\\work\\project-one' }], + }, + }); + expect(updateWorkspaceFolders).not.toHaveBeenCalled(); + }); + + test('rejects a missing path', async () => { + const response = await handleSystemBridgeMessage({ + id: 'add-missing', + type: 'api:workspace:addFolder', + payload: {}, + }, undefined, deps); + + expect(response).toEqual({ + id: 'add-missing', + type: 'api:workspace:addFolder', + success: false, + error: 'Directory path is required', + }); + expect(updateWorkspaceFolders).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index a84ce257..96de0bee 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -10,6 +10,8 @@ import { credentialStatus, deleteCredential, importCursorCredential, normalizeCr import { getSessionActivitySnapshot } from './sessionActivityWatcher'; import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime'; import { buildDeferredRestartResponse } from './config-mutation-response'; +import { normalizeWindowsDriveLetter } from './pathUtils'; +import { resolveWorkspaceFolders } from './workspaceResolver'; import type { BridgeContext, BridgeResponse } from './bridge'; type BridgeMessageInput = { @@ -594,6 +596,41 @@ export async function handleSystemBridgeMessage( } } + case 'api:workspace:addFolder': { + try { + // SAFETY: bridge payloads are untrusted JSON from the webview; the + // cast only reads the optional path field, and non-string values fail + // the emptiness check below (or throw inside the try, which the catch + // converts into a clean failure response). + const { path: targetPath } = (payload || {}) as { path?: string }; + if (!targetPath || targetPath.trim().length === 0) { + return { id, type, success: false, error: 'Directory path is required' }; + } + const folders = vscode.workspace.workspaceFolders ?? []; + const uri = vscode.Uri.file(normalizeWindowsDriveLetter(targetPath.trim())); + // VS Code reports workspace folder paths with lowercase Windows drive + // letters (see pathUtils), so normalize both sides before comparing. + const alreadyAdded = folders.some( + (folder) => normalizeWindowsDriveLetter(folder.uri.fsPath) === uri.fsPath, + ); + if (!alreadyAdded) { + const updated = await vscode.workspace.updateWorkspaceFolders(folders.length, null, { uri }); + if (!updated) { + return { id, type, success: false, error: 'Failed to add workspace folder' }; + } + } + return { + id, + type, + success: true, + data: { workspaceFolders: resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []) }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + case 'vscode:command': { const { command, args } = (payload || {}) as { command?: string; args?: unknown[] }; if (!command) { diff --git a/packages/vscode/webview/api/vscode.ts b/packages/vscode/webview/api/vscode.ts index 951189c6..33fa10af 100644 --- a/packages/vscode/webview/api/vscode.ts +++ b/packages/vscode/webview/api/vscode.ts @@ -15,6 +15,14 @@ export const createVSCodeActionsAPI = (): VSCodeAPI => ({ await openVSCodeExternalUrl(url); }, + async addWorkspaceFolder(path: string): Promise> { + const result = await sendBridgeMessage<{ workspaceFolders: Array<{ name: string; path: string }> }>( + 'api:workspace:addFolder', + { path }, + ); + return Array.isArray(result?.workspaceFolders) ? result.workspaceFolders : []; + }, + async pickFiles(options): Promise { return sendBridgeMessage('api:files/pick', options); },