blackbox-cli-vscode-ide-companion 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ IdeDiffAcceptedNotificationSchema,
9
+ IdeDiffClosedNotificationSchema,
10
+ } from '@blackbox_ai/blackbox-cli-core';
11
+ import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js';
12
+ import * as path from 'node:path';
13
+ import * as vscode from 'vscode';
14
+ import { DIFF_SCHEME } from './extension.js';
15
+
16
+ export class DiffContentProvider implements vscode.TextDocumentContentProvider {
17
+ private content = new Map<string, string>();
18
+ private onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
19
+
20
+ get onDidChange(): vscode.Event<vscode.Uri> {
21
+ return this.onDidChangeEmitter.event;
22
+ }
23
+
24
+ provideTextDocumentContent(uri: vscode.Uri): string {
25
+ return this.content.get(uri.toString()) ?? '';
26
+ }
27
+
28
+ setContent(uri: vscode.Uri, content: string): void {
29
+ this.content.set(uri.toString(), content);
30
+ this.onDidChangeEmitter.fire(uri);
31
+ }
32
+
33
+ deleteContent(uri: vscode.Uri): void {
34
+ this.content.delete(uri.toString());
35
+ }
36
+
37
+ getContent(uri: vscode.Uri): string | undefined {
38
+ return this.content.get(uri.toString());
39
+ }
40
+ }
41
+
42
+ // Information about a diff view that is currently open.
43
+ interface DiffInfo {
44
+ originalFilePath: string;
45
+ newContent: string;
46
+ rightDocUri: vscode.Uri;
47
+ }
48
+
49
+ /**
50
+ * Manages the state and lifecycle of diff views within the IDE.
51
+ */
52
+ export class DiffManager {
53
+ private readonly onDidChangeEmitter =
54
+ new vscode.EventEmitter<JSONRPCNotification>();
55
+ readonly onDidChange = this.onDidChangeEmitter.event;
56
+ private diffDocuments = new Map<string, DiffInfo>();
57
+ private readonly subscriptions: vscode.Disposable[] = [];
58
+
59
+ constructor(
60
+ private readonly log: (message: string) => void,
61
+ private readonly diffContentProvider: DiffContentProvider,
62
+ ) {
63
+ this.subscriptions.push(
64
+ vscode.window.onDidChangeActiveTextEditor((editor) => {
65
+ this.onActiveEditorChange(editor);
66
+ }),
67
+ );
68
+ this.onActiveEditorChange(vscode.window.activeTextEditor);
69
+ }
70
+
71
+ dispose() {
72
+ for (const subscription of this.subscriptions) {
73
+ subscription.dispose();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Creates and shows a new diff view.
79
+ */
80
+ async showDiff(filePath: string, newContent: string) {
81
+ const fileUri = vscode.Uri.file(filePath);
82
+
83
+ const rightDocUri = vscode.Uri.from({
84
+ scheme: DIFF_SCHEME,
85
+ path: filePath,
86
+ // cache busting
87
+ query: `rand=${Math.random()}`,
88
+ });
89
+ this.diffContentProvider.setContent(rightDocUri, newContent);
90
+
91
+ this.addDiffDocument(rightDocUri, {
92
+ originalFilePath: filePath,
93
+ newContent,
94
+ rightDocUri,
95
+ });
96
+
97
+ const diffTitle = `${path.basename(filePath)} ↔ Modified`;
98
+ await vscode.commands.executeCommand(
99
+ 'setContext',
100
+ 'blackbox.diff.isVisible',
101
+ true,
102
+ );
103
+
104
+ let leftDocUri;
105
+ try {
106
+ await vscode.workspace.fs.stat(fileUri);
107
+ leftDocUri = fileUri;
108
+ } catch {
109
+ // We need to provide an empty document to diff against.
110
+ // Using the 'untitled' scheme is one way to do this.
111
+ leftDocUri = vscode.Uri.from({
112
+ scheme: 'untitled',
113
+ path: filePath,
114
+ });
115
+ }
116
+
117
+ await vscode.commands.executeCommand(
118
+ 'vscode.diff',
119
+ leftDocUri,
120
+ rightDocUri,
121
+ diffTitle,
122
+ {
123
+ preview: false,
124
+ preserveFocus: true,
125
+ },
126
+ );
127
+ await vscode.commands.executeCommand(
128
+ 'workbench.action.files.setActiveEditorWriteableInSession',
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Closes an open diff view for a specific file.
134
+ */
135
+ async closeDiff(filePath: string) {
136
+ let uriToClose: vscode.Uri | undefined;
137
+ for (const [uriString, diffInfo] of this.diffDocuments.entries()) {
138
+ if (diffInfo.originalFilePath === filePath) {
139
+ uriToClose = vscode.Uri.parse(uriString);
140
+ break;
141
+ }
142
+ }
143
+
144
+ if (uriToClose) {
145
+ const rightDoc = await vscode.workspace.openTextDocument(uriToClose);
146
+ const modifiedContent = rightDoc.getText();
147
+ await this.closeDiffEditor(uriToClose);
148
+ this.onDidChangeEmitter.fire(
149
+ IdeDiffClosedNotificationSchema.parse({
150
+ jsonrpc: '2.0',
151
+ method: 'ide/diffClosed',
152
+ params: {
153
+ filePath,
154
+ content: modifiedContent,
155
+ },
156
+ }),
157
+ );
158
+ return modifiedContent;
159
+ }
160
+ return;
161
+ }
162
+
163
+ /**
164
+ * User accepts the changes in a diff view. Does not apply changes.
165
+ */
166
+ async acceptDiff(rightDocUri: vscode.Uri) {
167
+ const diffInfo = this.diffDocuments.get(rightDocUri.toString());
168
+ if (!diffInfo) {
169
+ this.log(`No diff info found for ${rightDocUri.toString()}`);
170
+ return;
171
+ }
172
+
173
+ const rightDoc = await vscode.workspace.openTextDocument(rightDocUri);
174
+ const modifiedContent = rightDoc.getText();
175
+ await this.closeDiffEditor(rightDocUri);
176
+
177
+ this.onDidChangeEmitter.fire(
178
+ IdeDiffAcceptedNotificationSchema.parse({
179
+ jsonrpc: '2.0',
180
+ method: 'ide/diffAccepted',
181
+ params: {
182
+ filePath: diffInfo.originalFilePath,
183
+ content: modifiedContent,
184
+ },
185
+ }),
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Called when a user cancels a diff view.
191
+ */
192
+ async cancelDiff(rightDocUri: vscode.Uri) {
193
+ const diffInfo = this.diffDocuments.get(rightDocUri.toString());
194
+ if (!diffInfo) {
195
+ this.log(`No diff info found for ${rightDocUri.toString()}`);
196
+ // Even if we don't have diff info, we should still close the editor.
197
+ await this.closeDiffEditor(rightDocUri);
198
+ return;
199
+ }
200
+
201
+ const rightDoc = await vscode.workspace.openTextDocument(rightDocUri);
202
+ const modifiedContent = rightDoc.getText();
203
+ await this.closeDiffEditor(rightDocUri);
204
+
205
+ this.onDidChangeEmitter.fire(
206
+ IdeDiffClosedNotificationSchema.parse({
207
+ jsonrpc: '2.0',
208
+ method: 'ide/diffClosed',
209
+ params: {
210
+ filePath: diffInfo.originalFilePath,
211
+ content: modifiedContent,
212
+ },
213
+ }),
214
+ );
215
+ }
216
+
217
+ private async onActiveEditorChange(editor: vscode.TextEditor | undefined) {
218
+ let isVisible = false;
219
+ if (editor) {
220
+ isVisible = this.diffDocuments.has(editor.document.uri.toString());
221
+ if (!isVisible) {
222
+ for (const document of this.diffDocuments.values()) {
223
+ if (document.originalFilePath === editor.document.uri.fsPath) {
224
+ isVisible = true;
225
+ break;
226
+ }
227
+ }
228
+ }
229
+ }
230
+ await vscode.commands.executeCommand(
231
+ 'setContext',
232
+ 'blackbox.diff.isVisible',
233
+ isVisible,
234
+ );
235
+ }
236
+
237
+ private addDiffDocument(uri: vscode.Uri, diffInfo: DiffInfo) {
238
+ this.diffDocuments.set(uri.toString(), diffInfo);
239
+ }
240
+
241
+ private async closeDiffEditor(rightDocUri: vscode.Uri) {
242
+ const diffInfo = this.diffDocuments.get(rightDocUri.toString());
243
+ await vscode.commands.executeCommand(
244
+ 'setContext',
245
+ 'blackbox.diff.isVisible',
246
+ false,
247
+ );
248
+
249
+ if (diffInfo) {
250
+ this.diffDocuments.delete(rightDocUri.toString());
251
+ this.diffContentProvider.deleteContent(rightDocUri);
252
+ }
253
+
254
+ // Find and close the tab corresponding to the diff view
255
+ for (const tabGroup of vscode.window.tabGroups.all) {
256
+ for (const tab of tabGroup.tabs) {
257
+ const input = tab.input as {
258
+ modified?: vscode.Uri;
259
+ original?: vscode.Uri;
260
+ };
261
+ if (input && input.modified?.toString() === rightDocUri.toString()) {
262
+ await vscode.window.tabGroups.close(tab);
263
+ return;
264
+ }
265
+ }
266
+ }
267
+ }
268
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import * as vscode from 'vscode';
9
+ import { activate } from './extension.js';
10
+
11
+ vi.mock('vscode', () => ({
12
+ window: {
13
+ createOutputChannel: vi.fn(() => ({
14
+ appendLine: vi.fn(),
15
+ })),
16
+ showInformationMessage: vi.fn(),
17
+ createTerminal: vi.fn(() => ({
18
+ show: vi.fn(),
19
+ sendText: vi.fn(),
20
+ })),
21
+ onDidChangeActiveTextEditor: vi.fn(),
22
+ activeTextEditor: undefined,
23
+ tabGroups: {
24
+ all: [],
25
+ close: vi.fn(),
26
+ },
27
+ showTextDocument: vi.fn(),
28
+ showWorkspaceFolderPick: vi.fn(),
29
+ },
30
+ workspace: {
31
+ workspaceFolders: [],
32
+ onDidCloseTextDocument: vi.fn(),
33
+ registerTextDocumentContentProvider: vi.fn(),
34
+ onDidChangeWorkspaceFolders: vi.fn(),
35
+ },
36
+ commands: {
37
+ registerCommand: vi.fn(),
38
+ executeCommand: vi.fn(),
39
+ },
40
+ Uri: {
41
+ joinPath: vi.fn(),
42
+ },
43
+ ExtensionMode: {
44
+ Development: 1,
45
+ Production: 2,
46
+ },
47
+ EventEmitter: vi.fn(() => ({
48
+ event: vi.fn(),
49
+ fire: vi.fn(),
50
+ dispose: vi.fn(),
51
+ })),
52
+ }));
53
+
54
+ describe('activate', () => {
55
+ let context: vscode.ExtensionContext;
56
+
57
+ beforeEach(() => {
58
+ context = {
59
+ subscriptions: [],
60
+ environmentVariableCollection: {
61
+ replace: vi.fn(),
62
+ },
63
+ globalState: {
64
+ get: vi.fn(),
65
+ update: vi.fn(),
66
+ },
67
+ extensionUri: {
68
+ fsPath: '/path/to/extension',
69
+ },
70
+ } as unknown as vscode.ExtensionContext;
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.restoreAllMocks();
75
+ });
76
+
77
+ it('should show the info message on first activation', async () => {
78
+ const showInformationMessageMock = vi
79
+ .mocked(vscode.window.showInformationMessage)
80
+ .mockResolvedValue(undefined as never);
81
+ vi.mocked(context.globalState.get).mockReturnValue(undefined);
82
+ await activate(context);
83
+ expect(showInformationMessageMock).toHaveBeenCalledWith(
84
+ 'Blackbox Code Companion extension successfully installed.',
85
+ );
86
+ });
87
+
88
+ it('should not show the info message on subsequent activations', async () => {
89
+ vi.mocked(context.globalState.get).mockReturnValue(true);
90
+ await activate(context);
91
+ expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('should launch Blackbox Code when the user clicks the button', async () => {
95
+ const showInformationMessageMock = vi
96
+ .mocked(vscode.window.showInformationMessage)
97
+ .mockResolvedValue('Run Blackbox Code' as never);
98
+ vi.mocked(context.globalState.get).mockReturnValue(undefined);
99
+ await activate(context);
100
+ expect(showInformationMessageMock).toHaveBeenCalled();
101
+ await new Promise(process.nextTick); // Wait for the promise to resolve
102
+ const commandCallback = vi
103
+ .mocked(vscode.commands.registerCommand)
104
+ .mock.calls.find((call) => call[0] === 'blackbox-cli.runBlackboxCode')?.[1];
105
+
106
+ expect(commandCallback).toBeDefined();
107
+ });
108
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as vscode from 'vscode';
8
+ import { IDEServer } from './ide-server.js';
9
+ import { DiffContentProvider, DiffManager } from './diff-manager.js';
10
+ import { createLogger } from './utils/logger.js';
11
+
12
+ const INFO_MESSAGE_SHOWN_KEY = 'blackboxCodeInfoMessageShown';
13
+ export const DIFF_SCHEME = 'blackbox-diff';
14
+
15
+ let ideServer: IDEServer;
16
+ let logger: vscode.OutputChannel;
17
+
18
+ let log: (message: string) => void = () => {};
19
+
20
+ export async function activate(context: vscode.ExtensionContext) {
21
+ logger = vscode.window.createOutputChannel('Blackbox Code Companion');
22
+ log = createLogger(context, logger);
23
+ log('Extension activated');
24
+
25
+ const diffContentProvider = new DiffContentProvider();
26
+ const diffManager = new DiffManager(log, diffContentProvider);
27
+
28
+ context.subscriptions.push(
29
+ vscode.workspace.onDidCloseTextDocument((doc) => {
30
+ if (doc.uri.scheme === DIFF_SCHEME) {
31
+ diffManager.cancelDiff(doc.uri);
32
+ }
33
+ }),
34
+ vscode.workspace.registerTextDocumentContentProvider(
35
+ DIFF_SCHEME,
36
+ diffContentProvider,
37
+ ),
38
+ vscode.commands.registerCommand('blackbox.diff.accept', (uri?: vscode.Uri) => {
39
+ const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
40
+ if (docUri && docUri.scheme === DIFF_SCHEME) {
41
+ diffManager.acceptDiff(docUri);
42
+ }
43
+ }),
44
+ vscode.commands.registerCommand('blackbox.diff.cancel', (uri?: vscode.Uri) => {
45
+ const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
46
+ if (docUri && docUri.scheme === DIFF_SCHEME) {
47
+ diffManager.cancelDiff(docUri);
48
+ }
49
+ }),
50
+ );
51
+
52
+ ideServer = new IDEServer(log, diffManager);
53
+ try {
54
+ await ideServer.start(context);
55
+ } catch (err) {
56
+ const message = err instanceof Error ? err.message : String(err);
57
+ log(`Failed to start IDE server: ${message}`);
58
+ }
59
+
60
+ if (!context.globalState.get(INFO_MESSAGE_SHOWN_KEY)) {
61
+ void vscode.window.showInformationMessage(
62
+ 'Blackbox Code Companion extension successfully installed.',
63
+ );
64
+ context.globalState.update(INFO_MESSAGE_SHOWN_KEY, true);
65
+ }
66
+
67
+ context.subscriptions.push(
68
+ vscode.workspace.onDidChangeWorkspaceFolders(() => {
69
+ ideServer.updateWorkspacePath();
70
+ }),
71
+ vscode.commands.registerCommand('blackbox-cli.runBlackboxCode', async () => {
72
+ const workspaceFolders = vscode.workspace.workspaceFolders;
73
+ if (!workspaceFolders || workspaceFolders.length === 0) {
74
+ vscode.window.showInformationMessage(
75
+ 'No folder open. Please open a folder to run Blackbox Code.',
76
+ );
77
+ return;
78
+ }
79
+
80
+ let selectedFolder: vscode.WorkspaceFolder | undefined;
81
+ if (workspaceFolders.length === 1) {
82
+ selectedFolder = workspaceFolders[0];
83
+ } else {
84
+ selectedFolder = await vscode.window.showWorkspaceFolderPick({
85
+ placeHolder: 'Select a folder to run Blackbox Code in',
86
+ });
87
+ }
88
+
89
+ if (selectedFolder) {
90
+ const blackboxCmd = 'blackbox';
91
+ const terminal = vscode.window.createTerminal({
92
+ name: `Blackbox Code (${selectedFolder.name})`,
93
+ cwd: selectedFolder.uri.fsPath,
94
+ });
95
+ terminal.show();
96
+ terminal.sendText(blackboxCmd);
97
+ }
98
+ }),
99
+ vscode.commands.registerCommand('blackbox-cli.showNotices', async () => {
100
+ const noticePath = vscode.Uri.joinPath(
101
+ context.extensionUri,
102
+ 'NOTICES.txt',
103
+ );
104
+ await vscode.window.showTextDocument(noticePath);
105
+ }),
106
+ );
107
+ }
108
+
109
+ export async function deactivate(): Promise<void> {
110
+ log('Extension deactivated');
111
+ try {
112
+ if (ideServer) {
113
+ await ideServer.stop();
114
+ }
115
+ } catch (err) {
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ log(`Failed to stop IDE server during deactivation: ${message}`);
118
+ } finally {
119
+ if (logger) {
120
+ logger.dispose();
121
+ }
122
+ }
123
+ }