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:
committed by
GitHub
parent
49a1424e5f
commit
7b33805ea0
@@ -7,6 +7,7 @@ import { getWebviewHtml } from './webviewHtml';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||
import { resolveWorkspaceFolders } from './workspaceResolver';
|
||||
|
||||
const t = vscode.l10n.t;
|
||||
|
||||
@@ -256,12 +257,14 @@ export class AgentManagerPanelProvider {
|
||||
const workspaceFolder = normalizeWindowsDriveLetter(
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''
|
||||
);
|
||||
const workspaceFolders = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []);
|
||||
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
||||
|
||||
return getWebviewHtml({
|
||||
webview,
|
||||
extensionUri: this._extensionUri,
|
||||
workspaceFolder,
|
||||
workspaceFolders,
|
||||
initialStatus: this._cachedStatus,
|
||||
cliAvailable,
|
||||
panelType: 'agentManager',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getWebviewHtml } from './webviewHtml';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||
import { resolveWorkspaceFolders, type WorkspaceFolderCandidate } from './workspaceResolver';
|
||||
|
||||
type ActiveEditorFilePayload = {
|
||||
filePath: string;
|
||||
@@ -264,18 +265,29 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public createNewSession() {
|
||||
public createNewSession(options?: { directory?: string; workspaceFolders?: WorkspaceFolderCandidate[] }) {
|
||||
if (this._view) {
|
||||
// Reveal the webview panel
|
||||
this._view.show(true);
|
||||
|
||||
|
||||
this._view.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'newSession'
|
||||
command: 'newSession',
|
||||
...((options?.directory || options?.workspaceFolders?.length) && {
|
||||
payload: { directory: options?.directory, workspaceFolders: options?.workspaceFolders ?? [] },
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public syncWorkspaceFolders(workspaceFolders: WorkspaceFolderCandidate[]) {
|
||||
this._view?.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'workspaceFoldersChanged',
|
||||
payload: { workspaceFolders },
|
||||
});
|
||||
}
|
||||
|
||||
public showSettings() {
|
||||
if (this._view) {
|
||||
// Reveal the webview panel
|
||||
@@ -596,6 +608,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
const workspaceFolder = normalizeWindowsDriveLetter(
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''
|
||||
);
|
||||
const workspaceFolders = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []);
|
||||
// Use cached values which are updated by onStatusChange callback
|
||||
const initialStatus = this._cachedStatus;
|
||||
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
||||
@@ -604,6 +617,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
webview,
|
||||
extensionUri: this._extensionUri,
|
||||
workspaceFolder,
|
||||
workspaceFolders,
|
||||
initialStatus,
|
||||
cliAvailable,
|
||||
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getWebviewHtml } from './webviewHtml';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||
import { resolveWorkspaceFolders } from './workspaceResolver';
|
||||
|
||||
const t = vscode.l10n.t;
|
||||
|
||||
@@ -484,6 +485,7 @@ export class SessionEditorPanelProvider {
|
||||
const workspaceFolder = normalizeWindowsDriveLetter(
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''
|
||||
);
|
||||
const workspaceFolders = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []);
|
||||
const initialStatus = this._cachedStatus;
|
||||
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
||||
|
||||
@@ -491,6 +493,7 @@ export class SessionEditorPanelProvider {
|
||||
webview,
|
||||
extensionUri: this._extensionUri,
|
||||
workspaceFolder,
|
||||
workspaceFolders,
|
||||
initialStatus,
|
||||
cliAvailable,
|
||||
panelType: 'chat',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AgentManagerPanelProvider } from './AgentManagerPanelProvider';
|
||||
import { SessionEditorPanelProvider } from './SessionEditorPanelProvider';
|
||||
import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
||||
import { startGlobalEventWatcher, stopGlobalEventWatcher, setChatViewProvider } from './sessionActivityWatcher';
|
||||
import { resolveWorkspaceFolders } from './workspaceResolver';
|
||||
|
||||
let chatViewProvider: ChatViewProvider | undefined;
|
||||
let agentManagerProvider: AgentManagerPanelProvider | undefined;
|
||||
@@ -458,8 +459,51 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.newSession', () => {
|
||||
chatViewProvider?.createNewSession();
|
||||
vscode.commands.registerCommand('openchamber.newSession', async (directory?: unknown) => {
|
||||
const candidates = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []);
|
||||
let folderPath: string | undefined = typeof directory === 'string' ? directory : undefined;
|
||||
|
||||
if (!folderPath && candidates.length === 0) {
|
||||
vscode.window.showInformationMessage('OpenChamber: No folder is open. Open a folder to start a new session.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!folderPath) {
|
||||
folderPath = candidates.length === 1
|
||||
? candidates[0].path
|
||||
: (await vscode.window.showQuickPick(
|
||||
candidates.map((folder) => ({ label: folder.name, description: folder.path, path: folder.path })),
|
||||
{ placeHolder: 'Select a workspace folder for this session', matchOnDescription: true }
|
||||
))?.path;
|
||||
}
|
||||
|
||||
if (!folderPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (openCodeManager) {
|
||||
const result = await openCodeManager.setWorkingDirectory(folderPath);
|
||||
if (!result.success) {
|
||||
vscode.window.showErrorMessage(`OpenChamber: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const workspaceFolders = candidates.some((folder) => folder.path === folderPath)
|
||||
? candidates
|
||||
: [
|
||||
...candidates,
|
||||
{
|
||||
name: folderPath.split(/[\\/]/).filter(Boolean).pop() ?? folderPath,
|
||||
path: folderPath,
|
||||
},
|
||||
];
|
||||
chatViewProvider?.createNewSession({ directory: folderPath, workspaceFolders });
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeWorkspaceFolders(() => {
|
||||
chatViewProvider?.syncWorkspaceFolders(resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []));
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { spawnSync } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||
import { resolveWorkingDirectoryChange } from './workingDirectoryChange';
|
||||
|
||||
const t = vscode.l10n.t;
|
||||
|
||||
@@ -45,11 +46,15 @@ export type OpenCodeDebugInfo = {
|
||||
authSource: 'user-env' | 'generated' | 'rotated' | null;
|
||||
};
|
||||
|
||||
export type SetWorkingDirectoryResult =
|
||||
| { success: true; path: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export interface OpenCodeManager {
|
||||
start(workdir?: string): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
restart(): Promise<void>;
|
||||
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
|
||||
setWorkingDirectory(path: string): Promise<SetWorkingDirectoryResult>;
|
||||
getStatus(): ConnectionStatus;
|
||||
getApiUrl(): string | null;
|
||||
getOpenCodeAuthHeaders(): Record<string, string>;
|
||||
@@ -728,6 +733,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
||||
const workspaceDirectory = (): string =>
|
||||
normalizeWindowsDriveLetter(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir());
|
||||
const serverWorkingDirectory = (): string => normalizeWindowsDriveLetter(os.homedir());
|
||||
let workingDirectory: string = workspaceDirectory();
|
||||
let startCount = 0;
|
||||
let restartCount = 0;
|
||||
@@ -886,14 +892,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
});
|
||||
process.env.OPENCODE_SERVER_PASSWORD = password;
|
||||
|
||||
// SDK spawns `opencode serve` in current process cwd.
|
||||
// Some OpenCode endpoints behave differently based on server process cwd,
|
||||
// so ensure we start it from the workspace directory.
|
||||
// Match the web runtime: keep the server process in a neutral cwd and pass
|
||||
// the selected workspace through explicit `directory` API parameters.
|
||||
const serverCwd = serverWorkingDirectory();
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(workingDirectory);
|
||||
process.chdir(serverCwd);
|
||||
const port = await allocateManagedOpenCodePort();
|
||||
server = await spawnManagedOpenCodeServer(workingDirectory, port, READY_CHECK_TIMEOUT_MS);
|
||||
server = await spawnManagedOpenCodeServer(serverCwd, port, READY_CHECK_TIMEOUT_MS);
|
||||
} finally {
|
||||
try {
|
||||
process.chdir(originalCwd);
|
||||
@@ -992,9 +998,10 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
|
||||
async function restartInternal(): Promise<void> {
|
||||
restartCount += 1;
|
||||
const restartDirectory = workingDirectory;
|
||||
await stopInternal();
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
await startInternal(undefined, { rotateManaged: true });
|
||||
await startInternal(restartDirectory, { rotateManaged: true });
|
||||
}
|
||||
|
||||
async function start(workdir?: string): Promise<void> {
|
||||
@@ -1042,22 +1049,29 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
}
|
||||
}
|
||||
|
||||
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
|
||||
void newPath;
|
||||
const workspacePath = workspaceDirectory();
|
||||
const nextDirectory = workspacePath;
|
||||
|
||||
if (workingDirectory === nextDirectory) {
|
||||
return { success: true, restarted: false, path: nextDirectory };
|
||||
async function setWorkingDirectory(newPath: string): Promise<SetWorkingDirectoryResult> {
|
||||
const trimmed = newPath.trim();
|
||||
if (!trimmed) {
|
||||
return { success: false, error: 'path not found' };
|
||||
}
|
||||
|
||||
workingDirectory = nextDirectory;
|
||||
|
||||
if (useConfiguredUrl && configuredApiUrl) {
|
||||
return { success: true, restarted: false, path: nextDirectory };
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.promises.stat(trimmed);
|
||||
} catch {
|
||||
return { success: false, error: 'path not found' };
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return { success: false, error: 'path not found' };
|
||||
}
|
||||
|
||||
return { success: true, restarted: false, path: nextDirectory };
|
||||
const change = resolveWorkingDirectoryChange(workingDirectory, trimmed);
|
||||
if (!change.changed) {
|
||||
return { success: true, path: change.path };
|
||||
}
|
||||
|
||||
workingDirectory = change.path;
|
||||
return { success: true, path: change.path };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
|
||||
import * as os from 'os';
|
||||
import { getThemeKindName } from './theme';
|
||||
import type { ConnectionStatus } from './opencode';
|
||||
import type { WorkspaceFolderCandidate } from './workspaceResolver';
|
||||
|
||||
export type PanelType = 'chat' | 'agentManager';
|
||||
|
||||
@@ -9,6 +10,7 @@ export interface WebviewHtmlOptions {
|
||||
webview: vscode.Webview;
|
||||
extensionUri: vscode.Uri;
|
||||
workspaceFolder: string;
|
||||
workspaceFolders?: WorkspaceFolderCandidate[];
|
||||
initialStatus: ConnectionStatus;
|
||||
cliAvailable: boolean;
|
||||
panelType?: PanelType;
|
||||
@@ -46,6 +48,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
webview,
|
||||
extensionUri,
|
||||
workspaceFolder,
|
||||
workspaceFolders = [],
|
||||
initialStatus,
|
||||
cliAvailable,
|
||||
panelType = 'chat',
|
||||
@@ -54,6 +57,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
devServerUrl,
|
||||
extensionVersion = '',
|
||||
} = options;
|
||||
const workspaceFoldersJson = JSON.stringify(workspaceFolders).replace(/</g, '\\u003c');
|
||||
|
||||
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
||||
const scriptUri = webview.asWebviewUri(scriptPath);
|
||||
@@ -176,6 +180,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
|
||||
window.__VSCODE_CONFIG__ = {
|
||||
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
|
||||
workspaceFolders: ${workspaceFoldersJson},
|
||||
theme: "${themeKind}",
|
||||
connectionStatus: "${initialStatus}",
|
||||
cliAvailable: ${cliAvailable},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveWorkingDirectoryChange } from './workingDirectoryChange.ts';
|
||||
|
||||
describe('resolveWorkingDirectoryChange', () => {
|
||||
test('returns unchanged when the selected directory already matches', () => {
|
||||
expect(resolveWorkingDirectoryChange('/work/alpha', '/work/alpha')).toEqual({
|
||||
changed: false,
|
||||
path: '/work/alpha',
|
||||
});
|
||||
});
|
||||
|
||||
test('updates the directory without requiring an OpenCode server restart', () => {
|
||||
expect(resolveWorkingDirectoryChange('/work/alpha', '/work/bravo')).toEqual({
|
||||
changed: true,
|
||||
path: '/work/bravo',
|
||||
});
|
||||
});
|
||||
|
||||
test('trims the selected directory before comparing', () => {
|
||||
expect(resolveWorkingDirectoryChange('/work/alpha', ' /work/bravo ')).toEqual({
|
||||
changed: true,
|
||||
path: '/work/bravo',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||
|
||||
export type WorkingDirectoryChange =
|
||||
| { changed: false; path: string }
|
||||
| { changed: true; path: string };
|
||||
|
||||
export function resolveWorkingDirectoryChange(
|
||||
currentDirectory: string,
|
||||
nextDirectory: string
|
||||
): WorkingDirectoryChange {
|
||||
const normalized = normalizeWindowsDriveLetter(nextDirectory.trim());
|
||||
if (currentDirectory === normalized) {
|
||||
return { changed: false, path: normalized };
|
||||
}
|
||||
return { changed: true, path: normalized };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveWorkspaceFolders } from './workspaceResolver.ts';
|
||||
|
||||
const ALPHA = { name: 'alpha', uri: { fsPath: '/work/alpha' } };
|
||||
const BRAVO = { name: 'Bravo', uri: { fsPath: '/work/bravo' } };
|
||||
const CHARLIE = { name: 'Charlie', uri: { fsPath: '/work/charlie' } };
|
||||
const ALPHA_DUP = { name: 'alpha-dup', uri: { fsPath: '/work/alpha' } };
|
||||
const ALPHA_WITH_TRAILING = { name: 'alpha', uri: { fsPath: '/work/alpha///' } };
|
||||
const BRAVO_WITH_TRAILING = { name: 'bravo', uri: { fsPath: '/work/bravo//' } };
|
||||
|
||||
describe('resolveWorkspaceFolders', () => {
|
||||
describe('when the input is empty', () => {
|
||||
test('returns an empty list without throwing', () => {
|
||||
expect(resolveWorkspaceFolders([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a single folder is provided', () => {
|
||||
test('preserves its name and path', () => {
|
||||
expect(resolveWorkspaceFolders([ALPHA])).toEqual([
|
||||
{ name: 'alpha', path: '/work/alpha' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when multiple folders are provided', () => {
|
||||
test('returns them sorted alphabetically by name, case-insensitive', () => {
|
||||
const result = resolveWorkspaceFolders([CHARLIE, ALPHA, BRAVO]);
|
||||
|
||||
expect(result.map((entry) => entry.name)).toEqual([
|
||||
'alpha',
|
||||
'Bravo',
|
||||
'Charlie',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when folders share the same path', () => {
|
||||
test('keeps only the first occurrence and discards duplicates by path', () => {
|
||||
const result = resolveWorkspaceFolders([ALPHA, ALPHA_DUP, BRAVO]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'alpha', path: '/work/alpha' },
|
||||
{ name: 'Bravo', path: '/work/bravo' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when paths contain trailing separators', () => {
|
||||
test('strips them from every returned path', () => {
|
||||
const result = resolveWorkspaceFolders([
|
||||
ALPHA_WITH_TRAILING,
|
||||
BRAVO_WITH_TRAILING,
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'alpha', path: '/work/alpha' },
|
||||
{ name: 'bravo', path: '/work/bravo' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('treats paths that differ only by trailing separators as the same folder', () => {
|
||||
const result = resolveWorkspaceFolders([ALPHA_WITH_TRAILING, ALPHA_DUP]);
|
||||
|
||||
expect(result).toEqual([{ name: 'alpha', path: '/work/alpha' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||
|
||||
export interface WorkspaceFolderInput {
|
||||
name: string;
|
||||
uri: { fsPath: string };
|
||||
}
|
||||
|
||||
export interface WorkspaceFolderCandidate {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceFolders(
|
||||
folders: ReadonlyArray<WorkspaceFolderInput>
|
||||
): WorkspaceFolderCandidate[] {
|
||||
const seen = new Map<string, WorkspaceFolderCandidate>();
|
||||
for (const folder of folders) {
|
||||
const path = normalizeWindowsDriveLetter(folder.uri.fsPath).replace(/[\\/]+$/, '');
|
||||
if (!seen.has(path)) {
|
||||
seen.set(path, { name: folder.name, path });
|
||||
}
|
||||
}
|
||||
return [...seen.values()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user