diff --git a/fonts/mongodb-icons.woff b/fonts/mongodb-icons.woff index c12f6799b..45d6e1254 100644 Binary files a/fonts/mongodb-icons.woff and b/fonts/mongodb-icons.woff differ diff --git a/images/icons/playground.svg b/images/icons/playground.svg new file mode 100644 index 000000000..f38f28a70 --- /dev/null +++ b/images/icons/playground.svg @@ -0,0 +1 @@ + diff --git a/images/dark/plus-circle.svg b/images/icons/plus-circle.svg similarity index 100% rename from images/dark/plus-circle.svg rename to images/icons/plus-circle.svg diff --git a/package.json b/package.json index f147a1d2a..f4676cdbe 100644 --- a/package.json +++ b/package.json @@ -255,6 +255,11 @@ "dark": "images/dark/add.svg" } }, + { + "command": "mdb.createNewPlaygroundFromTreeItem", + "title": "Create MongoDB Playground", + "icon": "$(mdb-playground)" + }, { "command": "mdb.changeActiveConnection", "title": "MongoDB: Change Active Connection" @@ -330,10 +335,7 @@ { "command": "mdb.addDatabase", "title": "Add Database...", - "icon": { - "light": "images/light/plus-circle.svg", - "dark": "images/dark/plus-circle.svg" - } + "icon": "$(mdb-plus-circle)" }, { "command": "mdb.searchForDocuments", @@ -371,13 +373,15 @@ "command": "mdb.refreshDatabase", "title": "Refresh" }, + { + "command": "mdb.askCopilotFromTreeItem", + "title": "Ask MongoDB Copilot", + "icon": "$(copilot)" + }, { "command": "mdb.addCollection", "title": "Add Collection...", - "icon": { - "light": "images/light/plus-circle.svg", - "dark": "images/dark/plus-circle.svg" - } + "icon": "$(mdb-plus-circle)" }, { "command": "mdb.viewCollectionDocuments", @@ -422,10 +426,7 @@ { "command": "mdb.createIndexFromTreeView", "title": "Create New Index...", - "icon": { - "light": "images/light/plus-circle.svg", - "dark": "images/dark/plus-circle.svg" - } + "icon": "$(mdb-plus-circle)" }, { "command": "mdb.insertObjectIdToEditor", @@ -454,10 +455,7 @@ { "command": "mdb.addStreamProcessor", "title": "Add StreamProcessor...", - "icon": { - "light": "images/light/plus-circle.svg", - "dark": "images/dark/plus-circle.svg" - } + "icon": "$(mdb-plus-circle)" }, { "command": "mdb.startStreamProcessor", @@ -587,7 +585,7 @@ { "command": "mdb.addCollection", "when": "view == mongoDBConnectionExplorer && viewItem == databaseTreeItem", - "group": "inline" + "group": "inline@3" }, { "command": "mdb.addCollection", @@ -604,10 +602,30 @@ "when": "view == mongoDBConnectionExplorer && viewItem == databaseTreeItem", "group": "2@1" }, + { + "command": "mdb.askCopilotFromTreeItem", + "when": "mdb.isCopilotActive == true && view == mongoDBConnectionExplorer && (viewItem == databaseTreeItem || viewItem == collectionTreeItem)", + "group": "inline@1" + }, + { + "command": "mdb.askCopilotFromTreeItem", + "when": "mdb.isCopilotActive == true && view == mongoDBConnectionExplorer && (viewItem == databaseTreeItem || viewItem == collectionTreeItem)", + "group": "3@1" + }, + { + "command": "mdb.createNewPlaygroundFromTreeItem", + "when": "view == mongoDBConnectionExplorer && (viewItem == databaseTreeItem || viewItem == collectionTreeItem)", + "group": "inline@2" + }, + { + "command": "mdb.createNewPlaygroundFromTreeItem", + "when": "view == mongoDBConnectionExplorer && (viewItem == databaseTreeItem || viewItem == collectionTreeItem)", + "group": "3@2" + }, { "command": "mdb.dropDatabase", "when": "view == mongoDBConnectionExplorer && viewItem == databaseTreeItem", - "group": "3@1" + "group": "4@1" }, { "command": "mdb.viewCollectionDocuments", @@ -1157,19 +1175,33 @@ } }, "icons": { - "mdb-connection-active": { + "mdb-playground": { "description": "MongoDB Icon", "default": { "fontPath": "./fonts/mongodb-icons.woff", "fontCharacter": "\\ea01" } }, - "mdb-connection-inactive": { + "mdb-plus-circle": { "description": "MongoDB Icon", "default": { "fontPath": "./fonts/mongodb-icons.woff", "fontCharacter": "\\ea02" } + }, + "mdb-connection-active": { + "description": "MongoDB Icon", + "default": { + "fontPath": "./fonts/mongodb-icons.woff", + "fontCharacter": "\\ea03" + } + }, + "mdb-connection-inactive": { + "description": "MongoDB Icon", + "default": { + "fontPath": "./fonts/mongodb-icons.woff", + "fontCharacter": "\\ea04" + } } } }, diff --git a/scripts/generate-icon-font.ts b/scripts/generate-icon-font.ts index 089dbf8d1..79ad286c9 100644 --- a/scripts/generate-icon-font.ts +++ b/scripts/generate-icon-font.ts @@ -4,7 +4,12 @@ import { GlyphData } from 'webfont/dist/src/types'; import prettier from 'prettier'; /** Icons to include in the generated icon font */ -const INCLUDED_ICONS = ['connection-active', 'connection-inactive']; +const INCLUDED_ICONS = [ + 'light/connection-active', + 'light/connection-inactive', + 'playground', + 'plus-circle', +]; /** * Generates an icon font from the included icons and outputs package.json @@ -13,7 +18,13 @@ const INCLUDED_ICONS = ['connection-active', 'connection-inactive']; */ async function main(): Promise { const font = await webfont({ - files: INCLUDED_ICONS.map((icon) => `./images/light/${icon}.svg`), + files: INCLUDED_ICONS.map((icon) => { + // Legacy support for icons inside light and dark folders. + if (icon.startsWith('light/')) { + return `./images/${icon}.svg`; + } + return `./images/icons/${icon}.svg`; + }), fontName: 'MongoDB Icons', formats: ['woff'], normalize: true, diff --git a/src/commands/index.ts b/src/commands/index.ts index a5fe02aa5..deca325c6 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -39,6 +39,7 @@ enum EXTENSION_COMMANDS { MDB_OPEN_PLAYGROUND_FROM_TREE_VIEW = 'mdb.openPlaygroundFromTreeView', MDB_CONNECT_TO_CONNECTION_TREE_VIEW = 'mdb.connectToConnectionTreeItem', MDB_CREATE_PLAYGROUND_FROM_TREE_VIEW = 'mdb.createNewPlaygroundFromTreeView', + MDB_CREATE_PLAYGROUND_FROM_TREE_ITEM = 'mdb.createNewPlaygroundFromTreeItem', MDB_DISCONNECT_FROM_CONNECTION_TREE_VIEW = 'mdb.disconnectFromConnectionTreeItem', MDB_EDIT_CONNECTION = 'mdb.editConnection', MDB_REFRESH_CONNECTION = 'mdb.refreshConnection', @@ -75,6 +76,7 @@ enum EXTENSION_COMMANDS { OPEN_PARTICIPANT_CODE_IN_PLAYGROUND = 'mdb.openParticipantCodeInPlayground', SEND_MESSAGE_TO_PARTICIPANT = 'mdb.sendMessageToParticipant', SEND_MESSAGE_TO_PARTICIPANT_FROM_INPUT = 'mdb.sendMessageToParticipantFromInput', + ASK_COPILOT_FROM_TREE_ITEM = 'mdb.askCopilotFromTreeItem', RUN_PARTICIPANT_CODE = 'mdb.runParticipantCode', CONNECT_WITH_PARTICIPANT = 'mdb.connectWithParticipant', SELECT_DATABASE_WITH_PARTICIPANT = 'mdb.selectDatabaseWithParticipant', diff --git a/src/editors/playgroundController.ts b/src/editors/playgroundController.ts index da4632294..7f2c2969d 100644 --- a/src/editors/playgroundController.ts +++ b/src/editors/playgroundController.ts @@ -8,6 +8,7 @@ import type ConnectionController from '../connectionController'; import { DataServiceEventTypes } from '../connectionController'; import { createLogger } from '../logging'; import type { ConnectionTreeItem } from '../explorer'; +import { CollectionTreeItem } from '../explorer'; import { DatabaseTreeItem } from '../explorer'; import formatError from '../utils/formatError'; import type { LanguageServerController } from '../language'; @@ -41,6 +42,8 @@ import { getPlaygroundExtensionForTelemetry, } from '../utils/playground'; import type ExportToLanguageCodeLensProvider from './exportToLanguageCodeLensProvider'; +import { playgroundFromDatabaseTreeItemTemplate } from '../templates/playgroundFromDatabaseTreeItemTemplate'; +import { playgroundFromCollectionTreeItemTemplate } from '../templates/playgroundFromCollectionTreeItemTemplate'; const log = createLogger('playground controller'); @@ -316,13 +319,36 @@ export default class PlaygroundController { return this._createPlaygroundFileWithContent(content); } + async createPlaygroundFromTreeItem( + treeItem: DatabaseTreeItem | CollectionTreeItem + ): Promise { + let content = ''; + if (treeItem instanceof DatabaseTreeItem) { + content = playgroundFromDatabaseTreeItemTemplate(treeItem.databaseName); + this._telemetryService.trackPlaygroundCreated('fromDatabaseTreeItem'); + } else if (treeItem instanceof CollectionTreeItem) { + content = playgroundFromCollectionTreeItemTemplate( + treeItem.databaseName, + treeItem.collectionName + ); + this._telemetryService.trackPlaygroundCreated('fromCollectionTreeItem'); + } + + return this._createPlaygroundFileWithContent(content); + } + async createPlayground(): Promise { const useDefaultTemplate = !!vscode.workspace .getConfiguration('mdb') .get('useDefaultTemplateForPlayground'); - const isStreams = this._connectionController.isConnectedToAtlasStreams(); - const template = isStreams ? playgroundStreamsTemplate : playgroundTemplate; - const content = useDefaultTemplate ? template : ''; + let content = ''; + if (useDefaultTemplate) { + const isStreams = this._connectionController.isConnectedToAtlasStreams(); + const template = isStreams + ? playgroundStreamsTemplate + : playgroundTemplate; + content = template; + } this._telemetryService.trackPlaygroundCreated('crud'); return this._createPlaygroundFileWithContent(content); diff --git a/src/editors/playgroundSelectionCodeActionProvider.ts b/src/editors/playgroundSelectionCodeActionProvider.ts index 578e68ce4..d943ddaba 100644 --- a/src/editors/playgroundSelectionCodeActionProvider.ts +++ b/src/editors/playgroundSelectionCodeActionProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import EXTENSION_COMMANDS from '../commands'; import { isPlayground, getSelectedText } from '../utils/playground'; +import { COPILOT_CHAT_EXTENSION_ID } from '../participant/constants'; export const EXPORT_TO_LANGUAGE_ALIASES = [ { id: 'csharp', alias: 'C#' }, @@ -42,7 +43,7 @@ export default class PlaygroundSelectionCodeActionProvider provideCodeActions(): vscode.CodeAction[] | undefined { const editor = vscode.window.activeTextEditor; - const copilot = vscode.extensions.getExtension('github.copilot-chat'); + const copilot = vscode.extensions.getExtension(COPILOT_CHAT_EXTENSION_ID); let codeActions: vscode.CodeAction[] = [ this.createCodeAction({ title: 'Run selected playground blocks', diff --git a/src/mdbExtensionController.ts b/src/mdbExtensionController.ts index 43213a662..c7c5f139e 100644 --- a/src/mdbExtensionController.ts +++ b/src/mdbExtensionController.ts @@ -51,6 +51,10 @@ import type { SendMessageToParticipantOptions, SendMessageToParticipantFromInputOptions, } from './participant/participantTypes'; +import { + COPILOT_CHAT_EXTENSION_ID, + COPILOT_EXTENSION_ID, +} from './participant/constants'; // This class is the top-level controller for our extension. // Commands which the extensions handles are defined in the function `activate`. @@ -177,12 +181,26 @@ export default class MDBExtensionController implements vscode.Disposable { // ------ In-app notifications ------ // void this.showCopilotIntroductionForEstablishedUsers(); - const copilot = vscode.extensions.getExtension('GitHub.copilot'); + const copilot = vscode.extensions.getExtension(COPILOT_EXTENSION_ID); void vscode.commands.executeCommand( 'setContext', 'mdb.isCopilotActive', copilot?.isActive ); + + // TODO: This is a workaround related to https://github.com/microsoft/vscode/issues/234426 + // If the extension was found but is not activated, there is a chance that the MongoDB extension + // was activated before the Copilot one, so we check again after a delay. + if (copilot && !copilot?.isActive) { + setTimeout(() => { + const copilot = vscode.extensions.getExtension(COPILOT_EXTENSION_ID); + void vscode.commands.executeCommand( + 'setContext', + 'mdb.isCopilotActive', + copilot?.isActive === true + ); + }, 3000); + } } registerCommands = (): void => { @@ -330,6 +348,13 @@ export default class MDBExtensionController implements vscode.Disposable { return true; } ); + this.registerParticipantCommand( + EXTENSION_COMMANDS.ASK_COPILOT_FROM_TREE_ITEM, + async (treeItem: DatabaseTreeItem | CollectionTreeItem) => { + await this._participantController.askCopilotFromTreeItem(treeItem); + return true; + } + ); this.registerParticipantCommand( EXTENSION_COMMANDS.RUN_PARTICIPANT_CODE, ({ runnableContent }: RunParticipantCodeCommandArgs) => { @@ -742,6 +767,11 @@ export default class MDBExtensionController implements vscode.Disposable { EXTENSION_COMMANDS.MDB_CREATE_PLAYGROUND_FROM_TREE_VIEW, () => this._playgroundController.createPlayground() ); + this.registerCommand( + EXTENSION_COMMANDS.MDB_CREATE_PLAYGROUND_FROM_TREE_ITEM, + (treeItem: DatabaseTreeItem | CollectionTreeItem) => + this._playgroundController.createPlaygroundFromTreeItem(treeItem) + ); this.registerCommand( EXTENSION_COMMANDS.MDB_REFRESH_PLAYGROUNDS_FROM_TREE_VIEW, () => this._playgroundsExplorer.refresh() @@ -972,7 +1002,7 @@ export default class MDBExtensionController implements vscode.Disposable { } ); - const copilot = vscode.extensions.getExtension('github.copilot-chat'); + const copilot = vscode.extensions.getExtension(COPILOT_CHAT_EXTENSION_ID); if (result?.title === action) { await this._participantController.sendMessageToParticipant({ message: '', diff --git a/src/participant/constants.ts b/src/participant/constants.ts index 1aeec079e..86511f823 100644 --- a/src/participant/constants.ts +++ b/src/participant/constants.ts @@ -4,6 +4,7 @@ import { ChatMetadataStore } from './chatMetadata'; export const CHAT_PARTICIPANT_ID = 'mongodb.participant'; export const CHAT_PARTICIPANT_MODEL = 'gpt-4o'; export const COPILOT_EXTENSION_ID = 'GitHub.copilot'; +export const COPILOT_CHAT_EXTENSION_ID = 'GitHub.copilot-chat'; export type ParticipantResponseType = | 'query' diff --git a/src/participant/participant.ts b/src/participant/participant.ts index f39cec2bc..5d98dd543 100644 --- a/src/participant/participant.ts +++ b/src/participant/participant.ts @@ -55,6 +55,7 @@ import type { } from './participantTypes'; import { DEFAULT_EXPORT_TO_LANGUAGE_DRIVER_SYNTAX } from '../editors/exportToLanguageCodeLensProvider'; import { EXPORT_TO_LANGUAGE_ALIASES } from '../editors/playgroundSelectionCodeActionProvider'; +import { CollectionTreeItem, DatabaseTreeItem } from '../explorer'; const log = createLogger('participant'); @@ -137,7 +138,12 @@ export default class ParticipantController { async sendMessageToParticipant( options: SendMessageToParticipantOptions ): Promise { - const { message, isNewChat = false, isPartialQuery = false } = options; + const { + message, + isNewChat = false, + isPartialQuery = false, + ...otherOptions + } = options; if (isNewChat) { await vscode.commands.executeCommand('workbench.action.chat.newChat'); @@ -146,7 +152,8 @@ export default class ParticipantController { ); } - return vscode.commands.executeCommand('workbench.action.chat.open', { + return await vscode.commands.executeCommand('workbench.action.chat.open', { + ...otherOptions, query: `@MongoDB ${message}`, isPartialQuery, }); @@ -182,6 +189,28 @@ export default class ParticipantController { }); } + async askCopilotFromTreeItem( + treeItem: DatabaseTreeItem | CollectionTreeItem + ): Promise { + if (treeItem instanceof DatabaseTreeItem) { + const { databaseName } = treeItem; + + await this.sendMessageToParticipant({ + message: `I want to ask questions about the \`${databaseName}\` database.`, + isNewChat: true, + }); + } else if (treeItem instanceof CollectionTreeItem) { + const { databaseName, collectionName } = treeItem; + + await this.sendMessageToParticipant({ + message: `I want to ask questions about the \`${databaseName}\` database's \`${collectionName}\` collection.`, + isNewChat: true, + }); + } else { + throw new Error('Unsupported tree item type'); + } + } + async _getChatResponse({ modelInput, token, diff --git a/src/templates/playgroundFromCollectionTreeItemTemplate.ts b/src/templates/playgroundFromCollectionTreeItemTemplate.ts new file mode 100644 index 000000000..35c335887 --- /dev/null +++ b/src/templates/playgroundFromCollectionTreeItemTemplate.ts @@ -0,0 +1,15 @@ +import { createTemplate } from './templateHelpers'; + +export const playgroundFromCollectionTreeItemTemplate = createTemplate( + (databaseName, collectionName) => `// MongoDB Playground +// Use Ctrl+Space inside a snippet or a string literal to trigger completions. + +// The current database to use. +use(${databaseName}); + +// Find a document in a collection. +db.getCollection(${collectionName}).findOne({ + +}); +` +); diff --git a/src/templates/playgroundFromDatabaseTreeItemTemplate.ts b/src/templates/playgroundFromDatabaseTreeItemTemplate.ts new file mode 100644 index 000000000..0a5c57941 --- /dev/null +++ b/src/templates/playgroundFromDatabaseTreeItemTemplate.ts @@ -0,0 +1,11 @@ +import { createTemplate } from './templateHelpers'; + +export const playgroundFromDatabaseTreeItemTemplate = createTemplate( + (currentDatabase) => `// MongoDB Playground +// Use Ctrl+Space inside a snippet or a string literal to trigger completions. + +// The current database to use. +use(${currentDatabase}); + +` +); diff --git a/src/templates/templateHelpers.ts b/src/templates/templateHelpers.ts new file mode 100644 index 000000000..185464a32 --- /dev/null +++ b/src/templates/templateHelpers.ts @@ -0,0 +1,10 @@ +/** Wraps a template function and escapes given string arguments. */ +export function createTemplate string>( + templateBuilder: T +): (...args: Parameters) => string { + return (...args: Parameters) => { + const escapedArgs = args.map((arg) => JSON.stringify(arg)); + + return templateBuilder(...escapedArgs); + }; +} diff --git a/src/test/suite/participant/participant.test.ts b/src/test/suite/participant/participant.test.ts index ce89bf105..1263f0e6e 100644 --- a/src/test/suite/participant/participant.test.ts +++ b/src/test/suite/participant/participant.test.ts @@ -42,6 +42,8 @@ import { } from './participantHelpers'; import EditDocumentCodeLensProvider from '../../../editors/editDocumentCodeLensProvider'; import PlaygroundResultProvider from '../../../editors/playgroundResultProvider'; +import { CollectionTreeItem, DatabaseTreeItem } from '../../../explorer'; +import type { SendMessageToParticipantOptions } from '../../../participant/participantTypes'; // The Copilot's model in not available in tests, // therefore we need to mock its methods and returning values. @@ -1802,6 +1804,73 @@ Schema: }); }); + suite('opened from tree view', function () { + let sendMessageToParticipantStub: SinonStub< + [options: SendMessageToParticipantOptions], + Promise + >; + + beforeEach(function () { + sendMessageToParticipantStub = sinon.stub( + testParticipantController, + 'sendMessageToParticipant' + ); + }); + + suite('with a database item', function () { + const mockDatabaseItem = Object.assign( + Object.create(DatabaseTreeItem.prototype), + { + databaseName: 'testDb', + } as DatabaseTreeItem + ); + + test('opens the chat and sends a message to set database context', async function () { + expect(sendMessageToParticipantStub).not.called; + + await testParticipantController.askCopilotFromTreeItem( + mockDatabaseItem + ); + + expect(sendMessageToParticipantStub).has.callCount(1); + + expect(sendMessageToParticipantStub.getCall(0).args).deep.equals([ + { + message: `I want to ask questions about the \`${mockDatabaseItem.databaseName}\` database.`, + isNewChat: true, + }, + ]); + }); + }); + + suite('with a collection item', function () { + const mockCollectionItem = Object.assign( + Object.create(CollectionTreeItem.prototype), + { + databaseName: 'testDb', + collectionName: 'testColl', + } as CollectionTreeItem + ); + + test('opens the chat and sends a message to set database and collection context', async function () { + expect(sendMessageToParticipantStub).not.called; + + await testParticipantController.askCopilotFromTreeItem( + mockCollectionItem + ); + + expect(sendMessageToParticipantStub).has.callCount(1); + + expect(sendMessageToParticipantStub.getCall(0).args).deep.equals([ + { + message: `I want to ask questions about the \`${mockCollectionItem.databaseName}\` database's \`${mockCollectionItem.collectionName}\` collection.`, + isNewChat: true, + }, + ]); + }); + }); + }); + suite('determining the namespace', function () { ['query', 'schema'].forEach(function (command) { suite(`${command} command`, function () {