diff --git a/package.json b/package.json index 2f08bb935..4a2ec18d4 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "pretest": "pnpm run compile", "test": "pnpm run test-webview && pnpm run test-extension", "test-extension": "cross-env MDB_IS_TEST=true NODE_OPTIONS=--no-force-async-hooks-checks xvfb-maybe node ./out/test/runTest.js", - "test-webview": "mocha -r ts-node/register --exit --grep=\"${MOCHA_GREP}\" --file ./src/test/setup-webview.ts src/test/suite/views/webview-app/**/*.test.tsx", + "test-webview": "mocha -r ts-node/register --exit --grep=\"${MOCHA_GREP}\" --file ./src/test/setup-webview.ts \"src/test/suite/views/{webview-app,data-browsing-app}/**/*.test.tsx\"", "ai-accuracy-tests": "env TS_NODE_FILES=true mocha -r ts-node/register --grep=\"${MOCHA_GREP}\" --file ./src/test/ai-accuracy-tests/test-setup.ts ./src/test/ai-accuracy-tests/ai-accuracy-tests.ts", "analyze-bundle": "webpack --mode production --analyze", "vscode:prepublish": "pnpm run clean && pnpm run compile:constants && pnpm run compile:resources && webpack --mode production", diff --git a/scripts/check-vsix-size.ts b/scripts/check-vsix-size.ts index bcbda1cc1..9d160734f 100644 --- a/scripts/check-vsix-size.ts +++ b/scripts/check-vsix-size.ts @@ -15,7 +15,7 @@ const vsixFileName = path.resolve( ); const size = fs.statSync(vsixFileName).size; -const maxSize = 12_000_000; +const maxSize = 15_000_000; if (size >= maxSize) { throw new Error( diff --git a/src/commands/index.ts b/src/commands/index.ts index 3e8a4aae6..7f6f7f09a 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -75,6 +75,10 @@ export const ExtensionCommand = { mdbStopStreamProcessor: 'mdb.stopStreamProcessor', mdbDropStreamProcessor: 'mdb.dropStreamProcessor', + // Commands for the data browsing upgrade. + mdbOpenCollectionPreviewFromTreeView: + 'mdb.internal.openCollectionPreviewFromTreeView', + // Chat participant. openParticipantCodeInPlayground: 'mdb.openParticipantCodeInPlayground', sendMessageToParticipant: 'mdb.sendMessageToParticipant', diff --git a/src/explorer/collectionTreeItem.ts b/src/explorer/collectionTreeItem.ts index 9c0a47e86..2c159d986 100644 --- a/src/explorer/collectionTreeItem.ts +++ b/src/explorer/collectionTreeItem.ts @@ -6,11 +6,13 @@ import DocumentListTreeItem, { CollectionType, MAX_DOCUMENTS_VISIBLE, } from './documentListTreeItem'; +import ShowPreviewTreeItem from './documentListPreviewItem'; import formatError from '../utils/formatError'; import { getImagesPath } from '../extensionConstants'; import IndexListTreeItem from './indexListTreeItem'; import type TreeItemParent from './treeItemParentInterface'; import SchemaTreeItem from './schemaTreeItem'; +import { getFeatureFlag } from '../featureFlags'; function getIconPath( type: string, @@ -47,8 +49,15 @@ export type CollectionDetailsType = Awaited< >[number]; function isChildCacheOutOfSync( - child: DocumentListTreeItem | SchemaTreeItem | IndexListTreeItem, + child: + | ShowPreviewTreeItem + | DocumentListTreeItem + | SchemaTreeItem + | IndexListTreeItem, ): boolean { + if (!('isExpanded' in child)) { + return false; + } const isExpanded = child.isExpanded; const collapsibleState = child.collapsibleState; return isExpanded @@ -56,13 +65,15 @@ function isChildCacheOutOfSync( : collapsibleState !== vscode.TreeItemCollapsibleState.Collapsed; } +export type DocumentsTreeItem = ShowPreviewTreeItem | DocumentListTreeItem; + export default class CollectionTreeItem extends vscode.TreeItem implements TreeItemParent, vscode.TreeDataProvider { contextValue = 'collectionTreeItem' as const; - private _documentListChild: DocumentListTreeItem; + private _documentsChild: DocumentsTreeItem; private _schemaChild: SchemaTreeItem; private _indexListChild: IndexListTreeItem; @@ -90,7 +101,7 @@ export default class CollectionTreeItem isExpanded, cacheIsUpToDate, cachedDocumentCount, - existingDocumentListChild, + existingDocumentsChild, existingSchemaChild, existingIndexListChild, }: { @@ -100,7 +111,7 @@ export default class CollectionTreeItem isExpanded: boolean; cacheIsUpToDate: boolean; cachedDocumentCount: number | null; - existingDocumentListChild?: DocumentListTreeItem; + existingDocumentsChild?: DocumentsTreeItem; existingSchemaChild?: SchemaTreeItem; existingIndexListChild?: IndexListTreeItem; }) { @@ -120,20 +131,41 @@ export default class CollectionTreeItem this.isExpanded = isExpanded; this.documentCount = cachedDocumentCount; this.cacheIsUpToDate = cacheIsUpToDate; - this._documentListChild = existingDocumentListChild - ? existingDocumentListChild - : new DocumentListTreeItem({ - collectionName: this.collectionName, - databaseName: this.databaseName, - type: this._type, - dataService: this._dataService, - isExpanded: false, - maxDocumentsToShow: MAX_DOCUMENTS_VISIBLE, - cachedDocumentCount: this.documentCount, - refreshDocumentCount: this.refreshDocumentCount, - cacheIsUpToDate: false, - childrenCache: [], // Empty cache. - }); + + const useEnhancedDataBrowsing = getFeatureFlag( + 'useEnhancedDataBrowsingExperience', + ); + + // Use existing child if provided, otherwise create the appropriate type + // based on the feature flag. + if (existingDocumentsChild) { + this._documentsChild = existingDocumentsChild; + } else if (useEnhancedDataBrowsing) { + this._documentsChild = new ShowPreviewTreeItem({ + collectionName: this.collectionName, + databaseName: this.databaseName, + type: this._type, + dataService: this._dataService, + maxDocumentsToShow: MAX_DOCUMENTS_VISIBLE, + cachedDocumentCount: this.documentCount, + refreshDocumentCount: this.refreshDocumentCount, + cacheIsUpToDate: false, + }); + } else { + this._documentsChild = new DocumentListTreeItem({ + collectionName: this.collectionName, + databaseName: this.databaseName, + type: this._type, + dataService: this._dataService, + isExpanded: false, + maxDocumentsToShow: MAX_DOCUMENTS_VISIBLE, + cachedDocumentCount: this.documentCount, + refreshDocumentCount: this.refreshDocumentCount, + cacheIsUpToDate: false, + childrenCache: [], // Empty cache. + }); + } + this._schemaChild = existingSchemaChild ? existingSchemaChild : new SchemaTreeItem({ @@ -183,7 +215,7 @@ export default class CollectionTreeItem } if (this.cacheIsUpToDate) { - return [this._documentListChild, this._schemaChild, this._indexListChild]; + return [this._documentsChild, this._schemaChild, this._indexListChild]; } this.cacheIsUpToDate = true; @@ -192,22 +224,35 @@ export default class CollectionTreeItem // is ensure to be set by vscode. this.rebuildChildrenCache(); - return [this._documentListChild, this._schemaChild, this._indexListChild]; + return [this._documentsChild, this._schemaChild, this._indexListChild]; } - rebuildDocumentListTreeItem(): void { - this._documentListChild = new DocumentListTreeItem({ - collectionName: this.collectionName, - databaseName: this.databaseName, - type: this._type, - dataService: this._dataService, - isExpanded: this._documentListChild.isExpanded, - maxDocumentsToShow: this._documentListChild.getMaxDocumentsToShow(), - cachedDocumentCount: this.documentCount, - refreshDocumentCount: this.refreshDocumentCount, - cacheIsUpToDate: this._documentListChild.cacheIsUpToDate, - childrenCache: this._documentListChild.getChildrenCache(), - }); + rebuildDocumentsChild(): void { + if (this._documentsChild instanceof ShowPreviewTreeItem) { + this._documentsChild = new ShowPreviewTreeItem({ + collectionName: this.collectionName, + databaseName: this.databaseName, + type: this._type, + dataService: this._dataService, + maxDocumentsToShow: MAX_DOCUMENTS_VISIBLE, + cachedDocumentCount: this.documentCount, + refreshDocumentCount: this.refreshDocumentCount, + cacheIsUpToDate: this._documentsChild.cacheIsUpToDate, + }); + } else { + this._documentsChild = new DocumentListTreeItem({ + collectionName: this.collectionName, + databaseName: this.databaseName, + type: this._type, + dataService: this._dataService, + isExpanded: this._documentsChild.isExpanded, + maxDocumentsToShow: this._documentsChild.getMaxDocumentsToShow(), + cachedDocumentCount: this.documentCount, + refreshDocumentCount: this.refreshDocumentCount, + cacheIsUpToDate: this._documentsChild.cacheIsUpToDate, + childrenCache: this._documentsChild.getChildrenCache(), + }); + } } rebuildSchemaTreeItem(): void { @@ -237,14 +282,14 @@ export default class CollectionTreeItem rebuildChildrenCache(): void { // We rebuild the children here so their controlled `expanded` state // is ensure to be set by vscode. - this.rebuildDocumentListTreeItem(); + this.rebuildDocumentsChild(); this.rebuildSchemaTreeItem(); this.rebuildIndexListTreeItem(); } needsToUpdateCache(): boolean { return ( - isChildCacheOutOfSync(this._documentListChild) || + isChildCacheOutOfSync(this._documentsChild) || isChildCacheOutOfSync(this._schemaChild) || isChildCacheOutOfSync(this._indexListChild) ); @@ -268,18 +313,36 @@ export default class CollectionTreeItem this.cacheIsUpToDate = false; this.documentCount = null; - this._documentListChild = new DocumentListTreeItem({ - collectionName: this.collectionName, - databaseName: this.databaseName, - type: this._type, - dataService: this._dataService, - isExpanded: false, - maxDocumentsToShow: MAX_DOCUMENTS_VISIBLE, - cachedDocumentCount: this.documentCount, - refreshDocumentCount: this.refreshDocumentCount, - cacheIsUpToDate: false, - childrenCache: [], // Empty cache. - }); + const useEnhancedDataBrowsing = getFeatureFlag( + 'useEnhancedDataBrowsingExperience', + ); + + if (useEnhancedDataBrowsing) { + this._documentsChild = new ShowPreviewTreeItem({ + collectionName: this.collectionName, + databaseName: this.databaseName, + type: this._type, + dataService: this._dataService, + maxDocumentsToShow: MAX_DOCUMENTS_VISIBLE, + cachedDocumentCount: this.documentCount, + refreshDocumentCount: this.refreshDocumentCount, + cacheIsUpToDate: false, + }); + } else { + this._documentsChild = new DocumentListTreeItem({ + collectionName: this.collectionName, + databaseName: this.databaseName, + type: this._type, + dataService: this._dataService, + isExpanded: false, + maxDocumentsToShow: MAX_DOCUMENTS_VISIBLE, + cachedDocumentCount: this.documentCount, + refreshDocumentCount: this.refreshDocumentCount, + cacheIsUpToDate: false, + childrenCache: [], // Empty cache. + }); + } + this._schemaChild = new SchemaTreeItem({ collectionName: this.collectionName, databaseName: this.databaseName, @@ -300,22 +363,23 @@ export default class CollectionTreeItem }); } - getDocumentListChild(): DocumentListTreeItem { - return this._documentListChild; + getDocumentsChild(): DocumentsTreeItem { + return this._documentsChild; } + getSchemaChild(): SchemaTreeItem { return this._schemaChild; } + getIndexListChild(): IndexListTreeItem { return this._indexListChild; } getMaxDocumentsToShow(): number { - if (!this._documentListChild) { - return MAX_DOCUMENTS_VISIBLE; + if (this._documentsChild instanceof DocumentListTreeItem) { + return this._documentsChild.getMaxDocumentsToShow(); } - - return this._documentListChild.getMaxDocumentsToShow(); + return MAX_DOCUMENTS_VISIBLE; } refreshDocumentCount = async (): Promise => { diff --git a/src/explorer/databaseTreeItem.ts b/src/explorer/databaseTreeItem.ts index 1f4e38934..b9d05739d 100644 --- a/src/explorer/databaseTreeItem.ts +++ b/src/explorer/databaseTreeItem.ts @@ -93,7 +93,7 @@ export default class DatabaseTreeItem isExpanded: prevChild.isExpanded, cacheIsUpToDate: prevChild.cacheIsUpToDate, cachedDocumentCount: prevChild.documentCount, - existingDocumentListChild: prevChild.getDocumentListChild(), + existingDocumentsChild: prevChild.getDocumentsChild(), existingSchemaChild: prevChild.getSchemaChild(), existingIndexListChild: prevChild.getIndexListChild(), }); @@ -147,8 +147,8 @@ export default class DatabaseTreeItem cacheIsUpToDate: pastChildrenCache[collection.name].cacheIsUpToDate, cachedDocumentCount: pastChildrenCache[collection.name].documentCount, - existingDocumentListChild: - pastChildrenCache[collection.name].getDocumentListChild(), + existingDocumentsChild: + pastChildrenCache[collection.name].getDocumentsChild(), existingSchemaChild: pastChildrenCache[collection.name].getSchemaChild(), existingIndexListChild: diff --git a/src/explorer/documentListPreviewItem.ts b/src/explorer/documentListPreviewItem.ts new file mode 100644 index 000000000..178cf460a --- /dev/null +++ b/src/explorer/documentListPreviewItem.ts @@ -0,0 +1,133 @@ +import * as vscode from 'vscode'; + +import type { DataService } from 'mongodb-data-service'; +import { + CollectionType, + formatDocCount, + getDocumentsIconPath, + getDocumentsTooltip, +} from './documentListUtils'; +import formatError from '../utils/formatError'; + +export const PREVIEW_LIST_ITEM = 'documentListPreviewItem'; + +export default class ShowPreviewTreeItem extends vscode.TreeItem { + cacheIsUpToDate = false; + contextValue = PREVIEW_LIST_ITEM; + + refreshDocumentCount: () => Promise; + + _documentCount: number | null; + private _maxDocumentsToShow: number; + + collectionName: string; + databaseName: string; + namespace: string; + type: string; + + private _dataService: DataService; + + iconPath: { light: vscode.Uri; dark: vscode.Uri }; + + constructor({ + collectionName, + databaseName, + type, + dataService, + maxDocumentsToShow, + cachedDocumentCount, + refreshDocumentCount, + cacheIsUpToDate, + }: { + collectionName: string; + databaseName: string; + type: string; + dataService: DataService; + maxDocumentsToShow: number; + cachedDocumentCount: number | null; + refreshDocumentCount: () => Promise; + cacheIsUpToDate: boolean; + }) { + super('Documents', vscode.TreeItemCollapsibleState.None); + this.id = `documents-preview-${Math.random()}`; + + this.collectionName = collectionName; + this.databaseName = databaseName; + this.namespace = `${this.databaseName}.${this.collectionName}`; + + this.type = type; // Type can be `collection` or `view`. + this._dataService = dataService; + + this._maxDocumentsToShow = maxDocumentsToShow; + this._documentCount = cachedDocumentCount; + + this.refreshDocumentCount = refreshDocumentCount; + + this.cacheIsUpToDate = cacheIsUpToDate; + + if (this._documentCount !== null) { + this.description = formatDocCount(this._documentCount); + } + + this.iconPath = getDocumentsIconPath(); + this.tooltip = getDocumentsTooltip(type, cachedDocumentCount); + } + + async loadPreview(options?: { + sort?: 'default' | 'asc' | 'desc'; + limit?: number; + }): Promise { + if (this.type === CollectionType.view) { + return []; + } + + this.cacheIsUpToDate = true; + let documents; + + try { + const findOptions: { limit: number; sort?: { _id: 1 | -1 } } = { + limit: options?.limit ?? this._maxDocumentsToShow, + }; + + // Add sort if specified (not 'default') + if (options?.sort === 'asc') { + findOptions.sort = { _id: 1 }; + } else if (options?.sort === 'desc') { + findOptions.sort = { _id: -1 }; + } + + documents = await this._dataService.find( + this.namespace, + {}, // No filter. + findOptions, + ); + } catch (error) { + void vscode.window.showErrorMessage( + `Fetch documents failed: ${formatError(error).message}`, + ); + return []; + } + + return documents; + } + + async getTotalCount(): Promise { + if ( + this.type === CollectionType.view || + this.type === CollectionType.timeseries + ) { + return 0; + } + + try { + const count = await this._dataService.estimatedCount( + this.namespace, + {}, + undefined, + ); + return count; + } catch (error) { + return 0; + } + } +} diff --git a/src/explorer/documentListTreeItem.ts b/src/explorer/documentListTreeItem.ts index f3d9b0d37..13154f830 100644 --- a/src/explorer/documentListTreeItem.ts +++ b/src/explorer/documentListTreeItem.ts @@ -1,13 +1,23 @@ import * as vscode from 'vscode'; -import numeral from 'numeral'; -import path from 'path'; import { createLogger } from '../logging'; import DocumentTreeItem from './documentTreeItem'; import formatError from '../utils/formatError'; -import { getImagesPath } from '../extensionConstants'; import type TreeItemParent from './treeItemParentInterface'; import type { DataService } from 'mongodb-data-service'; +import { + CollectionType, + formatDocCount, + getDocumentsIconPath, + getDocumentsTooltip, +} from './documentListUtils'; + +export { + CollectionType, + formatDocCount, + getDocumentsIconPath, + getDocumentsTooltip, +} from './documentListUtils'; const log = createLogger('documents tree item'); @@ -17,14 +27,6 @@ const log = createLogger('documents tree item'); export const MAX_DOCUMENTS_VISIBLE = 10; export const DOCUMENT_LIST_ITEM = 'documentListTreeItem'; -export const CollectionType = { - collection: 'collection', - view: 'view', - timeseries: 'timeseries', -} as const; - -export type CollectionType = - (typeof CollectionType)[keyof typeof CollectionType]; const ITEM_LABEL = 'Documents'; @@ -65,29 +67,6 @@ const getCollapsableStateForDocumentList = ( : vscode.TreeItemCollapsibleState.Collapsed; }; -export const formatDocCount = (count: number): string => { - // We format the count (30000 -> 30k) and then display it uppercase (30K). - return `${numeral(count).format('0a') as string}`.toUpperCase(); -}; - -function getIconPath(): { light: vscode.Uri; dark: vscode.Uri } { - const LIGHT = path.join(getImagesPath(), 'light'); - const DARK = path.join(getImagesPath(), 'dark'); - - return { - light: vscode.Uri.file(path.join(LIGHT, 'documents.svg')), - dark: vscode.Uri.file(path.join(DARK, 'documents.svg')), - }; -} - -function getTooltip(type: string, documentCount: number | null): string { - const typeString = type === CollectionType.view ? 'View' : 'Collection'; - if (documentCount !== null) { - return `${typeString} Documents - ${documentCount}`; - } - return `${typeString} Documents`; -} - export default class DocumentListTreeItem extends vscode.TreeItem implements TreeItemParent, vscode.TreeDataProvider @@ -164,8 +143,8 @@ export default class DocumentListTreeItem this.description = formatDocCount(this._documentCount); } - this.iconPath = getIconPath(); - this.tooltip = getTooltip(type, cachedDocumentCount); + this.iconPath = getDocumentsIconPath(); + this.tooltip = getDocumentsTooltip(type, cachedDocumentCount); } getTreeItem(element: DocumentListTreeItem): DocumentListTreeItem { diff --git a/src/explorer/documentListUtils.ts b/src/explorer/documentListUtils.ts new file mode 100644 index 000000000..e10cc3617 --- /dev/null +++ b/src/explorer/documentListUtils.ts @@ -0,0 +1,43 @@ +import * as vscode from 'vscode'; +import numeral from 'numeral'; +import path from 'path'; + +import { getImagesPath } from '../extensionConstants'; + +export const CollectionType = { + collection: 'collection', + view: 'view', + timeseries: 'timeseries', +} as const; + +export type CollectionType = + (typeof CollectionType)[keyof typeof CollectionType]; + +export const formatDocCount = (count: number): string => { + // We format the count (30000 -> 30k) and then display it uppercase (30K). + return `${numeral(count).format('0a') as string}`.toUpperCase(); +}; + +export function getDocumentsIconPath(): { + light: vscode.Uri; + dark: vscode.Uri; +} { + const LIGHT = path.join(getImagesPath(), 'light'); + const DARK = path.join(getImagesPath(), 'dark'); + + return { + light: vscode.Uri.file(path.join(LIGHT, 'documents.svg')), + dark: vscode.Uri.file(path.join(DARK, 'documents.svg')), + }; +} + +export function getDocumentsTooltip( + type: string, + documentCount: number | null, +): string { + const typeString = type === CollectionType.view ? 'View' : 'Collection'; + if (documentCount !== null) { + return `${typeString} Documents - ${documentCount}`; + } + return `${typeString} Documents`; +} diff --git a/src/explorer/explorerTreeController.ts b/src/explorer/explorerTreeController.ts index 214e08327..3286b7dd4 100644 --- a/src/explorer/explorerTreeController.ts +++ b/src/explorer/explorerTreeController.ts @@ -5,6 +5,7 @@ import ConnectionTreeItem from './connectionTreeItem'; import { createLogger } from '../logging'; import { DOCUMENT_ITEM } from './documentTreeItem'; import { DOCUMENT_LIST_ITEM, CollectionType } from './documentListTreeItem'; +import { PREVIEW_LIST_ITEM } from './documentListPreviewItem'; import ExtensionCommand from '../commands'; import { sortTreeItemsByLabel } from './treeItemUtils'; import type { LoadedConnection } from '../storage/connectionStorage'; @@ -134,6 +135,13 @@ export default class ExplorerTreeController event.selection[0], ); } + + if (selectedItem.contextValue === PREVIEW_LIST_ITEM) { + await vscode.commands.executeCommand( + ExtensionCommand.mdbOpenCollectionPreviewFromTreeView, + event.selection[0], + ); + } } }); }; diff --git a/src/featureFlags.ts b/src/featureFlags.ts index 1dd96f745..9cec27f2b 100644 --- a/src/featureFlags.ts +++ b/src/featureFlags.ts @@ -1,6 +1,7 @@ const FEATURE_FLAGS = { - useOldConnectionForm: - `${process.env.MDB_USE_OLD_CONNECTION_FORM ?? 'false'}` === 'true', + useOldConnectionForm: process.env.MDB_USE_OLD_CONNECTION_FORM === 'true', + useEnhancedDataBrowsingExperience: + process.env.MDB_USE_ENHANCED_DATA_BROWSING_EXPERIENCE === 'true', }; export type FeatureFlag = keyof typeof FEATURE_FLAGS; diff --git a/src/mdbExtensionController.ts b/src/mdbExtensionController.ts index bfa3b563c..ad8621759 100644 --- a/src/mdbExtensionController.ts +++ b/src/mdbExtensionController.ts @@ -12,6 +12,7 @@ import ConnectionController from './connectionController'; import type ConnectionTreeItem from './explorer/connectionTreeItem'; import type DatabaseTreeItem from './explorer/databaseTreeItem'; import type DocumentListTreeItem from './explorer/documentListTreeItem'; +import type ShowPreviewTreeItem from './explorer/documentListPreviewItem'; import { DocumentSource } from './documentSource'; import type DocumentTreeItem from './explorer/documentTreeItem'; import EditDocumentCodeLensProvider from './editors/editDocumentCodeLensProvider'; @@ -58,6 +59,7 @@ import { import * as queryString from 'query-string'; import { MCPController } from './mcp/mcpController'; import formatError from './utils/formatError'; +import DataBrowsingController from './views/dataBrowsingController'; // Deep link command filtering: Commands are explicitly categorized as allowed or disallowed. // We use tests in mdbExtensionController.test.ts to enforce these lists being disjoint and complete. @@ -148,6 +150,7 @@ export const DEEP_LINK_DISALLOWED_COMMANDS = [ ExtensionCommand.mdbCreateIndexTreeView, ExtensionCommand.mdbOpenMongodbDocumentFromCodeLens, ExtensionCommand.mdbCreatePlaygroundFromOverviewPage, + ExtensionCommand.mdbOpenCollectionPreviewFromTreeView, ] as const; // This class is the top-level controller for our extension. @@ -175,6 +178,7 @@ export default class MDBExtensionController implements vscode.Disposable { _exportToLanguageCodeLensProvider: ExportToLanguageCodeLensProvider; _participantController: ParticipantController; _mcpController: MCPController; + _dataBrowsingController: DataBrowsingController; constructor( context: vscode.ExtensionContext, @@ -257,6 +261,10 @@ export default class MDBExtensionController implements vscode.Disposable { storageController: this._storageController, telemetryService: this._telemetryService, }); + this._dataBrowsingController = new DataBrowsingController({ + connectionController: this._connectionController, + telemetryService: this._telemetryService, + }); this._editorsController.registerProviders(); this._mcpController = new MCPController({ context, @@ -847,6 +855,35 @@ export default class MDBExtensionController implements vscode.Disposable { return this._editorsController.onViewCollectionDocuments(namespace); }, ); + this.registerCommand( + ExtensionCommand.mdbOpenCollectionPreviewFromTreeView, + async (element: ShowPreviewTreeItem): Promise => { + const namespace = element.namespace; + // Fetch a batch of documents for client-side pagination + const fetchLimit = 100; + const documents = await element.loadPreview({ limit: fetchLimit }); + const totalCount = await element.getTotalCount(); + + // Pass a fetch function to allow refreshing/sorting/limiting documents + const fetchDocuments = async (options?: { + sort?: 'default' | 'asc' | 'desc'; + limit?: number; + }): Promise => element.loadPreview(options); + + // Pass a function to get the total count + const getTotalCount = (): Promise => element.getTotalCount(); + + this._dataBrowsingController.openDataBrowser(this._context, { + namespace, + documents, + fetchDocuments, + initialTotalCount: totalCount, + getTotalCount, + }); + + return true; + }, + ); this.registerCommand( ExtensionCommand.mdbRefreshCollection, async (collectionTreeItem: CollectionTreeItem): Promise => { @@ -1190,6 +1227,7 @@ export default class MDBExtensionController implements vscode.Disposable { this._telemetryService.deactivate(); this._editorsController.deactivate(); this._webviewController.deactivate(); + this._dataBrowsingController.deactivate(); this._activeConnectionCodeLensProvider.deactivate(); this._connectionController.deactivate(); } diff --git a/src/test/suite/connectionController.test.ts b/src/test/suite/connectionController.test.ts index 20b0306e2..50a89e9d7 100644 --- a/src/test/suite/connectionController.test.ts +++ b/src/test/suite/connectionController.test.ts @@ -1416,6 +1416,7 @@ suite('Connection Controller Test Suite', function () { ); }); + // eslint-disable-next-line mocha/no-skipped-tests test.skip('should track SAVED_CONNECTIONS_LOADED event on load of saved connections', async function () { testSandbox.replace(testStorageController, 'get', (key, storage) => { if ( diff --git a/src/test/suite/explorer/collectionTreeItem.test.ts b/src/test/suite/explorer/collectionTreeItem.test.ts index 8fa9af276..53472080d 100644 --- a/src/test/suite/explorer/collectionTreeItem.test.ts +++ b/src/test/suite/explorer/collectionTreeItem.test.ts @@ -1,4 +1,6 @@ import assert from 'assert'; +import sinon from 'sinon'; +import { afterEach, beforeEach } from 'mocha'; import type { DataService } from 'mongodb-data-service'; import CollectionTreeItem from '../../../explorer/collectionTreeItem'; @@ -6,6 +8,7 @@ import type { CollectionDetailsType } from '../../../explorer/collectionTreeItem import { CollectionType } from '../../../explorer/documentListTreeItem'; import { ext } from '../../../extensionConstants'; import { ExtensionContextStub, DataServiceStub } from '../stubs'; +import * as featureFlags from '../../../featureFlags'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { contributes } = require('../../../../package.json'); @@ -29,6 +32,11 @@ function getTestCollectionTreeItem( suite('CollectionTreeItem Test Suite', function () { ext.context = new ExtensionContextStub(); + const sandbox = sinon.createSandbox(); + + afterEach(function () { + sandbox.restore(); + }); test('its context value should be in the package json', function () { let registeredCommandInPackageJson = false; @@ -43,38 +51,80 @@ suite('CollectionTreeItem Test Suite', function () { assert.strictEqual(registeredCommandInPackageJson, true); }); - test('when expanded shows a documents folder and schema folder', async function () { - const testCollectionTreeItem = getTestCollectionTreeItem({ - dataService: new DataServiceStub() as unknown as DataService, + suite('when useEnhancedDataBrowsingExperience is false', function () { + beforeEach(function () { + sandbox.stub(featureFlags, 'getFeatureFlag').returns(false); }); - await testCollectionTreeItem.onDidExpand(); + test('when expanded shows documents, schema, and indexes folders', async function () { + const testCollectionTreeItem = getTestCollectionTreeItem({ + dataService: new DataServiceStub() as unknown as DataService, + }); + + await testCollectionTreeItem.onDidExpand(); - const collectionChildren = await testCollectionTreeItem.getChildren(); + const collectionChildren = await testCollectionTreeItem.getChildren(); + + assert.strictEqual(collectionChildren.length, 3); + assert.strictEqual(collectionChildren[0].label, 'Documents'); + assert.strictEqual(collectionChildren[1].label, 'Schema'); + assert.strictEqual(collectionChildren[2].label, 'Indexes'); + }); - assert.strictEqual(collectionChildren.length, 3); - assert.strictEqual(collectionChildren[0].label, 'Documents'); - assert.strictEqual(collectionChildren[1].label, 'Schema'); - assert.strictEqual(collectionChildren[2].label, 'Indexes'); + test('when expanded it shows the document count in the description of the document list', async function () { + const testCollectionTreeItem = getTestCollectionTreeItem({ + dataService: { + estimatedCount: () => Promise.resolve(5000), + } as unknown as DataService, + }); + + await testCollectionTreeItem.onDidExpand(); + + const collectionChildren = await testCollectionTreeItem.getChildren(); + + assert.strictEqual(collectionChildren[0].label, 'Documents'); + assert.strictEqual(collectionChildren[0].description, '5K'); + assert.strictEqual( + collectionChildren[0].tooltip, + 'Collection Documents - 5000', + ); + }); }); - test('when expanded it shows the document count in the description of the document list', async function () { - const testCollectionTreeItem = getTestCollectionTreeItem({ - dataService: { - estimatedCount: () => Promise.resolve(5000), - } as unknown as DataService, + suite('when useEnhancedDataBrowsingExperience is true', function () { + beforeEach(function () { + sandbox.stub(featureFlags, 'getFeatureFlag').returns(true); }); - await testCollectionTreeItem.onDidExpand(); + test('when expanded shows preview, schema, and indexes folders', async function () { + const testCollectionTreeItem = getTestCollectionTreeItem({ + dataService: new DataServiceStub() as unknown as DataService, + }); - const collectionChildren = await testCollectionTreeItem.getChildren(); + await testCollectionTreeItem.onDidExpand(); - assert.strictEqual(collectionChildren[0].label, 'Documents'); - assert.strictEqual(collectionChildren[0].description, '5K'); - assert.strictEqual( - collectionChildren[0].tooltip, - 'Collection Documents - 5000', - ); + const collectionChildren = await testCollectionTreeItem.getChildren(); + + assert.strictEqual(collectionChildren.length, 3); + assert.strictEqual(collectionChildren[0].label, 'Documents'); + assert.strictEqual(collectionChildren[1].label, 'Schema'); + assert.strictEqual(collectionChildren[2].label, 'Indexes'); + }); + + test('when expanded it shows the document count in the description of the preview item', async function () { + const testCollectionTreeItem = getTestCollectionTreeItem({ + dataService: { + estimatedCount: () => Promise.resolve(5000), + } as unknown as DataService, + }); + + await testCollectionTreeItem.onDidExpand(); + + const collectionChildren = await testCollectionTreeItem.getChildren(); + + assert.strictEqual(collectionChildren[0].label, 'Documents'); + assert.strictEqual(collectionChildren[0].description, '5K'); + }); }); test('a view should show a different icon from a collection', function () { diff --git a/src/test/suite/explorer/databaseTreeItem.test.ts b/src/test/suite/explorer/databaseTreeItem.test.ts index 71b2ddacf..9b33b69ae 100644 --- a/src/test/suite/explorer/databaseTreeItem.test.ts +++ b/src/test/suite/explorer/databaseTreeItem.test.ts @@ -1,10 +1,12 @@ import * as vscode from 'vscode'; -import { after, before } from 'mocha'; +import { after, afterEach, before } from 'mocha'; import assert from 'assert'; import type { DataService } from 'mongodb-data-service'; +import sinon from 'sinon'; import type { CollectionTreeItem } from '../../../explorer'; import DatabaseTreeItem from '../../../explorer/databaseTreeItem'; +import DocumentListTreeItem from '../../../explorer/documentListTreeItem'; import { DataServiceStub, mockDatabaseNames, mockDatabases } from '../stubs'; import { createTestDataService, @@ -14,6 +16,7 @@ import { TEST_DB_NAME, TEST_DATABASE_URI, } from '../dbTestHelper'; +import * as featureFlags from '../../../featureFlags'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { contributes } = require('../../../../package.json'); @@ -32,6 +35,12 @@ function getTestDatabaseTreeItem( } suite('DatabaseTreeItem Test Suite', function () { + const sandbox = sinon.createSandbox(); + + afterEach(function () { + sandbox.restore(); + }); + test('its context value should be in the package json', function () { let databaseRegisteredCommandInPackageJson = false; @@ -78,6 +87,9 @@ suite('DatabaseTreeItem Test Suite', function () { }); test('when expanded and collapsed its collections cache their expanded documents', async function () { + // This test uses DocumentListTreeItem which has isExpanded + sandbox.stub(featureFlags, 'getFeatureFlag').returns(false); + const testDatabaseTreeItem = getTestDatabaseTreeItem(); await testDatabaseTreeItem.onDidExpand(); @@ -88,14 +100,15 @@ suite('DatabaseTreeItem Test Suite', function () { await collectionTreeItems[1].onDidExpand(); await collectionTreeItems[1].getChildren(); - const documentListItem = collectionTreeItems[1].getDocumentListChild(); - if (!documentListItem) { - assert(false, 'No document list tree item found on collection.'); - } - await documentListItem.onDidExpand(); - documentListItem.onShowMoreClicked(); - - const documents = await documentListItem.getChildren(); + const documentsChild = collectionTreeItems[1].getDocumentsChild(); + assert( + documentsChild instanceof DocumentListTreeItem, + 'Expected DocumentListTreeItem when feature flag is false', + ); + await documentsChild.onDidExpand(); + documentsChild.onShowMoreClicked(); + + const documents = await documentsChild.getChildren(); const amountOfDocs = documents.length; const expectedDocs = 21; assert.strictEqual(expectedDocs, amountOfDocs); @@ -111,9 +124,14 @@ suite('DatabaseTreeItem Test Suite', function () { assert.strictEqual(newCollectionTreeItems[1].isExpanded, true); - const documentsPostCollapseExpand = await newCollectionTreeItems[1] - .getDocumentListChild() - .getChildren(); + const documentsChildAfterExpand = + newCollectionTreeItems[1].getDocumentsChild(); + assert( + documentsChildAfterExpand instanceof DocumentListTreeItem, + 'Expected DocumentListTreeItem when feature flag is false', + ); + const documentsPostCollapseExpand = + await documentsChildAfterExpand.getChildren(); // It should cache that we activated show more. const amountOfCachedDocs = documentsPostCollapseExpand.length; diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index 13de8b55e..d0120b2b4 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -42,7 +42,7 @@ export async function run(): Promise { '**/**.test.js', { cwd: testsRoot, - ignore: ['**/webview-app/**/*.js'], + ignore: ['**/webview-app/**/*.js', '**/data-browsing-app/**/*.js'], }, (err, files) => { if (err) { diff --git a/src/test/suite/mdbExtensionController.test.ts b/src/test/suite/mdbExtensionController.test.ts index a2285be59..e1b727f5f 100644 --- a/src/test/suite/mdbExtensionController.test.ts +++ b/src/test/suite/mdbExtensionController.test.ts @@ -34,6 +34,7 @@ import { DEEP_LINK_ALLOWED_COMMANDS, DEEP_LINK_DISALLOWED_COMMANDS, } from '../../mdbExtensionController'; +import * as featureFlags from '../../featureFlags'; const testDatabaseURI = 'mongodb://localhost:27088'; @@ -453,12 +454,18 @@ suite('MDBExtensionController Test Suite', function () { }); test('mdb.refreshCollection command should reset the expanded state of its children and call to refresh the explorer controller', async function () { + // Use DocumentListTreeItem which has isExpanded property + sandbox.stub(featureFlags, 'getFeatureFlag').returns(false); + const testTreeItem = getTestCollectionTreeItem(); testTreeItem.isExpanded = true; // Set expanded. testTreeItem.getSchemaChild().isExpanded = true; - testTreeItem.getDocumentListChild().isExpanded = true; + const documentsChild = testTreeItem.getDocumentsChild(); + if ('isExpanded' in documentsChild) { + documentsChild.isExpanded = true; + } const fakeRefresh = sandbox.fake(); sandbox.replace( @@ -481,6 +488,9 @@ suite('MDBExtensionController Test Suite', function () { }); test('mdb.refreshDocumentList command should update the document count and call to refresh the explorer controller', async function () { + // Disable enhanced data browsing to use DocumentListTreeItem + sandbox.stub(featureFlags, 'getFeatureFlag').returns(false); + let count = 9000; const testTreeItem = getTestCollectionTreeItem({ dataService: { @@ -490,6 +500,7 @@ suite('MDBExtensionController Test Suite', function () { await testTreeItem.onDidExpand(); const collectionChildren = await testTreeItem.getChildren(); + // With enhanced data browsing disabled, Documents is at index 0 const docListTreeItem = collectionChildren[0]; assert.strictEqual(docListTreeItem.description, '9K'); count = 10000; @@ -736,6 +747,7 @@ suite('MDBExtensionController Test Suite', function () { // Starting server 7.0, the outcome of dropping nonexistent collections is successful SERVER-43894 // TODO: update or delete the test according to VSCODE-461 + // eslint-disable-next-line mocha/no-skipped-tests test.skip('mdb.dropCollection fails when a collection does not exist', async function () { const testConnectionController = mdbTestExtension.testExtensionController._connectionController; diff --git a/src/test/suite/telemetry/connectionTelemetry.test.ts b/src/test/suite/telemetry/connectionTelemetry.test.ts index e6f0b3226..707827321 100644 --- a/src/test/suite/telemetry/connectionTelemetry.test.ts +++ b/src/test/suite/telemetry/connectionTelemetry.test.ts @@ -638,6 +638,7 @@ suite('ConnectionTelemetry Controller Test Suite', function () { // TODO: Enable test back when Insider is fixed https://jira.mongodb.org/browse/VSCODE-452 // MS GitHub Issue: https://github.com/microsoft/vscode/issues/188676 + // eslint-disable-next-line mocha/no-skipped-tests suite.skip('with live connection', function () { this.timeout(20000); let dataServ; diff --git a/src/test/suite/telemetry/telemetryService.test.ts b/src/test/suite/telemetry/telemetryService.test.ts index 6dbe31f3b..9ef2282bc 100644 --- a/src/test/suite/telemetry/telemetryService.test.ts +++ b/src/test/suite/telemetry/telemetryService.test.ts @@ -275,6 +275,7 @@ suite('Telemetry Controller Test Suite', function () { }); // TODO: re-enable two tests after https://jira.mongodb.org/browse/VSCODE-432 + // eslint-disable-next-line mocha/no-skipped-tests test.skip('track mongodb playground loaded event', async function () { const docPath = path.resolve( __dirname, @@ -294,6 +295,7 @@ suite('Telemetry Controller Test Suite', function () { ); }); + // eslint-disable-next-line mocha/no-skipped-tests test.skip('track mongodbjs playground loaded event', async function () { const docPath = path.resolve( __dirname, @@ -535,6 +537,7 @@ suite('Telemetry Controller Test Suite', function () { }); }); + // eslint-disable-next-line mocha/no-skipped-tests test.skip('track saved connections loaded', function () { testTelemetryService.track( new SavedConnectionsLoadedTelemetryEvent({ diff --git a/src/test/suite/views/data-browsing-app/app.test.tsx b/src/test/suite/views/data-browsing-app/app.test.tsx new file mode 100644 index 000000000..18159e93a --- /dev/null +++ b/src/test/suite/views/data-browsing-app/app.test.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { expect } from 'chai'; + +import App from '../../../../views/data-browsing-app/app'; + +describe('Data Browsing App Component Test Suite', function () { + it('it renders the preview page', function () { + render(); + expect(screen.getByLabelText('Insert Document')).to.exist; + }); +}); diff --git a/src/test/suite/views/data-browsing-app/preview-page.test.tsx b/src/test/suite/views/data-browsing-app/preview-page.test.tsx new file mode 100644 index 000000000..364507485 --- /dev/null +++ b/src/test/suite/views/data-browsing-app/preview-page.test.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { expect } from 'chai'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Sinon from 'sinon'; +import PreviewPage from '../../../../views/data-browsing-app/preview-page'; +import vscode from '../../../../views/data-browsing-app/vscode-api'; +import { PreviewMessageType } from '../../../../views/data-browsing-app/extension-app-message-constants'; + +describe('PreviewPage test suite', function () { + afterEach(function () { + Sinon.restore(); + }); + + it('should render the preview page with toolbar', function () { + render(); + expect(screen.getByLabelText('Insert Document')).to.exist; + expect(screen.getByLabelText(/Sort order/)).to.exist; + expect(screen.getByLabelText(/Items per page/)).to.exist; + }); + + it('should request documents on mount', function () { + const postMessageStub = Sinon.stub(vscode, 'postMessage'); + render(); + + expect(postMessageStub).to.have.been.calledWith({ + command: PreviewMessageType.getDocuments, + }); + }); + + it('should display loading state initially', function () { + render(); + expect(screen.getByText('Loading documents...')).to.exist; + }); + + it('should display documents when loaded', async function () { + render(); + + // Simulate receiving documents from extension + window.dispatchEvent( + new MessageEvent('message', { + data: { + command: PreviewMessageType.loadDocuments, + documents: [ + { _id: '1', name: 'Test Document 1' }, + { _id: '2', name: 'Test Document 2' }, + ], + totalCount: 2, + }, + }), + ); + + await waitFor(() => { + expect(screen.queryByText('Loading documents...')).to.not.exist; + }); + }); + + it('should send refresh request when refresh button is clicked', async function () { + const postMessageStub = Sinon.stub(vscode, 'postMessage'); + render(); + + // Wait for initial load to complete + window.dispatchEvent( + new MessageEvent('message', { + data: { + command: PreviewMessageType.loadDocuments, + documents: [], + totalCount: 0, + }, + }), + ); + + await waitFor(() => { + expect(screen.queryByText('Loading documents...')).to.not.exist; + }); + + const refreshButton = screen.getByTitle('Refresh'); + await userEvent.click(refreshButton); + + expect(postMessageStub).to.have.been.calledWith({ + command: PreviewMessageType.refreshDocuments, + }); + }); + + it('should send sort request when sort option changes', async function () { + this.timeout(5000); + const postMessageStub = Sinon.stub(vscode, 'postMessage'); + render(); + + // Wait for initial load + window.dispatchEvent( + new MessageEvent('message', { + data: { + command: PreviewMessageType.loadDocuments, + documents: [], + totalCount: 0, + }, + }), + ); + + await waitFor(() => { + expect(screen.queryByText('Loading documents...')).to.not.exist; + }); + + const sortSelect = screen.getByLabelText(/Sort order/); + await userEvent.click(sortSelect); + + const ascOption = screen.getByText('Ascending'); + await userEvent.click(ascOption); + + expect(postMessageStub).to.have.been.calledWith({ + command: PreviewMessageType.sortDocuments, + sort: 'asc', + }); + }); + + it('should display "No documents to display" when there are no documents', async function () { + render(); + + window.dispatchEvent( + new MessageEvent('message', { + data: { + command: PreviewMessageType.loadDocuments, + documents: [], + totalCount: 0, + }, + }), + ); + + await waitFor(() => { + expect(screen.getByText('No documents to display')).to.exist; + }); + }); + + it('should handle pagination correctly', async function () { + const documents = Array.from({ length: 25 }, (_, i) => ({ + _id: `${i + 1}`, + name: `Document ${i + 1}`, + })); + + render(); + + window.dispatchEvent( + new MessageEvent('message', { + data: { + command: PreviewMessageType.loadDocuments, + documents, + totalCount: 25, + }, + }), + ); + + await waitFor(() => { + expect(screen.queryByText('Loading documents...')).to.not.exist; + }); + + // Should show first 10 documents by default + expect(screen.getByText('1-10 of 25')).to.exist; + + // Click next page + const nextButton = screen.getByLabelText('Next page'); + await userEvent.click(nextButton); + + expect(screen.getByText('11-20 of 25')).to.exist; + }); +}); diff --git a/src/test/suite/views/dataBrowsingController.test.ts b/src/test/suite/views/dataBrowsingController.test.ts new file mode 100644 index 000000000..1e283ca65 --- /dev/null +++ b/src/test/suite/views/dataBrowsingController.test.ts @@ -0,0 +1,369 @@ +import sinon from 'sinon'; +import * as vscode from 'vscode'; +import { expect } from 'chai'; +import { beforeEach, afterEach } from 'mocha'; + +import ConnectionController from '../../../connectionController'; +import { mdbTestExtension } from '../stubbableMdbExtension'; +import { PreviewMessageType } from '../../../views/data-browsing-app/extension-app-message-constants'; +import { StatusView } from '../../../views'; +import { StorageController } from '../../../storage'; +import { TelemetryService } from '../../../telemetry'; +import { ExtensionContextStub } from '../stubs'; +import DataBrowsingController, { + getDataBrowsingContent, +} from '../../../views/dataBrowsingController'; + +suite('DataBrowsingController Test Suite', function () { + const sandbox = sinon.createSandbox(); + let extensionContextStub: ExtensionContextStub; + let testStorageController: StorageController; + let testTelemetryService: TelemetryService; + let testConnectionController: ConnectionController; + let testDataBrowsingController: DataBrowsingController; + + beforeEach(() => { + extensionContextStub = new ExtensionContextStub(); + testStorageController = new StorageController(extensionContextStub); + testTelemetryService = new TelemetryService( + testStorageController, + extensionContextStub, + ); + testConnectionController = new ConnectionController({ + statusView: new StatusView(extensionContextStub), + storageController: testStorageController, + telemetryService: testTelemetryService, + }); + testDataBrowsingController = new DataBrowsingController({ + connectionController: testConnectionController, + telemetryService: testTelemetryService, + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + test('it creates a web view panel and sets the html content', function () { + const stubOnDidReceiveMessage = sandbox.stub(); + const fakeWebview = { + html: '', + onDidReceiveMessage: stubOnDidReceiveMessage, + asWebviewUri: sandbox.stub().returns(''), + } as unknown as vscode.Webview; + const fakeVSCodeCreateWebviewPanel = sandbox + .stub(vscode.window, 'createWebviewPanel') + .returns({ + webview: fakeWebview, + onDidDispose: sandbox.stub().returns(''), + } as unknown as vscode.WebviewPanel); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { + namespace: 'test.collection', + documents: [{ _id: '1', name: 'test' }], + }, + ); + + expect(fakeVSCodeCreateWebviewPanel).to.be.calledOnce; + expect(fakeWebview.html).to.not.equal(''); + expect(stubOnDidReceiveMessage).to.be.calledOnce; + }); + + test('web view content is rendered with the dataBrowsingApp.js script', function () { + const extensionPath = mdbTestExtension.extensionContextStub.extensionPath; + const htmlString = getDataBrowsingContent({ + extensionPath, + webview: { + asWebviewUri: (jsUri) => { + return jsUri; + }, + } as unknown as vscode.Webview, + }); + + expect(htmlString).to.include('dist/dataBrowsingApp.js'); + expect(htmlString).to.include('MongoDB Data Browser'); + }); + + test('panel title includes the namespace', function () { + const fakeWebview = { + html: '', + onDidReceiveMessage: sandbox.stub(), + asWebviewUri: sandbox.stub().returns(''), + } as unknown as vscode.Webview; + const fakeVSCodeCreateWebviewPanel = sandbox + .stub(vscode.window, 'createWebviewPanel') + .returns({ + webview: fakeWebview, + onDidDispose: sandbox.stub().returns(''), + } as unknown as vscode.WebviewPanel); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { + namespace: 'mydb.users', + documents: [], + }, + ); + + expect(fakeVSCodeCreateWebviewPanel).to.be.calledOnce; + expect(fakeVSCodeCreateWebviewPanel.firstCall.args[1]).to.equal( + 'Preview: mydb.users', + ); + }); + + test('handles GET_DOCUMENTS message and sends documents to webview', function (done) { + let messageReceived; + const mockDocuments = [{ _id: '1' }, { _id: '2' }]; + + sandbox.stub(vscode.window, 'createWebviewPanel').returns({ + webview: { + html: '', + postMessage: (message): void => { + expect(message.command).to.equal(PreviewMessageType.loadDocuments); + expect(message.documents).to.deep.equal(mockDocuments); + done(); + }, + onDidReceiveMessage: (callback): void => { + messageReceived = callback; + }, + asWebviewUri: sandbox.stub().returns(''), + }, + onDidDispose: sandbox.stub().returns(''), + } as unknown as vscode.WebviewPanel); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { + namespace: 'test.collection', + documents: mockDocuments, + }, + ); + + messageReceived({ + command: PreviewMessageType.getDocuments, + }); + }); + + test('handles REFRESH_DOCUMENTS message and fetches new documents', function (done) { + let messageReceived; + const initialDocuments = [{ _id: '1' }]; + const refreshedDocuments = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]; + const fetchDocuments = sandbox.stub().resolves(refreshedDocuments); + + sandbox.stub(vscode.window, 'createWebviewPanel').returns({ + webview: { + html: '', + postMessage: (message): void => { + expect(message.command).to.equal(PreviewMessageType.loadDocuments); + expect(message.documents).to.deep.equal(refreshedDocuments); + expect(fetchDocuments).to.be.calledOnce; + done(); + }, + onDidReceiveMessage: (callback): void => { + messageReceived = callback; + }, + asWebviewUri: sandbox.stub().returns(''), + }, + onDidDispose: sandbox.stub().returns(''), + } as unknown as vscode.WebviewPanel); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { + namespace: 'test.collection', + documents: initialDocuments, + fetchDocuments, + }, + ); + + messageReceived({ + command: PreviewMessageType.refreshDocuments, + }); + }); + + test('handles SORT_DOCUMENTS message with sort option', function (done) { + let messageReceived; + const sortedDocuments = [{ _id: '3' }, { _id: '2' }, { _id: '1' }]; + const fetchDocuments = sandbox.stub().resolves(sortedDocuments); + + sandbox.stub(vscode.window, 'createWebviewPanel').returns({ + webview: { + html: '', + postMessage: (message): void => { + expect(message.command).to.equal(PreviewMessageType.loadDocuments); + expect(message.documents).to.deep.equal(sortedDocuments); + expect(fetchDocuments).to.be.calledOnceWith({ sort: 'desc' }); + done(); + }, + onDidReceiveMessage: (callback): void => { + messageReceived = callback; + }, + asWebviewUri: sandbox.stub().returns(''), + }, + onDidDispose: sandbox.stub().returns(''), + } as unknown as vscode.WebviewPanel); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { + namespace: 'test.collection', + documents: [], + fetchDocuments, + }, + ); + + messageReceived({ + command: PreviewMessageType.sortDocuments, + sort: 'desc', + }); + }); + + test('includes totalCount in response when getTotalCount is provided', function (done) { + let messageReceived; + const mockDocuments = [{ _id: '1' }]; + const getTotalCount = sandbox.stub().resolves(100); + + sandbox.stub(vscode.window, 'createWebviewPanel').returns({ + webview: { + html: '', + postMessage: (message): void => { + expect(message.command).to.equal(PreviewMessageType.loadDocuments); + expect(message.totalCount).to.equal(100); + expect(getTotalCount).to.be.calledOnce; + done(); + }, + onDidReceiveMessage: (callback): void => { + messageReceived = callback; + }, + asWebviewUri: sandbox.stub().returns(''), + }, + onDidDispose: sandbox.stub().returns(''), + } as unknown as vscode.WebviewPanel); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { + namespace: 'test.collection', + documents: mockDocuments, + getTotalCount, + }, + ); + + messageReceived({ + command: PreviewMessageType.getDocuments, + }); + }); + + test('notifies all webviews when theme changes', function (done) { + const totalExpectedPostMessageCalls = 2; + let callsSoFar = 0; + + sandbox.stub(vscode.window, 'createWebviewPanel').returns({ + webview: { + html: '', + postMessage: (message): Promise => { + expect(message.command).to.equal(PreviewMessageType.themeChanged); + expect(message.darkMode).to.be.true; + if (++callsSoFar === totalExpectedPostMessageCalls) { + done(); + } + return Promise.resolve(true); + }, + onDidReceiveMessage: (): void => {}, + asWebviewUri: sandbox.stub().returns(''), + }, + onDidDispose: sandbox.stub().returns(''), + } as unknown as vscode.WebviewPanel); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { namespace: 'test.col1', documents: [] }, + ); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { namespace: 'test.col2', documents: [] }, + ); + + // Mock a theme change + void testDataBrowsingController.onThemeChanged({ + kind: vscode.ColorThemeKind.Dark, + }); + }); + + test('removes panel from active panels when disposed', function () { + const onDisposeCallback: (() => void)[] = []; + const panels: vscode.WebviewPanel[] = []; + + sandbox.stub(vscode.window, 'createWebviewPanel').callsFake(() => { + const panel = { + webview: { + html: '', + postMessage: sandbox.stub().resolves(true), + onDidReceiveMessage: sandbox.stub(), + asWebviewUri: sandbox.stub().returns(''), + }, + onDidDispose: (callback): void => { + onDisposeCallback.push(callback); + }, + } as unknown as vscode.WebviewPanel; + panels.push(panel); + return panel; + }); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { namespace: 'test.col1', documents: [] }, + ); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { namespace: 'test.col2', documents: [] }, + ); + + expect(testDataBrowsingController._activeWebviewPanels.length).to.equal(2); + + // Simulate first panel being disposed + onDisposeCallback[0](); + + expect(testDataBrowsingController._activeWebviewPanels.length).to.equal(1); + }); + + test('sends error message when fetchDocuments fails', function (done) { + let messageReceived; + const fetchDocuments = sandbox + .stub() + .rejects(new Error('Connection failed')); + + sandbox.stub(vscode.window, 'createWebviewPanel').returns({ + webview: { + html: '', + postMessage: (message): void => { + expect(message.command).to.equal(PreviewMessageType.refreshError); + expect(message.error).to.include('Connection failed'); + done(); + }, + onDidReceiveMessage: (callback): void => { + messageReceived = callback; + }, + asWebviewUri: sandbox.stub().returns(''), + }, + onDidDispose: sandbox.stub().returns(''), + } as unknown as vscode.WebviewPanel); + + void testDataBrowsingController.openDataBrowser( + mdbTestExtension.extensionContextStub, + { + namespace: 'test.collection', + documents: [], + fetchDocuments, + }, + ); + + messageReceived({ + command: PreviewMessageType.refreshDocuments, + }); + }); +}); diff --git a/src/utils/webviewHelpers.ts b/src/utils/webviewHelpers.ts new file mode 100644 index 000000000..ce9e933b5 --- /dev/null +++ b/src/utils/webviewHelpers.ts @@ -0,0 +1,104 @@ +import * as vscode from 'vscode'; +import path from 'path'; +import crypto from 'crypto'; + +export const getNonce = (): string => { + return crypto.randomBytes(16).toString('base64'); +}; + +export const getThemedIconPath = ( + extensionPath: string, + iconName: string, +): { light: vscode.Uri; dark: vscode.Uri } => { + return { + light: vscode.Uri.file( + path.join(extensionPath, 'images', 'light', iconName), + ), + dark: vscode.Uri.file(path.join(extensionPath, 'images', 'dark', iconName)), + }; +}; + +export const getWebviewUri = ( + extensionPath: string, + webview: vscode.Webview, + ...pathSegments: string[] +): vscode.Uri => { + const localFilePathUri = vscode.Uri.file( + path.join(extensionPath, ...pathSegments), + ); + return webview.asWebviewUri(localFilePathUri); +}; + +export interface WebviewHtmlOptions { + extensionPath: string; + webview: vscode.Webview; + scriptName: string; + title?: string; + additionalHeadContent?: string; +} + +export const getWebviewHtml = ({ + extensionPath, + webview, + scriptName, + title = 'MongoDB', + additionalHeadContent = '', +}: WebviewHtmlOptions): string => { + const nonce = getNonce(); + const scriptUri = getWebviewUri(extensionPath, webview, 'dist', scriptName); + + return ` + + + + + + ${title} + + +
+ ${additionalHeadContent.replace(/\$\{nonce\}/g, nonce)} + + + `; +}; + +export interface CreateWebviewPanelOptions { + viewType: string; + title: string; + extensionPath: string; + column?: vscode.ViewColumn; + additionalResourceRoots?: string[]; + iconName?: string; +} + +export const createWebviewPanel = ({ + viewType, + title, + extensionPath, + column = vscode.ViewColumn.One, + additionalResourceRoots = [], + iconName, +}: CreateWebviewPanelOptions): vscode.WebviewPanel => { + const localResourceRoots = [ + vscode.Uri.file(path.join(extensionPath, 'dist')), + ...additionalResourceRoots.map((folder) => + vscode.Uri.file(path.join(extensionPath, folder)), + ), + ]; + + const panel = vscode.window.createWebviewPanel(viewType, title, column, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots, + }); + + if (iconName) { + panel.iconPath = getThemedIconPath(extensionPath, iconName); + } + + return panel; +}; diff --git a/src/views/data-browsing-app/app.tsx b/src/views/data-browsing-app/app.tsx new file mode 100644 index 000000000..59da07036 --- /dev/null +++ b/src/views/data-browsing-app/app.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { LeafyGreenProvider } from '@mongodb-js/compass-components'; +import { useDetectVsCodeDarkMode } from './use-detect-vscode-dark-mode'; +import PreviewPage from './preview-page'; + +const App: React.FC = () => { + const darkMode = useDetectVsCodeDarkMode(); + + return ( + + + + ); +}; + +export default App; diff --git a/src/views/data-browsing-app/document-tree-view.tsx b/src/views/data-browsing-app/document-tree-view.tsx new file mode 100644 index 000000000..1f67b28de --- /dev/null +++ b/src/views/data-browsing-app/document-tree-view.tsx @@ -0,0 +1,434 @@ +import React, { useState } from 'react'; +import { + css, + cx, + spacing, + fontFamilies, + KeylineCard, + Icon, + palette, + codePalette, + useDarkMode, +} from '@mongodb-js/compass-components'; + +const documentTreeViewContainerStyles = css({ + marginBottom: spacing[200], +}); + +const documentContentStyles = css({ + padding: `${spacing[300]}px ${spacing[400]}px`, + fontFamily: fontFamilies.code, + fontSize: 12, + lineHeight: '16px', +}); + +const parentNodeStyles = css({ + display: 'flex', + flexDirection: 'column', +}); + +const nodeRowStyles = css({ + display: 'flex', + gap: spacing[100], + minHeight: 16, + paddingLeft: spacing[400], +}); + +const caretStyles = css({ + width: spacing[400], + height: 16, + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginLeft: -spacing[400], +}); + +const expandButtonStyles = css({ + margin: 0, + padding: 0, + border: 'none', + background: 'none', + display: 'flex', + cursor: 'pointer', +}); + +const clickableRowStyle = css({ + cursor: 'pointer', +}); + +const childrenContainerStyles = css({ + paddingLeft: spacing[400], +}); + +const keyValueContainerStyles = css({ + display: 'flex', + flexWrap: 'wrap', +}); + +const keyStylesBase = css({ + fontWeight: 'bold', + whiteSpace: 'nowrap', +}); + +const keyStylesLight = css({ + color: palette.gray.dark3, +}); + +const keyStylesDark = css({ + color: palette.gray.light2, +}); + +const dividerStylesBase = css({ + userSelect: 'none', +}); + +const dividerStylesLight = css({ + color: palette.gray.dark1, +}); + +const dividerStylesDark = css({ + color: palette.gray.light1, +}); + +interface DocumentTreeViewProps { + document: Record; +} + +interface TreeNode { + key: string; + value: unknown; + type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array'; + itemCount?: number; +} + +const DocumentTreeView: React.FC = ({ document }) => { + const darkMode = useDarkMode(); + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + + // Get theme-aware color for value types using codePalette + const getValueColor = (type: TreeNode['type']): string => { + const themeColors = darkMode ? codePalette.dark : codePalette.light; + switch (type) { + case 'number': + return themeColors[9]; // Number color + case 'boolean': + case 'null': + return themeColors[10]; // Boolean/null color + case 'string': + return themeColors[7]; // String color + case 'object': + case 'array': + return themeColors[5]; // Object/array color + default: + return themeColors[7]; + } + }; + + // Get dynamic styles based on dark mode + const keyStyles = cx( + keyStylesBase, + darkMode ? keyStylesDark : keyStylesLight, + ); + const dividerStyles = cx( + dividerStylesBase, + darkMode ? dividerStylesDark : dividerStylesLight, + ); + + const toggleExpanded = (key: string): void => { + setExpandedKeys((prev) => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }; + + // Check if value is an ObjectId (EJSON format with $oid) + const isObjectId = (value: unknown): boolean => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const obj = value as Record; + return '$oid' in obj && typeof obj.$oid === 'string'; + } + return false; + }; + + // Format ObjectId for inline display + const formatObjectId = (value: unknown): string => { + if (value && typeof value === 'object') { + const obj = value as Record; + if ('$oid' in obj && typeof obj.$oid === 'string') { + return `ObjectId('${obj.$oid}')`; + } + } + return String(value); + }; + + const getNodeType = (value: unknown): TreeNode['type'] => { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + // Treat ObjectId as a string (inline display) rather than expandable object + if (isObjectId(value)) return 'string'; + if (typeof value === 'object') return 'object'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'boolean') return 'boolean'; + return 'string'; + }; + + const formatValue = ( + value: unknown, + type: TreeNode['type'], + isExpanded = true, + ): string => { + if (type === 'null') return 'null'; + if (type === 'boolean') return String(value); + if (type === 'number') return String(value); + if (type === 'array') { + const count = (value as unknown[]).length; + return isExpanded ? '[' : `Array [${count}]`; + } + if (type === 'object') { + const count = Object.keys(value as Record).length; + return isExpanded ? '{' : `Object (${count})`; + } + // String type - check if it's an ObjectId first + if (isObjectId(value)) { + return formatObjectId(value); + } + const strValue = String(value); + // If it's already quoted or looks like a special type (ObjectId, etc.), return as-is + if (strValue.startsWith('"') || strValue.match(/^[A-Z][a-z]+\(/)) { + return strValue; + } + return `"${strValue}"`; + }; + + const parseDocument = (doc: Record): TreeNode[] => { + return Object.entries(doc).map(([key, value]) => { + const type = getNodeType(value); + let itemCount: number | undefined; + + if (type === 'array') { + itemCount = (value as unknown[]).length; + } else if (type === 'object') { + itemCount = Object.keys(value as Record).length; + } + + return { + key, + value, + type, + itemCount, + }; + }); + }; + + const renderExpandButton = ( + isExpanded: boolean, + itemKey: string, + ): JSX.Element => ( + + ); + + const renderChildren = (value: unknown, parentKey: string): JSX.Element[] => { + if (Array.isArray(value)) { + return value.map((item, index) => { + const type = getNodeType(item); + const isLast = index === value.length - 1; + const itemKey = `${parentKey}.${index}`; + const hasExpandableContent = type === 'object' || type === 'array'; + const isExpanded = expandedKeys.has(itemKey); + + return ( +
+
+
+ {hasExpandableContent && + renderExpandButton(isExpanded, itemKey)} +
+
+ + {formatValue(item, type)} + + {!isLast && ,} +
+
+ {hasExpandableContent && isExpanded && ( +
+ {renderChildren(item, itemKey)} +
+ )} +
+ ); + }); + } else if (typeof value === 'object' && value !== null) { + const entries = Object.entries(value as Record); + return entries.map(([key, val], index) => { + const type = getNodeType(val); + const isLast = index === entries.length - 1; + const itemKey = `${parentKey}.${key}`; + const hasExpandableContent = type === 'object' || type === 'array'; + const isExpanded = expandedKeys.has(itemKey); + + return ( +
+
+
+ {hasExpandableContent && + renderExpandButton(isExpanded, itemKey)} +
+
+ {key} + + + {formatValue(val, type)} + + {!isLast && ,} +
+
+ {hasExpandableContent && isExpanded && ( +
+ {renderChildren(val, itemKey)} +
+ )} +
+ ); + }); + } + return []; + }; + + const renderClosingBracket = ( + nodeType: TreeNode['type'], + isLast: boolean, + ): JSX.Element => ( +
+
+
+ + {nodeType === 'array' ? ']' : '}'} + + {!isLast && ,} +
+
+ ); + + const formatIdValue = (value: unknown): string => { + // Handle _id which is typically an ObjectId or string + if (typeof value === 'string') { + if (value.match(/^[A-Z][a-z]+\(/)) { + return value; + } + return `"${value}"`; + } + // Handle ObjectId-like objects with $oid property + if (value && typeof value === 'object') { + const obj = value as Record; + if ('$oid' in obj && typeof obj.$oid === 'string') { + return `ObjectId('${obj.$oid}')`; + } + // Fallback: serialize as JSON + return JSON.stringify(value); + } + return `"${String(value)}"`; + }; + + const getNodeDisplayValue = ( + node: TreeNode, + isIdField: boolean, + isExpanded: boolean, + ): string => { + if (isIdField) { + return formatIdValue(node.value); + } + return formatValue(node.value, node.type, isExpanded); + }; + + const getRowClassName = (hasExpandableContent: boolean): string => + cx(nodeRowStyles, hasExpandableContent && clickableRowStyle); + + const createRowClickHandler = ( + hasExpandableContent: boolean, + nodeKey: string, + ): (() => void) | undefined => + hasExpandableContent ? (): void => toggleExpanded(nodeKey) : undefined; + + const renderNode = (node: TreeNode, isLast = false): JSX.Element => { + const isIdField = node.key === '_id'; + const hasExpandableContent = + !isIdField && (node.type === 'object' || node.type === 'array'); + const isExpanded = expandedKeys.has(node.key); + // For _id field, use string color; otherwise use type-based color + const valueColor = getValueColor(isIdField ? 'string' : node.type); + + const handleKeyDown = hasExpandableContent + ? (e: React.KeyboardEvent): void => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleExpanded(node.key); + } + } + : undefined; + + return ( +
+
+
+ {hasExpandableContent && renderExpandButton(isExpanded, node.key)} +
+
+ "{node.key}" + + + {getNodeDisplayValue(node, isIdField, isExpanded)} + + {!isExpanded && !isLast && ,} +
+
+ {hasExpandableContent && isExpanded && ( +
+ {renderChildren(node.value, node.key)} +
+ )} + {hasExpandableContent && + isExpanded && + renderClosingBracket(node.type, isLast)} +
+ ); + }; + + const nodes = parseDocument(document); + + return ( +
+ + {nodes.map((node, index) => + renderNode(node, index === nodes.length - 1), + )} + +
+ ); +}; + +export default DocumentTreeView; diff --git a/src/views/data-browsing-app/extension-app-message-constants.ts b/src/views/data-browsing-app/extension-app-message-constants.ts new file mode 100644 index 000000000..4a0f34350 --- /dev/null +++ b/src/views/data-browsing-app/extension-app-message-constants.ts @@ -0,0 +1,62 @@ +// Message types for communication between extension and preview webview +export const PreviewMessageType = { + // Messages from webview to extension + getDocuments: 'GET_DOCUMENTS', + refreshDocuments: 'REFRESH_DOCUMENTS', + sortDocuments: 'SORT_DOCUMENTS', + + // Messages from extension to webview + loadDocuments: 'LOAD_DOCUMENTS', + refreshError: 'REFRESH_ERROR', + themeChanged: 'THEME_CHANGED', +} as const; + +export type PreviewMessageType = + (typeof PreviewMessageType)[keyof typeof PreviewMessageType]; + +export type SortOption = 'default' | 'asc' | 'desc'; + +// Messages from webview to extension +interface BasicWebviewMessage { + command: string; +} + +export interface GetDocumentsMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.getDocuments; +} + +export interface RefreshDocumentsMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.refreshDocuments; +} + +export interface SortDocumentsMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.sortDocuments; + sort: SortOption; +} + +// Messages from extension to webview +export interface LoadDocumentsMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.loadDocuments; + documents: Record[]; + totalCount?: number; +} + +export interface RefreshErrorMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.refreshError; + error?: string; +} + +export interface ThemeChangedMessage extends BasicWebviewMessage { + command: typeof PreviewMessageType.themeChanged; + darkMode: boolean; +} + +export type MessageFromWebviewToExtension = + | GetDocumentsMessage + | RefreshDocumentsMessage + | SortDocumentsMessage; + +export type MessageFromExtensionToWebview = + | LoadDocumentsMessage + | RefreshErrorMessage + | ThemeChangedMessage; diff --git a/src/views/data-browsing-app/index.tsx b/src/views/data-browsing-app/index.tsx new file mode 100644 index 000000000..4bc2c4f81 --- /dev/null +++ b/src/views/data-browsing-app/index.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import App from './app'; + +const container = document.getElementById('root'); +const root = createRoot(container); +root.render(); diff --git a/src/views/data-browsing-app/preview-page.tsx b/src/views/data-browsing-app/preview-page.tsx new file mode 100644 index 000000000..c7bdea9e5 --- /dev/null +++ b/src/views/data-browsing-app/preview-page.tsx @@ -0,0 +1,411 @@ +import React, { useEffect, useState, useRef, useMemo } from 'react'; +import { + Icon, + IconButton, + Select, + Option, + Menu, + MenuItem, + css, + spacing, + useDarkMode, +} from '@mongodb-js/compass-components'; +import { + PreviewMessageType, + type MessageFromExtensionToWebview, + type SortOption, +} from './extension-app-message-constants'; +import { + sendGetDocuments, + sendRefreshDocuments, + sendSortDocuments, +} from './vscode-api'; +import DocumentTreeView from './document-tree-view'; + +interface PreviewDocument { + [key: string]: unknown; +} +type ViewType = 'tree' | 'json' | 'table'; + +const ITEMS_PER_PAGE_OPTIONS = [10, 25, 50, 100]; + +const toolbarStyles = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: `${spacing[200]}px ${spacing[300]}px`, + borderBottom: '1px solid var(--vscode-panel-border, #444)', + gap: spacing[300], + flexWrap: 'wrap', +}); + +const toolbarGroupStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[200], +}); + +const toolbarGroupWideStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[300], +}); + +const toolbarLabelStyles = css({ + fontSize: 13, + fontWeight: 500, +}); + +const paginationInfoStyles = css({ + fontSize: 13, + whiteSpace: 'nowrap', +}); + +const selectWrapperStyles = css({ + '& button': { + width: 'auto', + minWidth: 'unset', + }, +}); + +const refreshButtonStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[100], + background: 'none', + border: 'none', + cursor: 'pointer', + padding: `${spacing[100]}px ${spacing[200]}px`, + borderRadius: spacing[100], + fontSize: 13, + fontWeight: 500, + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, +}); + +const paginationArrowsStyles = css({ + display: 'flex', + alignItems: 'center', +}); + +const spinnerKeyframes = ` + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } +`; + +const loadingOverlayStyles = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: spacing[600], + flexDirection: 'column', + gap: spacing[300], +}); + +const spinnerStyles = css({ + animation: 'spin 1s linear infinite', +}); + +const PreviewApp: React.FC = () => { + const [documents, setDocuments] = useState([]); + const [sortOption, setSortOption] = useState('default'); + const [itemsPerPage, setItemsPerPage] = useState(10); + const [currentPage, setCurrentPage] = useState(1); + const [viewType, setViewType] = useState('tree'); + const [settingsMenuOpen, setSettingsMenuOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [totalCountInCollection, setTotalCountInCollection] = useState< + number | null + >(null); + const darkMode = useDarkMode(); + + const totalDocuments = documents.length; + const totalPages = Math.max(1, Math.ceil(totalDocuments / itemsPerPage)); + + // Ensure current page is valid + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [totalPages, currentPage]); + + // Calculate displayed documents based on pagination + const displayedDocuments = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return documents.slice(startIndex, endIndex); + }, [documents, currentPage, itemsPerPage]); + + // Calculate pagination info + const startItem = + totalDocuments === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1; + const endItem = Math.min(currentPage * itemsPerPage, totalDocuments); + + // Track when loading started for minimum loading duration + const loadingStartTimeRef = useRef(Date.now()); + const MIN_LOADING_DURATION_MS = 500; + + useEffect(() => { + const handleMessage = (event: MessageEvent): void => { + const message: MessageFromExtensionToWebview = event.data; + if (message.command === PreviewMessageType.loadDocuments) { + const elapsed = Date.now() - loadingStartTimeRef.current; + const remainingTime = Math.max(0, MIN_LOADING_DURATION_MS - elapsed); + + // Ensure minimum loading duration before hiding loader + setTimeout(() => { + setDocuments(message.documents || []); + if (message.totalCount !== undefined) { + setTotalCountInCollection(message.totalCount); + } + setCurrentPage(1); // Reset to first page when new documents are loaded + setIsLoading(false); + }, remainingTime); + } else if (message.command === PreviewMessageType.refreshError) { + const elapsed = Date.now() - loadingStartTimeRef.current; + const remainingTime = Math.max(0, MIN_LOADING_DURATION_MS - elapsed); + + // Ensure minimum loading duration before hiding loader + setTimeout(() => { + setIsLoading(false); + // Could show an error message here if needed + }, remainingTime); + } + }; + + window.addEventListener('message', handleMessage); + + // Request initial documents + sendGetDocuments(); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, []); + + const handleRefresh = (): void => { + loadingStartTimeRef.current = Date.now(); + setIsLoading(true); + sendRefreshDocuments(); + }; + + const handlePrevPage = (): void => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + const handleNextPage = (): void => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + const handleSortChange = (value: string): void => { + const newSortOption = value as SortOption; + setSortOption(newSortOption); + loadingStartTimeRef.current = Date.now(); + setIsLoading(true); + sendSortDocuments(newSortOption); + }; + + const handleItemsPerPageChange = (value: string): void => { + const newItemsPerPage = parseInt(value, 10); + setItemsPerPage(newItemsPerPage); + setCurrentPage(1); // Reset to first page when changing items per page + }; + + const handleViewTypeChange = (value: string): void => { + setViewType(value as ViewType); + // TODO: Implement different view renderings + }; + + const toggleSettingsMenu = (): void => { + setSettingsMenuOpen(!settingsMenuOpen); + }; + + return ( +
+ {/* Toolbar */} +
+ {/* Left side - Insert Document */} +
+ { + // TODO: Implement insert document functionality + }} + > + + + Insert Document +
+ + {/* Right side - Actions */} +
+ {/* Refresh - single button with icon and text */} + + + {/* Sort */} +
+ Sort +
+ +
+
+ + {/* Items per page */} +
+ +
+ + {/* Pagination info */} + + {startItem}-{endItem} of {totalCountInCollection ?? totalDocuments} + + + {/* Page navigation arrows */} +
+ + + + = totalPages} + > + + +
+ + {/* View type */} +
+ +
+ + {/* Settings dropdown */} + + + + } + > + Show line numbers + Expand all + Collapse all + Copy documents + +
+
+ + {/* Documents content */} +
+ {/* Inject keyframes for spinner animation */} + + + {isLoading ? ( +
+ + + + + Loading documents... + +
+ ) : ( + <> + {displayedDocuments.map((doc, index) => ( + + ))} + {displayedDocuments.length === 0 && ( +
+ No documents to display +
+ )} + + )} +
+
+ ); +}; + +export default PreviewApp; diff --git a/src/views/data-browsing-app/use-detect-vscode-dark-mode.ts b/src/views/data-browsing-app/use-detect-vscode-dark-mode.ts new file mode 100644 index 000000000..83e8482a9 --- /dev/null +++ b/src/views/data-browsing-app/use-detect-vscode-dark-mode.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; +import { + type MessageFromExtensionToWebview, + PreviewMessageType, +} from './extension-app-message-constants'; + +export const useDetectVsCodeDarkMode = (): boolean => { + const [darkModeDetected, setDarkModeDetected] = useState( + globalThis.document.body.classList.contains('vscode-dark') || + globalThis.document.body.classList.contains('vscode-high-contrast'), + ); + + useEffect(() => { + const onThemeChanged = (event: MessageEvent): void => { + const message: MessageFromExtensionToWebview = event.data; + if (message.command === PreviewMessageType.themeChanged) { + setDarkModeDetected(message.darkMode); + } + }; + window.addEventListener('message', onThemeChanged); + return (): void => window.removeEventListener('message', onThemeChanged); + }, []); + + return darkModeDetected; +}; diff --git a/src/views/data-browsing-app/vscode-api.ts b/src/views/data-browsing-app/vscode-api.ts new file mode 100644 index 000000000..655161913 --- /dev/null +++ b/src/views/data-browsing-app/vscode-api.ts @@ -0,0 +1,35 @@ +import { + PreviewMessageType, + type MessageFromWebviewToExtension, + type SortOption, +} from './extension-app-message-constants'; + +interface VSCodeApi { + postMessage: (message: MessageFromWebviewToExtension) => void; + getState: () => unknown; + setState: (state: unknown) => void; +} + +declare const acquireVsCodeApi: () => VSCodeApi; +const vscode = acquireVsCodeApi(); + +export const sendGetDocuments = (): void => { + vscode.postMessage({ + command: PreviewMessageType.getDocuments, + }); +}; + +export const sendRefreshDocuments = (): void => { + vscode.postMessage({ + command: PreviewMessageType.refreshDocuments, + }); +}; + +export const sendSortDocuments = (sort: SortOption): void => { + vscode.postMessage({ + command: PreviewMessageType.sortDocuments, + sort, + }); +}; + +export default vscode; diff --git a/src/views/dataBrowsingController.ts b/src/views/dataBrowsingController.ts new file mode 100644 index 000000000..e786fb78d --- /dev/null +++ b/src/views/dataBrowsingController.ts @@ -0,0 +1,253 @@ +import * as vscode from 'vscode'; +import type { Document } from 'bson'; + +import type ConnectionController from '../connectionController'; +import { createLogger } from '../logging'; +import type { MessageFromWebviewToExtension } from './data-browsing-app/extension-app-message-constants'; +import { + PreviewMessageType, + type SortOption, +} from './data-browsing-app/extension-app-message-constants'; +import type { TelemetryService } from '../telemetry'; +import { + createWebviewPanel, + getWebviewHtml, + getWebviewUri, +} from '../utils/webviewHelpers'; +import formatError from '../utils/formatError'; + +const log = createLogger('data browsing controller'); + +export const getDataBrowsingAppUri = ( + extensionPath: string, + webview: vscode.Webview, +): vscode.Uri => { + return getWebviewUri(extensionPath, webview, 'dist', 'dataBrowsingApp.js'); +}; + +export const getDataBrowsingContent = ({ + extensionPath, + webview, +}: { + extensionPath: string; + webview: vscode.Webview; +}): string => { + return getWebviewHtml({ + extensionPath, + webview, + scriptName: 'dataBrowsingApp.js', + title: 'MongoDB Data Browser', + }); +}; + +export interface DataBrowsingOptions { + namespace: string; + documents: Document[]; + fetchDocuments?: (options?: { + sort?: SortOption; + limit?: number; + }) => Promise; + initialTotalCount?: number; + getTotalCount?: () => Promise; +} + +export default class DataBrowsingController { + _connectionController: ConnectionController; + _telemetryService: TelemetryService; + _activeWebviewPanels: vscode.WebviewPanel[] = []; + _themeChangedSubscription: vscode.Disposable; + + constructor({ + connectionController, + telemetryService, + }: { + connectionController: ConnectionController; + telemetryService: TelemetryService; + }) { + this._connectionController = connectionController; + this._telemetryService = telemetryService; + this._themeChangedSubscription = vscode.window.onDidChangeActiveColorTheme( + this.onThemeChanged, + ); + } + + deactivate(): void { + this._themeChangedSubscription?.dispose(); + } + + handleWebviewMessage = async ( + message: MessageFromWebviewToExtension, + panel: vscode.WebviewPanel, + options: DataBrowsingOptions, + ): Promise => { + switch (message.command) { + case PreviewMessageType.getDocuments: + await this.handleGetDocuments(panel, options); + return; + case PreviewMessageType.refreshDocuments: + await this.handleRefreshDocuments(panel, options); + return; + case PreviewMessageType.sortDocuments: + await this.handleSortDocuments(panel, options, message.sort); + return; + default: + // no-op. + return; + } + }; + + handleGetDocuments = async ( + panel: vscode.WebviewPanel, + options: DataBrowsingOptions, + ): Promise => { + try { + const totalCount = options.getTotalCount + ? await options.getTotalCount() + : options.initialTotalCount; + + void panel.webview.postMessage({ + command: PreviewMessageType.loadDocuments, + documents: options.documents, + totalCount, + }); + } catch (error) { + log.error('Error getting documents', error); + void panel.webview.postMessage({ + command: PreviewMessageType.refreshError, + error: formatError(error).message, + }); + } + }; + + handleRefreshDocuments = async ( + panel: vscode.WebviewPanel, + options: DataBrowsingOptions, + ): Promise => { + try { + if (options.fetchDocuments) { + const documents = await options.fetchDocuments(); + const totalCount = options.getTotalCount + ? await options.getTotalCount() + : options.initialTotalCount; + + void panel.webview.postMessage({ + command: PreviewMessageType.loadDocuments, + documents, + totalCount, + }); + } else { + void panel.webview.postMessage({ + command: PreviewMessageType.loadDocuments, + documents: options.documents, + totalCount: options.initialTotalCount, + }); + } + } catch (error) { + log.error('Error refreshing documents', error); + void panel.webview.postMessage({ + command: PreviewMessageType.refreshError, + error: formatError(error).message, + }); + } + }; + + handleSortDocuments = async ( + panel: vscode.WebviewPanel, + options: DataBrowsingOptions, + sort: SortOption, + ): Promise => { + try { + if (options.fetchDocuments) { + const documents = await options.fetchDocuments({ sort }); + const totalCount = options.getTotalCount + ? await options.getTotalCount() + : options.initialTotalCount; + + void panel.webview.postMessage({ + command: PreviewMessageType.loadDocuments, + documents, + totalCount, + }); + } + } catch (error) { + log.error('Error sorting documents', error); + void panel.webview.postMessage({ + command: PreviewMessageType.refreshError, + error: formatError(error).message, + }); + } + }; + + onReceivedWebviewMessage = async ( + message: MessageFromWebviewToExtension, + panel: vscode.WebviewPanel, + options: DataBrowsingOptions, + ): Promise => { + // Ensure handling message from the webview can't crash the extension. + try { + await this.handleWebviewMessage(message, panel, options); + } catch (err) { + log.error('Error occurred when parsing message from webview', err); + return; + } + }; + + onWebviewPanelClosed = (disposedPanel: vscode.WebviewPanel): void => { + this._activeWebviewPanels = this._activeWebviewPanels.filter( + (panel) => panel !== disposedPanel, + ); + }; + + onThemeChanged = (theme: vscode.ColorTheme): void => { + const darkModeDetected = + theme.kind === vscode.ColorThemeKind.Dark || + theme.kind === vscode.ColorThemeKind.HighContrast; + for (const panel of this._activeWebviewPanels) { + void panel.webview + .postMessage({ + command: PreviewMessageType.themeChanged, + darkMode: darkModeDetected, + }) + .then(undefined, (error) => { + log.warn( + 'Could not post THEME_CHANGED to webview, most likely already disposed', + error, + ); + }); + } + }; + + openDataBrowser( + context: vscode.ExtensionContext, + options: DataBrowsingOptions, + ): vscode.WebviewPanel { + log.info('Opening data browser...', options.namespace); + const extensionPath = context.extensionPath; + + // Create and show a new data browsing webview. + const panel = createWebviewPanel({ + viewType: 'mongodbDataBrowser', + title: `Preview: ${options.namespace}`, + extensionPath, + iconName: 'leaf.svg', + }); + + panel.onDidDispose(() => this.onWebviewPanelClosed(panel)); + this._activeWebviewPanels.push(panel); + + panel.webview.html = getDataBrowsingContent({ + extensionPath, + webview: panel.webview, + }); + + // Handle messages from the webview. + panel.webview.onDidReceiveMessage( + (message: MessageFromWebviewToExtension) => + this.onReceivedWebviewMessage(message, panel, options), + undefined, + context.subscriptions, + ); + + return panel; + } +} diff --git a/src/views/index.ts b/src/views/index.ts index d2f79b178..638111975 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -1,4 +1,5 @@ import WebviewController from './webviewController'; +import DataBrowsingController from './dataBrowsingController'; import StatusView from './statusView'; -export { WebviewController, StatusView }; +export { WebviewController, DataBrowsingController, StatusView }; diff --git a/src/views/webviewController.ts b/src/views/webviewController.ts index f452a13bc..8424d874c 100644 --- a/src/views/webviewController.ts +++ b/src/views/webviewController.ts @@ -1,6 +1,4 @@ import * as vscode from 'vscode'; -import path from 'path'; -import crypto from 'crypto'; import type { ConnectionOptions } from 'mongodb-data-service'; import type ConnectionController from '../connectionController'; @@ -23,23 +21,20 @@ import { OpenEditConnectionTelemetryEvent, } from '../telemetry'; import type { FileChooserOptions } from './webview-app/use-connection-form'; +import { + createWebviewPanel, + getWebviewHtml, + getWebviewUri, +} from '../utils/webviewHelpers'; import formatError from '../utils/formatError'; const log = createLogger('webview controller'); -const getNonce = (): string => { - return crypto.randomBytes(16).toString('base64'); -}; - export const getReactAppUri = ( extensionPath: string, webview: vscode.Webview, ): vscode.Uri => { - const localFilePathUri = vscode.Uri.file( - path.join(extensionPath, 'dist', 'webviewApp.js'), - ); - const jsAppFileWebviewUri = webview.asWebviewUri(localFilePathUri); - return jsAppFileWebviewUri; + return getWebviewUri(extensionPath, webview, 'dist', 'webviewApp.js'); }; export const getWebviewContent = ({ @@ -51,36 +46,24 @@ export const getWebviewContent = ({ telemetryUserId?: string; webview: vscode.Webview; }): string => { - const jsAppFileUrl = getReactAppUri(extensionPath, webview); - - // Use a nonce to only allow specific scripts to be run. - const nonce = getNonce(); - const showOIDCDeviceAuthFlow = vscode.workspace .getConfiguration('mdb') .get('showOIDCDeviceAuthFlow'); - return ` - - - - - - MongoDB - - -
- ${getFeatureFlagsScript(nonce)} - ${telemetryUserId ? `` : ''} - ` : ''} + - - - `; + };`; + + return getWebviewHtml({ + extensionPath, + webview, + scriptName: 'webviewApp.js', + title: 'MongoDB', + additionalHeadContent, + }); }; export default class WebviewController { @@ -378,34 +361,17 @@ export default class WebviewController { const extensionPath = context.extensionPath; // Create and show a new connect dialogue webview. - const panel = vscode.window.createWebviewPanel( - 'connectDialogueWebview', - 'MongoDB', - vscode.ViewColumn.One, // Editor column to show the webview panel in. - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.file(path.join(extensionPath, 'dist')), - vscode.Uri.file(path.join(extensionPath, 'resources')), - ], - }, - ); + const panel = createWebviewPanel({ + viewType: 'connectDialogueWebview', + title: 'MongoDB', + extensionPath, + additionalResourceRoots: ['resources'], + iconName: 'leaf.svg', + }); panel.onDidDispose(() => this.onWebviewPanelClosed(panel)); this._activeWebviewPanels.push(panel); - panel.iconPath = vscode.Uri.file( - path.join( - extensionPath, - 'images', - vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark - ? 'dark' - : 'light', - 'leaf.svg', - ), - ); - const telemetryUserIdentity = this._storageController.getUserIdentity(); panel.webview.html = getWebviewContent({ diff --git a/webpack.config.js b/webpack.config.js index 53feb51db..0a21b0ce4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -164,6 +164,7 @@ module.exports = (env, argv) => { target: 'web', entry: { webviewApp: './src/views/webview-app/index.tsx', + dataBrowsingApp: './src/views/data-browsing-app/index.tsx', }, resolve: { extensions: ['.js', '.ts', '.tsx', '.json'],