fix(vscode): add project adds the chosen folder to the workspace
This commit is contained in:
@@ -417,11 +417,11 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
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<DirectoryExplorerDialogProps> = (
|
||||
} 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'),
|
||||
|
||||
@@ -772,6 +772,8 @@ export interface VSCodeAPI {
|
||||
pickFiles?(options?: { extensions?: string[] }): Promise<unknown>;
|
||||
saveImage?(payload: unknown): Promise<unknown>;
|
||||
saveMarkdown?(payload: unknown): Promise<unknown>;
|
||||
/** Add a directory as a VS Code workspace folder; resolves with the full folder list after the add. */
|
||||
addWorkspaceFolder?(path: string): Promise<Array<{ name: string; path: string }>>;
|
||||
}
|
||||
|
||||
export interface PushSubscribePayload {
|
||||
|
||||
@@ -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<ProjectEntry | null>;
|
||||
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<ProjectsStore>()(
|
||||
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();
|
||||
|
||||
@@ -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<string, string>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -15,6 +15,14 @@ export const createVSCodeActionsAPI = (): VSCodeAPI => ({
|
||||
await openVSCodeExternalUrl(url);
|
||||
},
|
||||
|
||||
async addWorkspaceFolder(path: string): Promise<Array<{ name: string; path: string }>> {
|
||||
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<unknown> {
|
||||
return sendBridgeMessage('api:files/pick', options);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user