feat: vscode extension (#59)
* feat: add initial VS Code extension plan and implementation tasks * feat(vscode): added initial version of an Openchamber VSCode extension * feat(vscode): enhance VS Code extension with theme integration and session management * feat(vscode): implement connection status handling and overlay in VSCode layout * feat: move extension to secondary sidebar * chore: upgrade @opencode-ai/sdk to 1.0.150 * vscode: editor bridge, file picker, click-to-open in tool parts * vscode: layout session lifecycle, theme sync, typography overrides * ui: compact mode for vscode, model search, autocomplete width fixes * perf: scroll force flag, raf placeholder, git polling backoff * ui: tool output styling, markdown code block fix, gitignore * refactor: update typography handling for VSCode runtime, remove unused styles * docs: update README with VS Code extension details and add extension image * docs: update changelog with new features and performance improvements
This commit is contained in:
committed by
GitHub
parent
610ccf4c62
commit
bb72c0fb0c
@@ -0,0 +1,114 @@
|
||||
declare const acquireVsCodeApi: () => {
|
||||
postMessage: (message: unknown) => void;
|
||||
getState: () => unknown;
|
||||
setState: (state: unknown) => void;
|
||||
};
|
||||
|
||||
interface VSCodeAPI {
|
||||
postMessage: (message: unknown) => void;
|
||||
}
|
||||
|
||||
let vscodeApi: VSCodeAPI | null = null;
|
||||
|
||||
function getVSCodeAPI(): VSCodeAPI {
|
||||
if (!vscodeApi) {
|
||||
vscodeApi = acquireVsCodeApi();
|
||||
}
|
||||
return vscodeApi;
|
||||
}
|
||||
|
||||
// Export vscode API for direct use
|
||||
export const vscode = {
|
||||
postMessage: (message: unknown) => getVSCodeAPI().postMessage(message),
|
||||
};
|
||||
|
||||
interface BridgeRequest {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
interface BridgeResponse {
|
||||
id: string;
|
||||
type: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const pendingRequests = new Map<string, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}>();
|
||||
|
||||
let requestIdCounter = 0;
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
|
||||
const response = event.data;
|
||||
if (!response || typeof response.id !== 'string') return;
|
||||
|
||||
const pending = pendingRequests.get(response.id);
|
||||
if (pending) {
|
||||
pendingRequests.delete(response.id);
|
||||
if (response.success) {
|
||||
pending.resolve(response.data);
|
||||
} else {
|
||||
pending.reject(new Error(response.error || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `req_${++requestIdCounter}_${Date.now()}`;
|
||||
const request: BridgeRequest = { id, type, payload };
|
||||
|
||||
pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(id)) {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${type} timed out`));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
getVSCodeAPI().postMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
type CommandHandler = (payload: unknown) => void;
|
||||
const commandHandlers = new Map<string, CommandHandler>();
|
||||
|
||||
export function onCommand(command: string, handler: CommandHandler): () => void {
|
||||
commandHandlers.set(command, handler);
|
||||
return () => commandHandlers.delete(command);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message?.type === 'command' && message.command) {
|
||||
const handler = commandHandlers.get(message.command);
|
||||
if (handler) {
|
||||
handler(message.payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type ThemeChangePayload = 'light' | 'dark' | { kind?: 'light' | 'dark' | 'high-contrast' };
|
||||
type ThemeChangeHandler = (theme: ThemeChangePayload) => void;
|
||||
let themeChangeHandler: ThemeChangeHandler | null = null;
|
||||
|
||||
export function onThemeChange(handler: ThemeChangeHandler): () => void {
|
||||
themeChangeHandler = handler;
|
||||
return () => { themeChangeHandler = null; };
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message?.type === 'themeChange' && themeChangeHandler) {
|
||||
themeChangeHandler(message.theme);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
import { sendBridgeMessage } from './bridge';
|
||||
import type { EditorAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createVSCodeEditorAPI = (): EditorAPI => ({
|
||||
openFile: async (path: string, line?: number, column?: number) => {
|
||||
await sendBridgeMessage('editor:openFile', { path, line, column });
|
||||
},
|
||||
openDiff: async (original: string, modified: string, label?: string) => {
|
||||
await sendBridgeMessage('editor:openDiff', { original, modified, label });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoints as web - fetch interceptor handles URL rewriting
|
||||
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
||||
|
||||
export const createVSCodeFilesAPI = (): FilesAPI => ({
|
||||
async listDirectory(path: string): Promise<DirectoryListResult> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/list', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to list directory');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||
const response = await fetch('/api/fs/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
directory: normalizePath(payload.directory),
|
||||
query: payload.query,
|
||||
maxResults: payload.maxResults,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to search files');
|
||||
}
|
||||
|
||||
const results = (await response.json()) as unknown;
|
||||
if (!Array.isArray(results)) {
|
||||
return [];
|
||||
}
|
||||
return results
|
||||
.filter((item): item is FileSearchResult => !!item && typeof item === 'object' && typeof (item as { path?: string }).path === 'string')
|
||||
.map((item) => ({
|
||||
path: normalizePath((item as FileSearchResult).path),
|
||||
score: (item as FileSearchResult).score,
|
||||
preview: (item as FileSearchResult).preview,
|
||||
}));
|
||||
},
|
||||
|
||||
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/mkdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create directory');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { RuntimeAPIs, TerminalAPI, GitAPI, NotificationsAPI } from '@openchamber/ui/lib/api/types';
|
||||
import { createVSCodeFilesAPI } from './files';
|
||||
import { createVSCodeSettingsAPI } from './settings';
|
||||
import { createVSCodePermissionsAPI } from './permissions';
|
||||
import { createVSCodeToolsAPI } from './tools';
|
||||
import { createVSCodeEditorAPI } from './editor';
|
||||
|
||||
// Stub APIs return sensible defaults instead of throwing
|
||||
const createStubTerminalAPI = (): TerminalAPI => ({
|
||||
createSession: async () => ({ sessionId: '', cols: 80, rows: 24 }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async () => {},
|
||||
});
|
||||
|
||||
const createStubGitAPI = (): GitAPI => ({
|
||||
checkIsGitRepository: async () => false,
|
||||
getGitStatus: async () => ({ current: '', tracking: null, ahead: 0, behind: 0, files: [], isClean: true }),
|
||||
getGitDiff: async () => ({ diff: '' }),
|
||||
getGitFileDiff: async () => ({ original: '', modified: '', path: '' }),
|
||||
revertGitFile: async () => {},
|
||||
isLinkedWorktree: async () => false,
|
||||
getGitBranches: async () => ({ all: [], current: '', branches: {} }),
|
||||
deleteGitBranch: async () => ({ success: false }),
|
||||
deleteRemoteBranch: async () => ({ success: false }),
|
||||
generateCommitMessage: async () => ({ message: { subject: '', highlights: [] } }),
|
||||
listGitWorktrees: async () => [],
|
||||
addGitWorktree: async () => ({ success: false, path: '', branch: '' }),
|
||||
removeGitWorktree: async () => ({ success: false }),
|
||||
ensureOpenChamberIgnored: async () => {},
|
||||
createGitCommit: async () => ({ success: false, commit: '', branch: '', summary: { changes: 0, insertions: 0, deletions: 0 } }),
|
||||
gitPush: async () => ({ success: false, pushed: [], repo: '', ref: null }),
|
||||
gitPull: async () => ({ success: false, summary: { changes: 0, insertions: 0, deletions: 0 }, files: [], insertions: 0, deletions: 0 }),
|
||||
gitFetch: async () => ({ success: false }),
|
||||
checkoutBranch: async () => ({ success: false, branch: '' }),
|
||||
createBranch: async () => ({ success: false, branch: '' }),
|
||||
getGitLog: async () => ({ all: [], latest: null, total: 0 }),
|
||||
getCommitFiles: async () => ({ files: [] }),
|
||||
getCurrentGitIdentity: async () => null,
|
||||
setGitIdentity: async () => ({ success: false, profile: { id: '', name: '', userName: '', userEmail: '' } }),
|
||||
getGitIdentities: async () => [],
|
||||
createGitIdentity: async (p) => p,
|
||||
updateGitIdentity: async (_, p) => p,
|
||||
deleteGitIdentity: async () => {},
|
||||
});
|
||||
|
||||
const createStubNotificationsAPI = (): NotificationsAPI => ({
|
||||
notifyAgentCompletion: async () => true,
|
||||
canNotify: () => true,
|
||||
});
|
||||
|
||||
export const createVSCodeAPIs = (): RuntimeAPIs => ({
|
||||
runtime: { platform: 'vscode', isDesktop: false, isVSCode: true, label: 'VS Code Extension' },
|
||||
terminal: createStubTerminalAPI(),
|
||||
git: createStubGitAPI(),
|
||||
files: createVSCodeFilesAPI(),
|
||||
settings: createVSCodeSettingsAPI(),
|
||||
permissions: createVSCodePermissionsAPI(),
|
||||
notifications: createStubNotificationsAPI(),
|
||||
tools: createVSCodeToolsAPI(),
|
||||
editor: createVSCodeEditorAPI(),
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DirectoryPermissionRequest, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createVSCodePermissionsAPI = (): PermissionsAPI => ({
|
||||
async requestDirectoryAccess(request: DirectoryPermissionRequest) {
|
||||
// VS Code handles permissions via workspace
|
||||
return { success: true, path: request.path };
|
||||
},
|
||||
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoints as web - fetch interceptor handles URL rewriting
|
||||
const SETTINGS_ENDPOINT = '/api/config/settings';
|
||||
const RELOAD_ENDPOINT = '/api/config/reload';
|
||||
|
||||
const sanitizePayload = (data: unknown): SettingsPayload => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return data as SettingsPayload;
|
||||
};
|
||||
|
||||
export const createVSCodeSettingsAPI = (): SettingsAPI => ({
|
||||
async load(): Promise<SettingsLoadResult> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Fallback to VS Code config
|
||||
return {
|
||||
settings: {
|
||||
themeVariant: window.__VSCODE_CONFIG__?.theme === 'light' ? 'light' : 'dark',
|
||||
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || '',
|
||||
},
|
||||
source: 'web',
|
||||
};
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return {
|
||||
settings: {
|
||||
...payload,
|
||||
// Override with VS Code settings
|
||||
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || payload.lastDirectory || '',
|
||||
},
|
||||
source: 'web',
|
||||
};
|
||||
},
|
||||
|
||||
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to save settings');
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return payload;
|
||||
},
|
||||
|
||||
async restartOpenCode(): Promise<{ restarted: boolean }> {
|
||||
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to restart OpenCode');
|
||||
}
|
||||
return { restarted: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoint as web - fetch interceptor handles URL rewriting
|
||||
export const createVSCodeToolsAPI = (): ToolsAPI => ({
|
||||
async getAvailableTools(): Promise<string[]> {
|
||||
const response = await fetch('/api/experimental/tool/ids');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Tools API returned invalid data format');
|
||||
}
|
||||
|
||||
return data
|
||||
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user