diff --git a/src/documentSource.ts b/src/documentSource.ts index dc7b52196..b3846e599 100644 --- a/src/documentSource.ts +++ b/src/documentSource.ts @@ -3,6 +3,7 @@ export const DocumentSource = { playground: 'playground', collectionview: 'collectionview', codelens: 'codelens', + databrowser: 'databrowser', } as const; export type DocumentSource = diff --git a/src/editors/playgroundController.ts b/src/editors/playgroundController.ts index 6ddbd34e5..6b95f9c6b 100644 --- a/src/editors/playgroundController.ts +++ b/src/editors/playgroundController.ts @@ -507,8 +507,12 @@ export default class PlaygroundController { if (shouldConfirmRunCopilotCode === true) { const name = this._connectionController.getActiveConnectionName(); const confirmRunCopilotCode = await vscode.window.showInformationMessage( - `Are you sure you want to run this code generated by the MongoDB participant against ${name}? This confirmation can be disabled in the extension settings.`, - { modal: true }, + `Are you sure you want to run this code generated by the MongoDB participant against ${name}?`, + { + modal: true, + detail: + 'This confirmation can be disabled in the extension settings.', + }, 'Yes', ); @@ -576,8 +580,12 @@ export default class PlaygroundController { if (shouldConfirmRunAll === true) { const name = this._connectionController.getActiveConnectionName(); const confirmRunAll = await vscode.window.showInformationMessage( - `Are you sure you want to run this playground against ${name}? This confirmation can be disabled in the extension settings.`, - { modal: true }, + `Are you sure you want to run this playground against ${name}?`, + { + modal: true, + detail: + 'This confirmation can be disabled in the extension settings.', + }, 'Yes', ); diff --git a/src/explorer/documentTreeItem.ts b/src/explorer/documentTreeItem.ts index b0b86c396..6bda09c20 100644 --- a/src/explorer/documentTreeItem.ts +++ b/src/explorer/documentTreeItem.ts @@ -57,7 +57,7 @@ export default class DocumentTreeItem const documentLabel = document._id ? JSON.stringify(document._id) - : `Document ${documentIndexInTree + 1}`; + : `"Document ${documentIndexInTree + 1}"`; this.dataService = dataService; this.document = document; @@ -112,9 +112,11 @@ export default class DocumentTreeItem if (shouldConfirmDeleteDocument === true) { const confirmationResult = await vscode.window.showInformationMessage( - `Are you sure you wish to drop this document${this.tooltip ? ` "${this.tooltip}"` : ''}? This confirmation can be disabled in the extension settings.`, + `Are you sure you wish to drop this document${this.tooltip ? ` ${this.tooltip}` : ''}?`, { modal: true, + detail: + 'This confirmation can be disabled in the extension settings.', }, 'Yes', ); diff --git a/src/mdbExtensionController.ts b/src/mdbExtensionController.ts index 13a7eefb1..af01a032e 100644 --- a/src/mdbExtensionController.ts +++ b/src/mdbExtensionController.ts @@ -264,6 +264,9 @@ export default class MDBExtensionController implements vscode.Disposable { }); this._dataBrowsingController = new DataBrowsingController({ connectionController: this._connectionController, + editorsController: this._editorsController, + playgroundController: this._playgroundController, + explorerController: this._explorerController, telemetryService: this._telemetryService, }); this._editorsController.registerProviders(); diff --git a/src/test/suite/views/data-browsing-app/messageHandler.test.tsx b/src/test/suite/views/data-browsing-app/messageHandler.test.tsx index 9d8187c31..48ee4b6c9 100644 --- a/src/test/suite/views/data-browsing-app/messageHandler.test.tsx +++ b/src/test/suite/views/data-browsing-app/messageHandler.test.tsx @@ -7,6 +7,7 @@ import { handleExtensionMessage, setupMessageHandler, } from '../../../../views/data-browsing-app/store/messageHandler'; +import * as vscodeApi from '../../../../views/data-browsing-app/vscode-api'; describe('messageHandler test suite', function () { afterEach(function () { @@ -273,6 +274,37 @@ describe('messageHandler test suite', function () { expect(store.getState().documentQuery.themeKind).to.equal('vs'); }); }); + + describe('documentDeleted', function () { + it('should request a refresh when documentDeleted message received', function () { + const store = createStore(); + + // Ensure we start with a non-loading state + handleExtensionMessage(store.dispatch, { + command: PreviewMessageType.loadPage, + documents: [{ _id: '1', name: 'Test' }], + }); + expect(store.getState().documentQuery.isLoading).to.be.false; + + // Stub the API calls that documentsRefreshRequested triggers + const sendGetDocumentsStub = sinon.stub(vscodeApi, 'sendGetDocuments'); + const sendGetTotalCountStub = sinon.stub( + vscodeApi, + 'sendGetTotalCount', + ); + + // Trigger documentDeleted message + handleExtensionMessage(store.dispatch, { + command: PreviewMessageType.documentDeleted, + }); + + // State should be set to loading and refresh API functions called + expect(store.getState().documentQuery.isLoading).to.be.true; + expect(store.getState().documentQuery.isLoading).to.be.true; + expect(sendGetDocumentsStub.calledOnce).to.be.true; + expect(sendGetTotalCountStub.calledOnce).to.be.true; + }); + }); }); describe('setupMessageHandler', function () { diff --git a/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx b/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx index 46bdc48ac..0fb5b408a 100644 --- a/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx +++ b/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx @@ -1,9 +1,16 @@ import React from 'react'; import { expect } from 'chai'; import sinon from 'sinon'; -import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { + render, + screen, + cleanup, + waitFor, + fireEvent, +} from '@testing-library/react'; import MonacoViewer from '../../../../views/data-browsing-app/monaco-viewer'; +import * as vscodeApi from '../../../../views/data-browsing-app/vscode-api'; // Mock the Monaco Editor component let mockEditorValue = ''; @@ -314,4 +321,172 @@ describe('MonacoViewer test suite', function () { expect(container).to.exist; }); }); + + describe('Action bar', function () { + let sendEditDocumentStub: sinon.SinonStub; + let sendCloneDocumentStub: sinon.SinonStub; + let sendDeleteDocumentStub: sinon.SinonStub; + let clipboardWriteTextStub: sinon.SinonStub; + + beforeEach(function () { + sendEditDocumentStub = sinon.stub(vscodeApi, 'sendEditDocument'); + sendCloneDocumentStub = sinon.stub(vscodeApi, 'sendCloneDocument'); + sendDeleteDocumentStub = sinon.stub(vscodeApi, 'sendDeleteDocument'); + + if (!navigator.clipboard) { + (navigator as any).clipboard = { + writeText: async (): Promise => { + return Promise.resolve(); + }, + }; + } else if (!navigator.clipboard.writeText) { + (navigator.clipboard as any).writeText = async (): Promise => { + return Promise.resolve(); + }; + } + clipboardWriteTextStub = sinon.stub(navigator.clipboard, 'writeText'); + }); + + afterEach(function () { + sendEditDocumentStub.restore(); + sendCloneDocumentStub.restore(); + sendDeleteDocumentStub.restore(); + clipboardWriteTextStub.restore(); + }); + + it('should render all action buttons when document has _id', function () { + const document = { _id: '123', name: 'Test' }; + + render(); + + const editButton = screen.queryByTitle('Edit'); + const copyButton = screen.queryByTitle('Copy'); + const cloneButton = screen.queryByTitle('Clone'); + const deleteButton = screen.queryByTitle('Delete'); + + expect(editButton).to.exist; + expect(copyButton).to.exist; + expect(cloneButton).to.exist; + expect(deleteButton).to.exist; + }); + + it('should only render copy button when document has no _id', function () { + const document = { name: 'Test' }; + + render(); + + const editButton = screen.queryByTitle('Edit'); + const copyButton = screen.queryByTitle('Copy'); + const cloneButton = screen.queryByTitle('Clone'); + const deleteButton = screen.queryByTitle('Delete'); + + expect(editButton).to.not.exist; + expect(copyButton).to.exist; + expect(cloneButton).to.not.exist; + expect(deleteButton).to.not.exist; + }); + + it('should call sendEditDocument when edit button is clicked', function () { + const document = { _id: '123', name: 'Test' }; + + render(); + + const editButton = screen.getByTitle('Edit'); + fireEvent.click(editButton); + + expect(sendEditDocumentStub.calledOnce).to.be.true; + expect(sendEditDocumentStub.calledWith('123')).to.be.true; + }); + + it('should call clipboard.writeText when copy button is clicked', async function () { + const document = { _id: '123', name: 'Test' }; + + render(); + + const copyButton = screen.getByTitle('Copy'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(clipboardWriteTextStub.calledOnce).to.be.true; + const calledWith = clipboardWriteTextStub.firstCall.args[0]; + expect(calledWith).to.be.a('string'); + expect(calledWith).to.include('_id:'); + expect(calledWith).to.include('123'); + }); + }); + + it('should call sendCloneDocument when clone button is clicked', function () { + const document = { _id: '123', name: 'Test' }; + + render(); + + const cloneButton = screen.getByTitle('Clone'); + fireEvent.click(cloneButton); + + expect(sendCloneDocumentStub.calledOnce).to.be.true; + expect(sendCloneDocumentStub.calledWith(document)).to.be.true; + }); + + it('should call sendDeleteDocument when delete button is clicked', function () { + const document = { _id: '123', name: 'Test' }; + + render(); + + const deleteButton = screen.getByTitle('Delete'); + fireEvent.click(deleteButton); + + expect(sendDeleteDocumentStub.calledOnce).to.be.true; + expect(sendDeleteDocumentStub.calledWith('123')).to.be.true; + }); + + it('should handle ObjectId _id correctly', function () { + const document = { + _id: { $oid: '507f1f77bcf86cd799439011' }, + name: 'Test', + }; + + render(); + + const editButton = screen.getByTitle('Edit'); + fireEvent.click(editButton); + + expect(sendEditDocumentStub.calledOnce).to.be.true; + expect(sendEditDocumentStub.firstCall.args[0]).to.deep.equal({ + $oid: '507f1f77bcf86cd799439011', + }); + }); + + it('should handle numeric _id correctly', function () { + const document = { _id: 42, name: 'Test' }; + + render(); + + const deleteButton = screen.getByTitle('Delete'); + fireEvent.click(deleteButton); + + expect(sendDeleteDocumentStub.calledOnce).to.be.true; + expect(sendDeleteDocumentStub.calledWith(42)).to.be.true; + }); + + it('should have correct codicon icons for each button', function () { + const document = { _id: '123', name: 'Test' }; + + render(); + + const editButton = screen.getByTitle('Edit'); + const copyButton = screen.getByTitle('Copy'); + const cloneButton = screen.getByTitle('Clone'); + const deleteButton = screen.getByTitle('Delete'); + + const editIcon = editButton.querySelector('.codicon-edit'); + const copyIcon = copyButton.querySelector('.codicon-copy'); + const cloneIcon = cloneButton.querySelector('.codicon-files'); + const deleteIcon = deleteButton.querySelector('.codicon-trash'); + + expect(editIcon).to.exist; + expect(copyIcon).to.exist; + expect(cloneIcon).to.exist; + expect(deleteIcon).to.exist; + }); + }); }); diff --git a/src/test/suite/views/data-browsing-app/vscode-api.test.tsx b/src/test/suite/views/data-browsing-app/vscode-api.test.tsx index c4d324e1e..32d71a0e1 100644 --- a/src/test/suite/views/data-browsing-app/vscode-api.test.tsx +++ b/src/test/suite/views/data-browsing-app/vscode-api.test.tsx @@ -7,6 +7,9 @@ import { sendCancelRequest, sendGetDocuments, sendGetThemeColors, + sendEditDocument, + sendCloneDocument, + sendDeleteDocument, } from '../../../../views/data-browsing-app/vscode-api'; describe('vscode-api test suite', function () { @@ -73,4 +76,106 @@ describe('vscode-api test suite', function () { }); }); }); + + describe('sendEditDocument', function () { + it('should send message with editDocument command and documentId', function () { + const documentId = '507f1f77bcf86cd799439011'; + sendEditDocument(documentId); + + expect(postMessageStub).to.have.been.calledOnce; + expect(postMessageStub).to.have.been.calledWithExactly({ + command: PreviewMessageType.editDocument, + documentId, + }); + }); + + it('should handle ObjectId format documentId', function () { + const documentId = { $oid: '507f1f77bcf86cd799439011' }; + sendEditDocument(documentId); + + expect(postMessageStub).to.have.been.calledOnce; + expect(postMessageStub).to.have.been.calledWithExactly({ + command: PreviewMessageType.editDocument, + documentId, + }); + }); + + it('should handle numeric documentId', function () { + const documentId = 123; + sendEditDocument(documentId); + + expect(postMessageStub).to.have.been.calledOnce; + expect(postMessageStub).to.have.been.calledWithExactly({ + command: PreviewMessageType.editDocument, + documentId, + }); + }); + }); + + describe('sendCloneDocument', function () { + it('should send message with cloneDocument command and serialized document', function () { + const document = { _id: '123', name: 'Test', value: 42 }; + sendCloneDocument(document); + + expect(postMessageStub).to.have.been.calledOnce; + const call = postMessageStub.firstCall.args[0]; + expect(call.command).to.equal(PreviewMessageType.cloneDocument); + expect(call.document).to.exist; + // Document should be serialized with EJSON + expect(call.document).to.deep.include({ + _id: '123', + name: 'Test', + value: { $numberInt: '42' }, + }); + }); + + it('should handle document with complex types', function () { + const document = { + _id: { $oid: '507f1f77bcf86cd799439011' }, + date: { $date: '2024-01-01T00:00:00Z' }, + nested: { field: 'value' }, + }; + sendCloneDocument(document); + + expect(postMessageStub).to.have.been.calledOnce; + const call = postMessageStub.firstCall.args[0]; + expect(call.command).to.equal(PreviewMessageType.cloneDocument); + expect(call.document).to.exist; + }); + }); + + describe('sendDeleteDocument', function () { + it('should send message with deleteDocument command and documentId', function () { + const documentId = '507f1f77bcf86cd799439011'; + sendDeleteDocument(documentId); + + expect(postMessageStub).to.have.been.calledOnce; + expect(postMessageStub).to.have.been.calledWithExactly({ + command: PreviewMessageType.deleteDocument, + documentId, + }); + }); + + it('should handle ObjectId format documentId', function () { + const documentId = { $oid: '507f1f77bcf86cd799439011' }; + sendDeleteDocument(documentId); + + expect(postMessageStub).to.have.been.calledOnce; + expect(postMessageStub).to.have.been.calledWithExactly({ + command: PreviewMessageType.deleteDocument, + documentId, + }); + }); + + it('should handle numeric documentId', function () { + const documentId = 456; + sendDeleteDocument(documentId); + + expect(postMessageStub).to.have.been.calledOnce; + expect(postMessageStub).to.have.been.calledWithExactly({ + command: PreviewMessageType.deleteDocument, + documentId, + }); + }); + }); }); diff --git a/src/test/suite/views/dataBrowsingController.test.ts b/src/test/suite/views/dataBrowsingController.test.ts index e11946093..83a97e359 100644 --- a/src/test/suite/views/dataBrowsingController.test.ts +++ b/src/test/suite/views/dataBrowsingController.test.ts @@ -1,5 +1,5 @@ import sinon, { type SinonSandbox, type SinonStub } from 'sinon'; -import type * as vscode from 'vscode'; +import * as vscode from 'vscode'; import { expect } from 'chai'; import { beforeEach, afterEach } from 'mocha'; @@ -7,6 +7,7 @@ import DataBrowsingController from '../../../views/dataBrowsingController'; import { PreviewMessageType } from '../../../views/data-browsing-app/extension-app-message-constants'; import type { DataBrowsingOptions } from '../../../views/dataBrowsingController'; import { CollectionType } from '../../../explorer/documentUtils'; +import { EJSON } from 'bson'; suite('DataBrowsingController Test Suite', function () { const sandbox: SinonSandbox = sinon.createSandbox(); @@ -55,6 +56,9 @@ suite('DataBrowsingController Test Suite', function () { }; testController = new DataBrowsingController({ connectionController: mockConnectionController as any, + editorsController: {} as any, + playgroundController: {} as any, + explorerController: {} as any, telemetryService: {} as any, }); mockPanel = createMockPanel(); @@ -753,4 +757,114 @@ suite('DataBrowsingController Test Suite', function () { expect(handleGetTotalCountSpy.calledWith(mockPanel, options)).to.be.true; }); }); + + test('handleEditDocument calls editorsController.openMongoDBDocument', async function () { + const options = createMockOptions(); + + (testController as any)._connectionController = { + getActiveConnectionId: sandbox.stub().returns('conn-id'), + }; + + const openSpy = sandbox.stub().resolves(true); + // attach editors controller + (testController as any)._editorsController = { + openMongoDBDocument: openSpy, + }; + + await testController.handleEditDocument(mockPanel, options, 'my-id'); + + expect(openSpy.calledOnce).to.be.true; + const arg = openSpy.firstCall.args[0]; + expect(arg.documentId).to.equal('my-id'); + expect(arg.namespace).to.equal(options.namespace); + expect(arg.source).to.equal('databrowser'); + }); + + test('handleCloneDocument creates playground without _id', async function () { + const options = createMockOptions(); + + const createPlaygroundStub = sandbox.stub().resolves(true); + (testController as any)._playgroundController = { + createPlaygroundForCloneDocument: createPlaygroundStub, + }; + + const doc = { _id: '123', name: 'Test' }; + + // Note: handleCloneDocument expects a serialized single document (not array) + const singleSerialized = EJSON.serialize(doc, { relaxed: false }); + + await testController.handleCloneDocument( + mockPanel, + options, + singleSerialized, + ); + + expect(createPlaygroundStub.calledOnce).to.be.true; + const calledWith = createPlaygroundStub.firstCall.args; + // first arg is document contents string, second is database name, third is collection name + expect(calledWith[1]).to.equal('test'); + expect(calledWith[2]).to.equal('collection'); + }); + + test('handleDeleteDocument deletes and notifies webview when confirmed', async function () { + const options = createMockOptions(); + + // stub confirm setting + const getStub = sandbox.stub(); + getStub.withArgs('confirmDeleteDocument').returns(false); + + sandbox + .stub(vscode.workspace, 'getConfiguration') + .returns({ get: getStub } as any); + + // stub deleteOne on data service + (mockDataService as any).deleteOne = sandbox + .stub() + .resolves({ deletedCount: 1 }); + // attach explorer controller + (testController as any)._explorerController = { refresh: sandbox.stub() }; + + // make sure we use the mock data service + (testController as any)._connectionController = { + getActiveDataService: () => { + return mockDataService; + }, + }; + + await testController.handleDeleteDocument(mockPanel, options, 'del-id'); + + expect(getStub.calledWith('confirmDeleteDocument')).to.be.true; + + expect((mockDataService as any).deleteOne.calledOnce).to.be.true; + const deleteArgs = (mockDataService as any).deleteOne.firstCall.args; + expect(deleteArgs[0]).to.equal(options.namespace); + expect(deleteArgs[1]).to.deep.equal({ _id: 'del-id' }); + + // explorer refresh called + expect((testController as any)._explorerController.refresh.calledOnce).to.be + .true; + + // webview notified + const msg = postMessageStub + .getCalls() + .find((c) => c.args[0].command === PreviewMessageType.documentDeleted); + expect(msg).to.not.be.undefined; + }); + + test('handleDeleteDocument cancels when user declines', async function () { + const options = createMockOptions(); + + sandbox + .stub(vscode.workspace, 'getConfiguration') + .returns({ get: () => true } as any); + sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); + + (mockDataService as any).deleteOne = sandbox + .stub() + .resolves({ deletedCount: 1 }); + + await testController.handleDeleteDocument(mockPanel, options, 'del-id'); + + expect((mockDataService as any).deleteOne.called).to.be.false; + }); }); diff --git a/src/views/data-browsing-app/extension-app-message-constants.ts b/src/views/data-browsing-app/extension-app-message-constants.ts index 83d4b50a8..94a07bb9e 100644 --- a/src/views/data-browsing-app/extension-app-message-constants.ts +++ b/src/views/data-browsing-app/extension-app-message-constants.ts @@ -4,6 +4,9 @@ export const PreviewMessageType = { getTotalCount: 'GET_TOTAL_COUNT', cancelRequest: 'CANCEL_REQUEST', getThemeColors: 'GET_THEME_COLORS', + editDocument: 'EDIT_DOCUMENT', + cloneDocument: 'CLONE_DOCUMENT', + deleteDocument: 'DELETE_DOCUMENT', // Messages from extension to webview loadPage: 'LOAD_PAGE', @@ -12,6 +15,7 @@ export const PreviewMessageType = { updateTotalCount: 'UPDATE_TOTAL_COUNT', updateTotalCountError: 'UPDATE_TOTAL_COUNT_ERROR', updateThemeColors: 'UPDATE_THEME_COLORS', + documentDeleted: 'DOCUMENT_DELETED', } as const; export interface TokenColors { @@ -53,6 +57,21 @@ export interface GetThemeColorsMessage extends BasicWebviewMessage { command: typeof PreviewMessageType.getThemeColors; } +export interface EditDocumentMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.editDocument; + documentId: any; +} + +export interface CloneDocumentMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.cloneDocument; + document: Record; +} + +export interface DeleteDocumentMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.deleteDocument; + documentId: any; +} + // Messages from extension to webview export interface LoadPageMessage extends BasicWebviewMessage { command: typeof PreviewMessageType.loadPage; @@ -84,11 +103,18 @@ export interface UpdateThemeColorsMessage extends BasicWebviewMessage { themeKind: MonacoBaseTheme; } +export interface DocumentDeletedMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.documentDeleted; +} + export type MessageFromWebviewToExtension = | GetDocumentsMessage | GetTotalCountMessage | CancelRequestMessage - | GetThemeColorsMessage; + | GetThemeColorsMessage + | EditDocumentMessage + | CloneDocumentMessage + | DeleteDocumentMessage; export type MessageFromExtensionToWebview = | LoadPageMessage @@ -96,4 +122,5 @@ export type MessageFromExtensionToWebview = | RequestCancelledMessage | UpdateTotalCountMessage | UpdateTotalCountErrorMessage - | UpdateThemeColorsMessage; + | UpdateThemeColorsMessage + | DocumentDeletedMessage; diff --git a/src/views/data-browsing-app/monaco-viewer.tsx b/src/views/data-browsing-app/monaco-viewer.tsx index 0171d9c01..1ebaa1d31 100644 --- a/src/views/data-browsing-app/monaco-viewer.tsx +++ b/src/views/data-browsing-app/monaco-viewer.tsx @@ -15,6 +15,11 @@ import type { } from './extension-app-message-constants'; import { toJSString } from 'mongodb-query-parser'; import { EJSON } from 'bson'; +import { + sendEditDocument, + sendCloneDocument, + sendDeleteDocument, +} from './vscode-api'; // Configure Monaco Editor loader to use local files instead of CDN declare global { @@ -75,6 +80,7 @@ const monacoWrapperStyles = css({ }); const cardStyles = css({ + position: 'relative', backgroundColor: 'var(--vscode-editorWidget-background, var(--vscode-editor-background))', border: @@ -82,6 +88,49 @@ const cardStyles = css({ borderRadius: '6px', marginBottom: spacing[200], padding: spacing[300], + + '.action-bar': { + position: 'absolute', + top: spacing[200], + right: spacing[200], + display: 'flex', + gap: spacing[100], + zIndex: 1000, + + opacity: 0, + transition: 'opacity 0.2s', + }, + + '&:hover .action-bar': { + opacity: 1, + }, + + '&:focus-within .action-bar': { + opacity: 1, + }, +}); + +const actionButtonStyles = css({ + background: 'var(--vscode-button-background)', + border: '1px solid var(--vscode-button-border, transparent)', + color: 'var(--vscode-button-foreground)', + borderRadius: '4px', + padding: `${spacing[100]}px ${spacing[200]}px`, + cursor: 'pointer', + fontSize: '12px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: '24px', + minHeight: '24px', + + '&:hover': { + background: 'var(--vscode-button-hoverBackground)', + }, + + '&:active': { + background: 'var(--vscode-button-background)', + }, }); const viewerOptions: Monaco.editor.IStandaloneEditorConstructionOptions = { @@ -258,8 +307,68 @@ const MonacoViewer: React.FC = ({ }; }, []); + const handleEdit = useCallback(() => { + if (document._id) { + sendEditDocument(document._id); + } + }, [document]); + + const handleCopy = useCallback(() => { + void navigator.clipboard.writeText(documentString); + }, [documentString]); + + const handleClone = useCallback(() => { + sendCloneDocument(document); + }, [document]); + + const handleDelete = useCallback(() => { + if (document._id) { + sendDeleteDocument(document._id); + } + }, [document]); + return (
+
+ {document._id && ( + + )} + + {document._id && ( + + )} + {document._id && ( + + )} +
{ command: PreviewMessageType.getThemeColors, }); }; + +export const sendEditDocument = (documentId: any): void => { + getVSCodeApi().postMessage({ + command: PreviewMessageType.editDocument, + documentId, + }); +}; + +export const sendCloneDocument = (document: Record): void => { + getVSCodeApi().postMessage({ + command: PreviewMessageType.cloneDocument, + document: EJSON.serialize(document, { relaxed: false }), + }); +}; + +export const sendDeleteDocument = (documentId: any): void => { + getVSCodeApi().postMessage({ + command: PreviewMessageType.deleteDocument, + documentId, + }); +}; diff --git a/src/views/dataBrowsingController.ts b/src/views/dataBrowsingController.ts index c6341a78a..7d76d2ff7 100644 --- a/src/views/dataBrowsingController.ts +++ b/src/views/dataBrowsingController.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { EJSON, type Document } from 'bson'; import path from 'path'; +import { toJSString } from 'mongodb-query-parser'; import type ConnectionController from '../connectionController'; import { createLogger } from '../logging'; import { PreviewMessageType } from './data-browsing-app/extension-app-message-constants'; @@ -13,6 +14,11 @@ import { getThemeTokenColors, getMonacoBaseTheme, } from '../utils/themeColorReader'; +import type EditorsController from '../editors/editorsController'; +import type PlaygroundController from '../editors/playgroundController'; +import { DocumentSource } from '../documentSource'; +import { getDocumentViewAndEditFormat } from '../editors/types'; +import type ExplorerController from '../explorer/explorerController'; const log = createLogger('data browsing controller'); @@ -63,6 +69,9 @@ interface PanelAbortControllers { export default class DataBrowsingController { _connectionController: ConnectionController; + _editorsController: EditorsController; + _playgroundController: PlaygroundController; + _explorerController: ExplorerController; _telemetryService: TelemetryService; _activeWebviewPanels: vscode.WebviewPanel[] = []; _configChangedSubscription: vscode.Disposable; @@ -72,12 +81,21 @@ export default class DataBrowsingController { constructor({ connectionController, + editorsController, + playgroundController, + explorerController, telemetryService, }: { connectionController: ConnectionController; + editorsController: EditorsController; + playgroundController: PlaygroundController; + explorerController: ExplorerController; telemetryService: TelemetryService; }) { this._connectionController = connectionController; + this._editorsController = editorsController; + this._playgroundController = playgroundController; + this._explorerController = explorerController; this._telemetryService = telemetryService; this._configChangedSubscription = vscode.workspace.onDidChangeConfiguration( this.onConfigurationChanged, @@ -142,6 +160,23 @@ export default class DataBrowsingController { case PreviewMessageType.getThemeColors: this._sendThemeColors(panel); return; + case PreviewMessageType.editDocument: + await this.handleEditDocument( + panel, + options, + EJSON.deserialize(message.documentId, { relaxed: false }), + ); + return; + case PreviewMessageType.cloneDocument: + await this.handleCloneDocument(panel, options, message.document); + return; + case PreviewMessageType.deleteDocument: + await this.handleDeleteDocument( + panel, + options, + EJSON.deserialize(message.documentId, { relaxed: false }), + ); + return; default: // no-op. return; @@ -234,6 +269,116 @@ export default class DataBrowsingController { } }; + handleEditDocument = async ( + panel: vscode.WebviewPanel, + options: DataBrowsingOptions, + documentId: any, + ): Promise => { + try { + await this._editorsController.openMongoDBDocument({ + source: DocumentSource.databrowser, + documentId, + namespace: options.namespace, + format: getDocumentViewAndEditFormat(), + connectionId: this._connectionController.getActiveConnectionId(), + line: 1, + }); + } catch (error) { + log.error('Error opening document for editing', error); + void vscode.window.showErrorMessage( + `Failed to open document: ${formatError(error).message}`, + ); + } + }; + + handleCloneDocument = async ( + panel: vscode.WebviewPanel, + options: DataBrowsingOptions, + document: Record, + ): Promise => { + try { + const deserialized = EJSON.deserialize(document, { relaxed: false }); + delete deserialized._id; + const documentContents = toJSString(deserialized) ?? ''; + + const [databaseName, collectionName] = options.namespace.split(/\.(.*)/s); + + await this._playgroundController.createPlaygroundForCloneDocument( + documentContents, + databaseName, + collectionName, + ); + } catch (error) { + log.error('Error cloning document', error); + void vscode.window.showErrorMessage( + `Failed to clone document: ${formatError(error).message}`, + ); + } + }; + + handleDeleteDocument = async ( + panel: vscode.WebviewPanel, + options: DataBrowsingOptions, + documentId: any, + ): Promise => { + try { + const shouldConfirmDeleteDocument = vscode.workspace + .getConfiguration('mdb') + .get('confirmDeleteDocument'); + + if (shouldConfirmDeleteDocument === true) { + const documentIdString = JSON.stringify( + EJSON.serialize(documentId, { relaxed: false }), + ); + const confirmationResult = await vscode.window.showInformationMessage( + `Are you sure you wish to drop this document${documentIdString ? ` ${documentIdString}` : ''}?`, + { + modal: true, + detail: + 'This confirmation can be disabled in the extension settings.', + }, + 'Yes', + ); + + if (confirmationResult !== 'Yes') { + return; + } + } + + const dataService = this._connectionController.getActiveDataService(); + if (!dataService) { + throw new Error('No active database connection'); + } + + const deleteResult = await dataService.deleteOne( + options.namespace, + { _id: documentId }, + {}, + ); + + if (deleteResult.deletedCount !== 1) { + throw new Error('document not found'); + } + + void vscode.window.showInformationMessage( + 'Document successfully deleted.', + ); + + // Refresh the explorer view + this._explorerController.refresh(); + + // Notify the webview that the document was deleted + void panel.webview.postMessage({ + command: PreviewMessageType.documentDeleted, + }); + } catch (error) { + log.error('Error deleting document', error); + void vscode.window.showErrorMessage( + `Failed to delete document: ${formatError(error).message}`, + ); + } + }; + private async _fetchDocuments( namespace: string, signal?: AbortSignal,