diff --git a/src/connectionController.ts b/src/connectionController.ts index b25da9201..9f62e0f20 100644 --- a/src/connectionController.ts +++ b/src/connectionController.ts @@ -435,7 +435,12 @@ export default class ConnectionController { this._currentConnectionId = connectionId; this._connectionAttempt = null; this._connectingConnectionId = null; + + this._connections[connectionId].lastUsed = new Date(); this.eventEmitter.emit(DataServiceEventTypes.ACTIVE_CONNECTION_CHANGED); + await this._connectionStorage.saveConnection( + this._connections[connectionId] + ); // Send metrics to Segment this.sendTelemetry(dataService, connectionType); diff --git a/src/explorer/connectionTreeItem.ts b/src/explorer/connectionTreeItem.ts index 7eecddc72..680d89c2f 100644 --- a/src/explorer/connectionTreeItem.ts +++ b/src/explorer/connectionTreeItem.ts @@ -115,10 +115,7 @@ export default class ConnectionTreeItem } try { - const dbs = await dataService.listDatabases({ - nameOnly: true, - }); - + const dbs = await dataService.listDatabases(); return dbs.map((dbItem) => dbItem.name); } catch (error) { throw new Error( diff --git a/src/participant/participant.ts b/src/participant/participant.ts index 5cd362a0e..7d4991d52 100644 --- a/src/participant/participant.ts +++ b/src/participant/participant.ts @@ -27,12 +27,19 @@ interface ChatResult extends vscode.ChatResult { }; } +interface NamespaceQuickPicks { + label: string; + data: string; +} + const DB_NAME_ID = 'DATABASE_NAME'; const DB_NAME_REGEX = `${DB_NAME_ID}: (.*)\n`; const COL_NAME_ID = 'COLLECTION_NAME'; const COL_NAME_REGEX = `${COL_NAME_ID}: (.*)`; +const MAX_MARKDOWN_LIST_LENGTH = 10; + export function parseForDatabaseAndCollectionName(text: string): { databaseName?: string; collectionName?: string; @@ -226,7 +233,7 @@ export default class ParticipantController { async connectWithParticipant(id?: string): Promise { if (!id) { - await this._connectionController.connectWithURI(); + await this._connectionController.changeActiveConnection(); } else { await this._connectionController.connectWithConnectionId(id); } @@ -259,22 +266,16 @@ export default class ParticipantController { return connName; } - // TODO (VSCODE-589): Evaluate the usability of displaying all existing connections in the list. - // Consider introducing a "recent connections" feature to display only a limited number of recent connections, - // with a "Show more" link that opens the Command Palette for access to the full list. - // If we implement this, the "Add new connection" link may become redundant, - // as this option is already available in the Command Palette dropdown. getConnectionsTree(): vscode.MarkdownString[] { return [ - this._createMarkdownLink({ - commandId: 'mdb.connectWithParticipant', - name: 'Add new connection', - }), ...this._connectionController .getSavedConnections() - .sort((connectionA: LoadedConnection, connectionB: LoadedConnection) => - (connectionA.name || '').localeCompare(connectionB.name || '') - ) + .sort((a, b) => { + const aTime = a.lastUsed ? new Date(a.lastUsed).getTime() : 0; + const bTime = b.lastUsed ? new Date(b.lastUsed).getTime() : 0; + return bTime - aTime; + }) + .slice(0, MAX_MARKDOWN_LIST_LENGTH) .map((conn: LoadedConnection) => this._createMarkdownLink({ commandId: 'mdb.connectWithParticipant', @@ -282,24 +283,96 @@ export default class ParticipantController { name: conn.name, }) ), + this._createMarkdownLink({ + commandId: 'mdb.connectWithParticipant', + name: 'Show more', + }), ]; } + async getDatabaseQuickPicks(): Promise { + const dataService = this._connectionController.getActiveDataService(); + if (!dataService) { + this._queryGenerationState = QUERY_GENERATION_STATE.ASK_TO_CONNECT; + return []; + } + + try { + const databases = await dataService.listDatabases(); + return databases.map((db) => ({ + label: db.name, + data: db.name, + })); + } catch (error) { + return []; + } + } + + async _selectDatabaseWithCommandPalette(): Promise { + const databases = await this.getDatabaseQuickPicks(); + const selectedQuickPickItem = await vscode.window.showQuickPick(databases, { + placeHolder: 'Select a database...', + }); + return selectedQuickPickItem?.data; + } + async selectDatabaseWithParticipant(name: string): Promise { - this._databaseName = name; + if (!name) { + this._databaseName = await this._selectDatabaseWithCommandPalette(); + } else { + this._databaseName = name; + } + return vscode.commands.executeCommand('workbench.action.chat.open', { - query: `@MongoDB /query ${name}`, + query: `@MongoDB /query ${this._databaseName || ''}`, }); } + async getCollectionQuickPicks(): Promise { + if (!this._databaseName) { + return []; + } + + const dataService = this._connectionController.getActiveDataService(); + if (!dataService) { + this._queryGenerationState = QUERY_GENERATION_STATE.ASK_TO_CONNECT; + return []; + } + + try { + const collections = await dataService.listCollections(this._databaseName); + return collections.map((db) => ({ + label: db.name, + data: db.name, + })); + } catch (error) { + return []; + } + } + + async _selectCollectionWithCommandPalette(): Promise { + const collections = await this.getCollectionQuickPicks(); + const selectedQuickPickItem = await vscode.window.showQuickPick( + collections, + { + placeHolder: 'Select a collection...', + } + ); + return selectedQuickPickItem?.data; + } + async selectCollectionWithParticipant(name: string): Promise { - this._collectionName = name; + if (!name) { + this._collectionName = await this._selectCollectionWithCommandPalette(); + } else { + this._collectionName = name; + } + return vscode.commands.executeCommand('workbench.action.chat.open', { - query: `@MongoDB /query ${name}`, + query: `@MongoDB /query ${this._collectionName || ''}`, }); } - // TODO (VSCODE-589): Display only 10 items in clickable lists with the show more option. async getDatabasesTree(): Promise { const dataService = this._connectionController.getActiveDataService(); if (!dataService) { @@ -308,23 +381,30 @@ export default class ParticipantController { } try { - const databases = await dataService.listDatabases({ - nameOnly: true, - }); - return databases.map((db) => - this._createMarkdownLink({ - commandId: 'mdb.selectDatabaseWithParticipant', - query: db.name, - name: db.name, - }) - ); + const databases = await dataService.listDatabases(); + return [ + ...databases.slice(0, MAX_MARKDOWN_LIST_LENGTH).map((db) => + this._createMarkdownLink({ + commandId: 'mdb.selectDatabaseWithParticipant', + query: db.name, + name: db.name, + }) + ), + ...(databases.length > MAX_MARKDOWN_LIST_LENGTH + ? [ + this._createMarkdownLink({ + commandId: 'mdb.selectDatabaseWithParticipant', + name: 'Show more', + }), + ] + : []), + ]; } catch (error) { // Users can always do this manually when asked to provide a database name. return []; } } - // TODO (VSCODE-589): Display only 10 items in clickable lists with the show more option. async getCollectionTree(): Promise { if (!this._databaseName) { return []; @@ -338,13 +418,23 @@ export default class ParticipantController { try { const collections = await dataService.listCollections(this._databaseName); - return collections.map((coll) => - this._createMarkdownLink({ - commandId: 'mdb.selectCollectionWithParticipant', - query: coll.name, - name: coll.name, - }) - ); + return [ + ...collections.slice(0, MAX_MARKDOWN_LIST_LENGTH).map((coll) => + this._createMarkdownLink({ + commandId: 'mdb.selectCollectionWithParticipant', + query: coll.name, + name: coll.name, + }) + ), + ...(collections.length > MAX_MARKDOWN_LIST_LENGTH + ? [ + this._createMarkdownLink({ + commandId: 'mdb.selectCollectionWithParticipant', + name: 'Show more', + }), + ] + : []), + ]; } catch (error) { // Users can always do this manually when asked to provide a collection name. return []; diff --git a/src/storage/connectionStorage.ts b/src/storage/connectionStorage.ts index 56bb2f166..e233ad4d5 100644 --- a/src/storage/connectionStorage.ts +++ b/src/storage/connectionStorage.ts @@ -23,6 +23,7 @@ export interface StoreConnectionInfo { storageLocation: StorageLocation; secretStorageLocation?: SecretStorageLocationType; connectionOptions?: ConnectionOptions; + lastUsed?: Date; // Date and time when the connection was last used, i.e. connected with. } type StoreConnectionInfoWithConnectionOptions = StoreConnectionInfo & diff --git a/src/test/suite/participant/participant.test.ts b/src/test/suite/participant/participant.test.ts index cad2a596e..47cddbc6c 100644 --- a/src/test/suite/participant/participant.test.ts +++ b/src/test/suite/participant/participant.test.ts @@ -20,6 +20,15 @@ import { SecretStorageLocation, StorageLocation, } from '../../../storage/storageController'; +import type { LoadedConnection } from '../../../storage/connectionStorage'; + +const loadedConnection = { + id: 'id', + name: 'localhost', + storageLocation: StorageLocation.NONE, + secretStorageLocation: SecretStorageLocation.SecretStorage, + connectionOptions: { connectionString: 'mongodb://localhost' }, +}; suite('Participant Controller Test Suite', function () { const extensionContextStub = new ExtensionContextStub(); @@ -121,16 +130,17 @@ suite('Participant Controller Test Suite', function () { suite('when not connected', function () { let connectWithConnectionIdStub; - let connectWithURIStub; + let changeActiveConnectionStub; + let getSavedConnectionsStub; beforeEach(function () { connectWithConnectionIdStub = sinon.stub( testParticipantController._connectionController, 'connectWithConnectionId' ); - connectWithURIStub = sinon.stub( + changeActiveConnectionStub = sinon.stub( testParticipantController._connectionController, - 'connectWithURI' + 'changeActiveConnection' ); sinon.replace( testParticipantController._connectionController, @@ -142,22 +152,16 @@ suite('Participant Controller Test Suite', function () { 'get', sinon.fake.returns(true) ); + getSavedConnectionsStub = sinon.stub(); sinon.replace( testParticipantController._connectionController, 'getSavedConnections', - () => [ - { - id: '123', - name: 'localhost', - storageLocation: StorageLocation.NONE, - secretStorageLocation: SecretStorageLocation.SecretStorage, - connectionOptions: { connectionString: 'mongodb://localhost' }, - }, - ] + getSavedConnectionsStub ); }); test('asks to connect', async function () { + getSavedConnectionsStub.returns([loadedConnection]); const chatRequestMock = { prompt: 'find all docs by a name example', command: 'query', @@ -173,15 +177,56 @@ suite('Participant Controller Test Suite', function () { expect(connectMessage).to.include( "Looks like you aren't currently connected, first let's get you connected to the cluster we'd like to create this query to run against." ); - const addNewConnectionMessage = - chatStreamStub.markdown.getCall(1).args[0]; - expect(addNewConnectionMessage.value).to.include( - '- Add new connection' + const listConnectionsMessage = chatStreamStub.markdown.getCall(1).args[0]; + expect(listConnectionsMessage.value).to.include( + '- localhost' + ); + const showMoreMessage = chatStreamStub.markdown.getCall(2).args[0]; + expect(showMoreMessage.value).to.include( + '- Show more' + ); + expect( + testParticipantController._chatResult?.metadata.responseContent + ).to.be.eql(undefined); + expect(testParticipantController._queryGenerationState).to.be.eql( + QUERY_GENERATION_STATE.ASK_TO_CONNECT + ); + }); + + test('shows only 10 connections with the show more option', async function () { + const connections: LoadedConnection[] = []; + for (let i = 0; i < 11; i++) { + connections.push({ + ...loadedConnection, + id: `${loadedConnection.id}${i}`, + name: `${loadedConnection.name}${i}`, + }); + } + getSavedConnectionsStub.returns(connections); + const chatRequestMock = { + prompt: 'find all docs by a name example', + command: 'query', + references: [], + }; + await testParticipantController.chatHandler( + chatRequestMock, + chatContextStub, + chatStreamStub, + chatTokenStub + ); + const connectMessage = chatStreamStub.markdown.getCall(0).args[0]; + expect(connectMessage).to.include( + "Looks like you aren't currently connected, first let's get you connected to the cluster we'd like to create this query to run against." ); - const listConnectionsMessage = chatStreamStub.markdown.getCall(2).args[0]; + const listConnectionsMessage = chatStreamStub.markdown.getCall(1).args[0]; expect(listConnectionsMessage.value).to.include( - '- localhost' + '- localhost0' + ); + const showMoreMessage = chatStreamStub.markdown.getCall(11).args[0]; + expect(showMoreMessage.value).to.include( + '- Show more' ); + expect(chatStreamStub.markdown.callCount).to.be.eql(12); expect( testParticipantController._chatResult?.metadata.responseContent ).to.be.eql(undefined); @@ -191,6 +236,7 @@ suite('Participant Controller Test Suite', function () { }); test('handles empty connection name', async function () { + getSavedConnectionsStub.returns([loadedConnection]); const chatRequestMock = { prompt: 'find all docs by a name example', command: 'query', @@ -218,14 +264,13 @@ suite('Participant Controller Test Suite', function () { expect(emptyMessage).to.include( 'Please select a cluster to connect by clicking on an item in the connections list.' ); - const addNewConnectionMessage = - chatStreamStub.markdown.getCall(4).args[0]; - expect(addNewConnectionMessage.value).to.include( - '- Add new connection' - ); - const listConnectionsMessage = chatStreamStub.markdown.getCall(5).args[0]; + const listConnectionsMessage = chatStreamStub.markdown.getCall(4).args[0]; expect(listConnectionsMessage.value).to.include( - '- localhost' + '- localhost' + ); + const showMoreMessage = chatStreamStub.markdown.getCall(5).args[0]; + expect(showMoreMessage.value).to.include( + '- Show more' ); expect( testParticipantController._chatResult?.metadata.responseContent @@ -242,7 +287,7 @@ suite('Participant Controller Test Suite', function () { test('calls connect with uri for a new connection', async function () { await testParticipantController.connectWithParticipant(); - expect(connectWithURIStub).to.have.been.called; + expect(changeActiveConnectionStub).to.have.been.called; }); }); @@ -253,8 +298,34 @@ suite('Participant Controller Test Suite', function () { 'getActiveDataService', () => ({ - listDatabases: () => Promise.resolve([{ name: 'dbOne' }]), - listCollections: () => Promise.resolve([{ name: 'collOne' }]), + listDatabases: () => + Promise.resolve([ + { name: 'dbOne' }, + { name: 'customer' }, + { name: 'inventory' }, + { name: 'sales' }, + { name: 'employee' }, + { name: 'financialReports' }, + { name: 'productCatalog' }, + { name: 'projectTracker' }, + { name: 'user' }, + { name: 'analytics' }, + { name: '123' }, + ]), + listCollections: () => + Promise.resolve([ + { name: 'collOne' }, + { name: 'notifications' }, + { name: 'products' }, + { name: 'orders' }, + { name: 'categories' }, + { name: 'invoices' }, + { name: 'transactions' }, + { name: 'logs' }, + { name: 'messages' }, + { name: 'sessions' }, + { name: 'feedback' }, + ]), getMongoClientConnectionOptions: () => ({ url: TEST_DATABASE_URI, options: {}, @@ -391,6 +462,12 @@ suite('Participant Controller Test Suite', function () { expect(listDBsMessage.value).to.include( '- dbOne' ); + const showMoreDBsMessage = + chatStreamStub.markdown.getCall(11).args[0]; + expect(showMoreDBsMessage.value).to.include( + '- Show more' + ); + expect(chatStreamStub.markdown.callCount).to.be.eql(12); expect( testParticipantController._chatResult?.metadata.responseContent ).to.be.eql(undefined); @@ -410,14 +487,21 @@ suite('Participant Controller Test Suite', function () { 'dbOne' ); const askForCollMessage = - chatStreamStub.markdown.getCall(2).args[0]; + chatStreamStub.markdown.getCall(12).args[0]; expect(askForCollMessage).to.include( 'Which collection would you like to query within this database?' ); - const listCollsMessage = chatStreamStub.markdown.getCall(3).args[0]; + const listCollsMessage = + chatStreamStub.markdown.getCall(13).args[0]; expect(listCollsMessage.value).to.include( '- collOne' ); + const showMoreCollsMessage = + chatStreamStub.markdown.getCall(23).args[0]; + expect(showMoreCollsMessage.value).to.include( + '- Show more' + ); + expect(chatStreamStub.markdown.callCount).to.be.eql(24); expect( testParticipantController._chatResult?.metadata.responseContent ).to.be.eql(undefined);