From 9fc3af160f6409d6b1c5145a1b7bc979e5372f65 Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Wed, 17 Dec 2025 15:24:39 +0100 Subject: [PATCH 01/18] Add MDB_USE_ENHANCED_DATA_BROWSING_EXPERIENCE feature flag --- src/featureFlags.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/featureFlags.ts b/src/featureFlags.ts index 7f0a786b1..fdb06339e 100644 --- a/src/featureFlags.ts +++ b/src/featureFlags.ts @@ -1,5 +1,6 @@ const FEATURE_FLAGS = { 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; From 24b98288521b5fe3cf1d481a0948f498a64ee03a Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Wed, 17 Dec 2025 20:40:41 +0100 Subject: [PATCH 02/18] chore: reuse webview boilerplate --- src/editors/editorsController.ts | 162 +++++++++++++++++++++++++++++++ src/utils/webviewHelpers.ts | 104 ++++++++++++++++++++ src/views/webviewController.ts | 87 ++++++----------- 3 files changed, 293 insertions(+), 60 deletions(-) create mode 100644 src/utils/webviewHelpers.ts diff --git a/src/editors/editorsController.ts b/src/editors/editorsController.ts index 8390f0dff..c23b11757 100644 --- a/src/editors/editorsController.ts +++ b/src/editors/editorsController.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import path from 'path'; import { EJSON } from 'bson'; import type { Document } from 'bson'; @@ -33,6 +34,11 @@ import { PLAYGROUND_RESULT_SCHEME } from './playgroundResultProvider'; import { StatusView } from '../views'; import type { TelemetryService } from '../telemetry'; import type { QueryWithCopilotCodeLensProvider } from './queryWithCopilotCodeLensProvider'; +import { + createWebviewPanel, + getNonce, + getWebviewHtml, +} from '../utils/webviewHelpers'; const log = createLogger('editors controller'); @@ -104,6 +110,7 @@ export default class EditorsController { _editDocumentCodeLensProvider: EditDocumentCodeLensProvider; _collectionDocumentsCodeLensProvider: CollectionDocumentsCodeLensProvider; _queryWithCopilotCodeLensProvider: QueryWithCopilotCodeLensProvider; + _activePreviewPanels: vscode.WebviewPanel[] = []; constructor({ context, @@ -304,6 +311,161 @@ export default class EditorsController { } } + openCollectionPreview( + namespace: string, + documents: Document[], + fetchDocuments?: (options?: { + sort?: 'default' | 'asc' | 'desc'; + limit?: number; + }) => Promise, + initialTotalCount?: number, + getTotalCount?: () => Promise, + ): boolean { + log.info('Open collection preview', namespace); + + try { + const extensionPath = this._context.extensionPath; + const nonce = getNonce(); + + const panel = vscode.window.createWebviewPanel( + 'mongodbPreview', + `Preview: ${namespace}`, + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.file(path.join(extensionPath, 'dist')), + ], + }, + ); + + const previewAppUri = panel.webview.asWebviewUri( + vscode.Uri.file(path.join(extensionPath, 'dist', 'previewApp.js')), + ); + + panel.webview.html = ` + + + + + + Preview + + +
+ + + `; + + // Keep track of current documents, sort option, and total count + // Fetch limit is fixed - pagination is handled client-side + const FETCH_LIMIT = 100; + let currentDocuments = documents; + let currentSort: 'default' | 'asc' | 'desc' = 'default'; + let totalCount = initialTotalCount ?? documents.length; + + // Helper to send current documents to webview + const sendDocuments = (): void => { + void panel.webview.postMessage({ + command: 'LOAD_DOCUMENTS', + documents: JSON.parse(EJSON.stringify(currentDocuments)), + totalCount, + }); + }; + + // Helper to handle errors + const handleError = (operation: string, error: unknown): void => { + log.error(`${operation} failed:`, error); + void panel.webview.postMessage({ + command: 'REFRESH_ERROR', + error: formatError(error).message, + }); + }; + + // Helper to fetch and update documents + const fetchAndUpdateDocuments = async ( + operation: string, + ): Promise => { + if (fetchDocuments) { + log.info( + `${operation} with sort:`, + currentSort, + 'limit:', + FETCH_LIMIT, + ); + currentDocuments = await fetchDocuments({ + sort: currentSort, + limit: FETCH_LIMIT, + }); + log.info(`${operation} complete, count:`, currentDocuments.length); + } + }; + + // Send documents to webview + panel.webview.onDidReceiveMessage( + async (message: { + command: string; + sort?: 'default' | 'asc' | 'desc'; + }) => { + log.info('Preview received message:', message.command); + + switch (message.command) { + case 'GET_DOCUMENTS': + sendDocuments(); + break; + + case 'REFRESH_DOCUMENTS': + try { + await fetchAndUpdateDocuments('Refreshing documents'); + if (getTotalCount) { + totalCount = await getTotalCount(); + } + sendDocuments(); + } catch (error) { + handleError('Refresh documents', error); + } + break; + + case 'SORT_DOCUMENTS': + try { + if (message.sort) { + currentSort = message.sort; + await fetchAndUpdateDocuments('Sorting documents'); + } + sendDocuments(); + } catch (error) { + handleError('Sort documents', error); + } + break; + + default: + log.info('Unknown command:', message.command); + break; + } + }, + ); + + return true; + } catch (error) { + void vscode.window.showErrorMessage( + `Unable to open preview: ${formatError(error).message}`, + ); + + return false; + } + } + + private onPreviewPanelClosed(panel: vscode.WebviewPanel): void { + const panelIndex = this._activePreviewPanels.indexOf(panel); + if (panelIndex !== -1) { + this._activePreviewPanels.splice(panelIndex, 1); + } + } + onViewMoreCollectionDocuments( operationId: string, connectionId: string, diff --git a/src/utils/webviewHelpers.ts b/src/utils/webviewHelpers.ts new file mode 100644 index 000000000..814999f51 --- /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/webviewController.ts b/src/views/webviewController.ts index a33acbe5a..3bceed59a 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,22 +21,20 @@ import { OpenEditConnectionTelemetryEvent, } from '../telemetry'; import type { FileChooserOptions } from './webview-app/use-connection-form'; +import { + createWebviewPanel, + getWebviewHtml, + getWebviewUri, + getNonce, +} from '../utils/webviewHelpers'; 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 = ({ @@ -50,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)} - - + - - - `; + };`; + + return getWebviewHtml({ + extensionPath, + webview, + scriptName: 'webviewApp.js', + title: 'MongoDB', + additionalHeadContent, + }); }; export default class WebviewController { @@ -377,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({ From 17bc86913a170eda56cd7fa5268c2a92dd22c6b5 Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Wed, 17 Dec 2025 20:40:56 +0100 Subject: [PATCH 03/18] feat: Add basic tree view for new data browsing experience --- src/commands/index.ts | 4 + src/explorer/collectionTreeItem.ts | 47 +- src/explorer/databaseTreeItem.ts | 3 + src/explorer/documentListPreviewItem.ts | 154 ++++++ src/explorer/explorerTreeController.ts | 8 + src/mdbExtensionController.ts | 29 ++ .../data-browsing-app/document-tree-view.tsx | 488 ++++++++++++++++++ src/views/data-browsing-app/index.tsx | 7 + src/views/data-browsing-app/preview-app.tsx | 430 +++++++++++++++ webpack.config.js | 1 + 10 files changed, 1168 insertions(+), 3 deletions(-) create mode 100644 src/explorer/documentListPreviewItem.ts create mode 100644 src/views/data-browsing-app/document-tree-view.tsx create mode 100644 src/views/data-browsing-app/index.tsx create mode 100644 src/views/data-browsing-app/preview-app.tsx 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 d87d221f3..41602a8fd 100644 --- a/src/explorer/collectionTreeItem.ts +++ b/src/explorer/collectionTreeItem.ts @@ -6,6 +6,7 @@ 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'; @@ -47,8 +48,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 @@ -65,6 +73,7 @@ export default class CollectionTreeItem private _documentListChild: DocumentListTreeItem; private _schemaChild: SchemaTreeItem; private _indexListChild: IndexListTreeItem; + private _previewChild: ShowPreviewTreeItem; collection: CollectionDetailsType; collectionName: string; @@ -93,6 +102,7 @@ export default class CollectionTreeItem existingDocumentListChild, existingSchemaChild, existingIndexListChild, + existingPreviewChild, }: { collection: CollectionDetailsType; databaseName: string; @@ -103,6 +113,7 @@ export default class CollectionTreeItem existingDocumentListChild?: DocumentListTreeItem; existingSchemaChild?: SchemaTreeItem; existingIndexListChild?: IndexListTreeItem; + existingPreviewChild?: ShowPreviewTreeItem; }) { super( collection.name, @@ -134,6 +145,18 @@ export default class CollectionTreeItem cacheIsUpToDate: false, childrenCache: [], // Empty cache. }); + this._previewChild = existingPreviewChild + ? existingPreviewChild + : 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, + }); this._schemaChild = existingSchemaChild ? existingSchemaChild : new SchemaTreeItem({ @@ -183,7 +206,12 @@ export default class CollectionTreeItem } if (this.cacheIsUpToDate) { - return [this._documentListChild, this._schemaChild, this._indexListChild]; + return [ + this._previewChild, + this._documentListChild, + this._schemaChild, + this._indexListChild, + ]; } this.cacheIsUpToDate = true; @@ -192,7 +220,12 @@ export default class CollectionTreeItem // is ensure to be set by vscode. this.rebuildChildrenCache(); - return [this._documentListChild, this._schemaChild, this._indexListChild]; + return [ + this._previewChild, + this._documentListChild, + this._schemaChild, + this._indexListChild, + ]; } rebuildDocumentListTreeItem(): void { @@ -234,12 +267,17 @@ export default class CollectionTreeItem }); } + rebuildPreviewTreeItem(): void { + this._previewChild.refreshDocumentCount(); + } + rebuildChildrenCache(): void { // We rebuild the children here so their controlled `expanded` state // is ensure to be set by vscode. this.rebuildDocumentListTreeItem(); this.rebuildSchemaTreeItem(); this.rebuildIndexListTreeItem(); + this.rebuildPreviewTreeItem(); } needsToUpdateCache(): boolean { @@ -309,6 +347,9 @@ export default class CollectionTreeItem getIndexListChild(): IndexListTreeItem { return this._indexListChild; } + getPreviewChild(): ShowPreviewTreeItem { + return this._previewChild; + } getMaxDocumentsToShow(): number { if (!this._documentListChild) { diff --git a/src/explorer/databaseTreeItem.ts b/src/explorer/databaseTreeItem.ts index c747c1e07..529a13058 100644 --- a/src/explorer/databaseTreeItem.ts +++ b/src/explorer/databaseTreeItem.ts @@ -96,6 +96,7 @@ export default class DatabaseTreeItem existingDocumentListChild: prevChild.getDocumentListChild(), existingSchemaChild: prevChild.getSchemaChild(), existingIndexListChild: prevChild.getIndexListChild(), + existingPreviewChild: prevChild.getPreviewChild(), }); }); @@ -153,6 +154,8 @@ export default class DatabaseTreeItem pastChildrenCache[collection.name].getSchemaChild(), existingIndexListChild: pastChildrenCache[collection.name].getIndexListChild(), + existingPreviewChild: + pastChildrenCache[collection.name].getPreviewChild(), }); } else { this._childrenCache[collection.name] = new CollectionTreeItem({ diff --git a/src/explorer/documentListPreviewItem.ts b/src/explorer/documentListPreviewItem.ts new file mode 100644 index 000000000..dc1266b9e --- /dev/null +++ b/src/explorer/documentListPreviewItem.ts @@ -0,0 +1,154 @@ +import * as vscode from 'vscode'; +import numeral from 'numeral'; +import path from 'path'; + +import type { DataService } from 'mongodb-data-service'; +import { CollectionType } from './documentListTreeItem'; +import { getImagesPath } from '../extensionConstants'; +import formatError from '../utils/formatError'; + +export const PREVIEW_LIST_ITEM = 'documentListPreviewItem'; + +export const formatDocCount = (count: number): string => { + // We format the count (30000 -> 30k) and then display it uppercase (30K). + return `${numeral(count).format('0a')}`.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 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 = getIconPath(); + this.tooltip = getTooltip(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/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/mdbExtensionController.ts b/src/mdbExtensionController.ts index ea71c6eb9..0aed262e9 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'; @@ -147,6 +148,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. @@ -846,6 +848,33 @@ 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(); + + return this._editorsController.openCollectionPreview( + namespace, + documents, + fetchDocuments, + totalCount, + getTotalCount, + ); + }, + ); this.registerCommand( ExtensionCommand.mdbRefreshCollection, async (collectionTreeItem: CollectionTreeItem): Promise => { 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..c849ccdfa --- /dev/null +++ b/src/views/data-browsing-app/document-tree-view.tsx @@ -0,0 +1,488 @@ +import React, { useState } from 'react'; +import { css } from '@mongodb-js/compass-components'; + +const documentTreeViewContainerStyles = css({ + display: 'flex', + padding: '0', + alignItems: 'flex-start', + alignSelf: 'stretch', + position: 'relative', + width: '100%', + marginBottom: '8px', +}); + +const documentContentStyles = css({ + display: 'flex', + padding: '12px 16px', + flexDirection: 'column', + justifyContent: 'flex-start', + alignItems: 'flex-start', + flex: '1 0 0', + borderRadius: '4px', + border: '1px solid rgba(255, 255, 255, 0.1)', + position: 'relative', + backgroundColor: '#2D2D30', + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: '13px', + lineHeight: '19px', + width: '100%', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)', + '@media (max-width: 991px)': { + padding: '10px 14px', + }, + '@media (max-width: 640px)': { + padding: '8px 12px', + fontSize: '12px', + }, +}); + +const parentNodeStyles = css({ + display: 'flex', + padding: '0', + flexDirection: 'column', + alignItems: 'flex-start', + position: 'relative', + width: '100%', +}); + +const nodeRowStyles = css({ + display: 'flex', + alignItems: 'flex-start', + gap: '4px', + alignSelf: 'stretch', + position: 'relative', + minHeight: '19px', + paddingLeft: '16px', + '@media (max-width: 640px)': { + gap: '3px', + paddingLeft: '12px', + }, +}); + +const caretStyles = css({ + width: '16px', + height: '19px', + position: 'relative', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginLeft: '-16px', + '@media (max-width: 640px)': { + marginLeft: '-12px', + }, +}); + +const caretIconStyles = css({ + color: '#CCCCCC', + fontSize: '12px', + lineHeight: '19px', + fontFamily: 'codicon', + userSelect: 'none', + cursor: 'pointer', + transition: 'transform 0.1s ease', + '&:hover': { + color: '#FFFFFF', + }, +}); + +const clickableRowStyle = css({ + cursor: 'pointer', +}); + +const caretExpandedStyles = css({ + transform: 'rotate(90deg)', +}); + +const childrenContainerStyles = css({ + paddingLeft: '16px', + '@media (max-width: 640px)': { + paddingLeft: '12px', + }, +}); + +const keyValueContainerStyles = css({ + display: 'flex', + alignItems: 'flex-start', + position: 'relative', + flexWrap: 'wrap', + gap: '0', +}); + +const keyStyles = css({ + color: '#9CDCFE', + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: '13px', + lineHeight: '19px', + fontWeight: 400, + whiteSpace: 'nowrap', + '@media (max-width: 640px)': { + fontSize: '12px', + }, +}); + +const colonStyles = css({ + color: '#D4D4D4', + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: '13px', + lineHeight: '19px', + fontWeight: 400, + '@media (max-width: 640px)': { + fontSize: '12px', + }, +}); + +const valueStyles = css({ + color: '#CE9178', + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: '13px', + lineHeight: '19px', + fontWeight: 400, + '@media (max-width: 640px)': { + fontSize: '12px', + }, +}); + +const numberValueStyles = css({ + color: '#B5CEA8', + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: '13px', + lineHeight: '19px', + fontWeight: 400, + '@media (max-width: 640px)': { + fontSize: '12px', + }, +}); + +const objectValueStyles = css({ + color: '#4EC9B0', + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: '13px', + lineHeight: '19px', + fontWeight: 400, + '@media (max-width: 640px)': { + fontSize: '12px', + }, +}); + +const commaStyles = css({ + color: '#D4D4D4', + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: '13px', + lineHeight: '19px', + fontWeight: 400, + '@media (max-width: 640px)': { + fontSize: '12px', + }, +}); + +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 [expandedKeys, setExpandedKeys] = useState>(new Set()); + + 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 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 && ( + toggleExpanded(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 && ( + toggleExpanded(itemKey)} + > + › + + )} +
+
+ {key} + : + + {formatValue(val, type)} + + {!isLast && ,} +
+
+ {hasExpandableContent && isExpanded && ( +
+ {renderChildren(val, itemKey)} +
+ )} +
+ ); + }); + } + return []; + }; + + const getValueClassName = (type: TreeNode['type']): string => { + if (type === 'number') { + return numberValueStyles; + } + if (type === 'object' || type === 'array') { + return objectValueStyles; + } + if (type === 'boolean' || type === 'null') { + return numberValueStyles; + } + return valueStyles; + }; + + 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 renderExpandCaret = ( + isExpanded: boolean + ): JSX.Element => ( + + › + + ); + + const getRowClassName = (hasExpandableContent: boolean): string => + `${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); + const displayClassName = isIdField ? valueStyles : getValueClassName(node.type); + + return ( +
+
+
+ {hasExpandableContent && renderExpandCaret(isExpanded)} +
+
+ "{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/index.tsx b/src/views/data-browsing-app/index.tsx new file mode 100644 index 000000000..20ee0b3a7 --- /dev/null +++ b/src/views/data-browsing-app/index.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +import PreviewApp from './preview-app'; + +ReactDOM.render(, document.getElementById('root')); + diff --git a/src/views/data-browsing-app/preview-app.tsx b/src/views/data-browsing-app/preview-app.tsx new file mode 100644 index 000000000..072d3c69e --- /dev/null +++ b/src/views/data-browsing-app/preview-app.tsx @@ -0,0 +1,430 @@ +import React, { useEffect, useState, useMemo, useRef } from 'react'; +import { + LeafyGreenProvider, + Icon, + IconButton, + Select, + Option, + Menu, + MenuItem, + css, + spacing, +} from '@mongodb-js/compass-components'; +import { useDetectVsCodeDarkMode } from './use-detect-vscode-dark-mode'; +import DocumentTreeView from './document-tree-view'; + +declare const acquireVsCodeApi: () => { + postMessage: (message: unknown) => void; + getState: () => unknown; + setState: (state: unknown) => void; +}; + +interface PreviewDocument { + [key: string]: unknown; +} + +type SortOption = 'default' | 'asc' | 'desc'; +type ViewType = 'tree' | 'json' | 'table'; + +const ITEMS_PER_PAGE_OPTIONS = [10, 25, 50, 100]; + +// Styles +const toolbarStyles = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: `${spacing[2]}px ${spacing[3]}px`, + borderBottom: '1px solid var(--vscode-panel-border, #444)', + gap: spacing[3], + flexWrap: 'wrap', +}); + +const toolbarLeftStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[2], +}); + +const toolbarRightStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[3], +}); + +const toolbarGroupStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[2], +}); + +const toolbarLabelStyles = css({ + fontSize: '13px', + fontWeight: 500, +}); + +const paginationInfoStyles = css({ + fontSize: '13px', + whiteSpace: 'nowrap', +}); + +const selectWrapperStyles = css({ + // Style the select button to fit content width + '& button': { + width: 'auto', + minWidth: 'unset', + }, +}); + +const narrowSelectStyles = css({ + // Style the select button to fit content width + '& button': { + width: 'auto', + minWidth: 'unset', + }, +}); + +const settingsMenuStyles = css({ + position: 'relative', +}); + +const refreshButtonStyles = css({ + display: 'flex', + alignItems: 'center', + gap: '4px', + background: 'none', + border: 'none', + color: 'inherit', + cursor: 'pointer', + padding: '4px 8px', + borderRadius: '4px', + fontSize: '13px', + fontWeight: 500, + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, +}); + +const paginationArrowsStyles = css({ + display: 'flex', + alignItems: 'center', + gap: '0', +}); + +const spinnerKeyframes = ` + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } +`; + +const loadingOverlayStyles = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '32px', + flexDirection: 'column', + gap: '12px', +}); + +const spinnerStyles = css({ + animation: 'spin 1s linear infinite', + display: 'inline-block', +}); + +const PreviewApp: React.FC = () => { + const darkMode = useDetectVsCodeDarkMode(); + 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(null); + + 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); + + // Store vscode API reference - acquireVsCodeApi should only be called once + const vscodeApi = useMemo(() => acquireVsCodeApi(), []); + + // 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 = event.data; + if (message.command === 'LOAD_DOCUMENTS') { + 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 === 'REFRESH_ERROR') { + 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 + vscodeApi.postMessage({ command: 'GET_DOCUMENTS' }); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [vscodeApi]); + + const handleRefresh = (): void => { + loadingStartTimeRef.current = Date.now(); + setIsLoading(true); + vscodeApi.postMessage({ command: 'REFRESH_DOCUMENTS' }); + }; + + 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); + vscodeApi.postMessage({ command: 'SORT_DOCUMENTS', sort: 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/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'], From 9fe27d8a171cc91305588d560cdbca0dffe873f1 Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Thu, 18 Dec 2025 12:24:19 +0100 Subject: [PATCH 04/18] small refactor, css cleanup --- src/editors/editorsController.ts | 73 ++--- src/explorer/collectionTreeItem.ts | 2 +- .../data-browsing-app/document-tree-view.tsx | 282 +++++++----------- .../extension-app-message-constants.ts | 63 ++++ src/views/data-browsing-app/preview-app.tsx | 132 ++++---- .../use-detect-vscode-dark-mode.ts | 26 ++ src/views/data-browsing-app/vscode-api.ts | 36 +++ src/views/webviewController.ts | 1 - 8 files changed, 306 insertions(+), 309 deletions(-) create mode 100644 src/views/data-browsing-app/extension-app-message-constants.ts create mode 100644 src/views/data-browsing-app/use-detect-vscode-dark-mode.ts create mode 100644 src/views/data-browsing-app/vscode-api.ts diff --git a/src/editors/editorsController.ts b/src/editors/editorsController.ts index c23b11757..af3505a4d 100644 --- a/src/editors/editorsController.ts +++ b/src/editors/editorsController.ts @@ -1,5 +1,4 @@ import * as vscode from 'vscode'; -import path from 'path'; import { EJSON } from 'bson'; import type { Document } from 'bson'; @@ -34,11 +33,11 @@ import { PLAYGROUND_RESULT_SCHEME } from './playgroundResultProvider'; import { StatusView } from '../views'; import type { TelemetryService } from '../telemetry'; import type { QueryWithCopilotCodeLensProvider } from './queryWithCopilotCodeLensProvider'; +import { createWebviewPanel, getWebviewHtml } from '../utils/webviewHelpers'; import { - createWebviewPanel, - getNonce, - getWebviewHtml, -} from '../utils/webviewHelpers'; + PreviewMessageType, + type SortOption, +} from '../views/data-browsing-app/extension-app-message-constants'; const log = createLogger('editors controller'); @@ -315,7 +314,7 @@ export default class EditorsController { namespace: string, documents: Document[], fetchDocuments?: (options?: { - sort?: 'default' | 'asc' | 'desc'; + sort?: SortOption; limit?: number; }) => Promise, initialTotalCount?: number, @@ -325,53 +324,34 @@ export default class EditorsController { try { const extensionPath = this._context.extensionPath; - const nonce = getNonce(); - const panel = vscode.window.createWebviewPanel( - 'mongodbPreview', - `Preview: ${namespace}`, - vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.file(path.join(extensionPath, 'dist')), - ], - }, - ); + const panel = createWebviewPanel({ + viewType: 'mongodbPreview', + title: `Preview: ${namespace}`, + extensionPath, + }); - const previewAppUri = panel.webview.asWebviewUri( - vscode.Uri.file(path.join(extensionPath, 'dist', 'previewApp.js')), - ); + panel.webview.html = getWebviewHtml({ + extensionPath, + webview: panel.webview, + scriptName: 'previewApp.js', + title: 'Preview', + }); - panel.webview.html = ` - - - - - - Preview - - -
- - - `; + panel.onDidDispose(() => this.onPreviewPanelClosed(panel)); + this._activePreviewPanels.push(panel); // Keep track of current documents, sort option, and total count // Fetch limit is fixed - pagination is handled client-side const FETCH_LIMIT = 100; let currentDocuments = documents; - let currentSort: 'default' | 'asc' | 'desc' = 'default'; + let currentSort: SortOption = 'default'; let totalCount = initialTotalCount ?? documents.length; // Helper to send current documents to webview const sendDocuments = (): void => { void panel.webview.postMessage({ - command: 'LOAD_DOCUMENTS', + command: PreviewMessageType.loadDocuments, documents: JSON.parse(EJSON.stringify(currentDocuments)), totalCount, }); @@ -381,7 +361,7 @@ export default class EditorsController { const handleError = (operation: string, error: unknown): void => { log.error(`${operation} failed:`, error); void panel.webview.postMessage({ - command: 'REFRESH_ERROR', + command: PreviewMessageType.refreshError, error: formatError(error).message, }); }; @@ -407,18 +387,15 @@ export default class EditorsController { // Send documents to webview panel.webview.onDidReceiveMessage( - async (message: { - command: string; - sort?: 'default' | 'asc' | 'desc'; - }) => { + async (message: { command: string; sort?: SortOption }) => { log.info('Preview received message:', message.command); switch (message.command) { - case 'GET_DOCUMENTS': + case PreviewMessageType.getDocuments: sendDocuments(); break; - case 'REFRESH_DOCUMENTS': + case PreviewMessageType.refreshDocuments: try { await fetchAndUpdateDocuments('Refreshing documents'); if (getTotalCount) { @@ -430,7 +407,7 @@ export default class EditorsController { } break; - case 'SORT_DOCUMENTS': + case PreviewMessageType.sortDocuments: try { if (message.sort) { currentSort = message.sort; diff --git a/src/explorer/collectionTreeItem.ts b/src/explorer/collectionTreeItem.ts index 41602a8fd..f3e2ed313 100644 --- a/src/explorer/collectionTreeItem.ts +++ b/src/explorer/collectionTreeItem.ts @@ -268,7 +268,7 @@ export default class CollectionTreeItem } rebuildPreviewTreeItem(): void { - this._previewChild.refreshDocumentCount(); + void this._previewChild.refreshDocumentCount(); } rebuildChildrenCache(): void { diff --git a/src/views/data-browsing-app/document-tree-view.tsx b/src/views/data-browsing-app/document-tree-view.tsx index c849ccdfa..a3d41d1b7 100644 --- a/src/views/data-browsing-app/document-tree-view.tsx +++ b/src/views/data-browsing-app/document-tree-view.tsx @@ -1,179 +1,95 @@ import React, { useState } from 'react'; -import { css } from '@mongodb-js/compass-components'; +import { + css, + cx, + spacing, + fontFamilies, + KeylineCard, + Icon, + palette, + codePalette, + useDarkMode, +} from '@mongodb-js/compass-components'; + const documentTreeViewContainerStyles = css({ - display: 'flex', - padding: '0', - alignItems: 'flex-start', - alignSelf: 'stretch', - position: 'relative', - width: '100%', - marginBottom: '8px', + marginBottom: spacing[200], }); const documentContentStyles = css({ - display: 'flex', - padding: '12px 16px', - flexDirection: 'column', - justifyContent: 'flex-start', - alignItems: 'flex-start', - flex: '1 0 0', - borderRadius: '4px', - border: '1px solid rgba(255, 255, 255, 0.1)', - position: 'relative', - backgroundColor: '#2D2D30', - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - fontSize: '13px', - lineHeight: '19px', - width: '100%', - boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)', - '@media (max-width: 991px)': { - padding: '10px 14px', - }, - '@media (max-width: 640px)': { - padding: '8px 12px', - fontSize: '12px', - }, + padding: `${spacing[300]}px ${spacing[400]}px`, + fontFamily: fontFamilies.code, + fontSize: 12, + lineHeight: '16px', }); const parentNodeStyles = css({ display: 'flex', - padding: '0', flexDirection: 'column', - alignItems: 'flex-start', - position: 'relative', - width: '100%', }); const nodeRowStyles = css({ display: 'flex', - alignItems: 'flex-start', - gap: '4px', - alignSelf: 'stretch', - position: 'relative', - minHeight: '19px', - paddingLeft: '16px', - '@media (max-width: 640px)': { - gap: '3px', - paddingLeft: '12px', - }, + gap: spacing[100], + minHeight: 16, + paddingLeft: spacing[400], }); const caretStyles = css({ - width: '16px', - height: '19px', - position: 'relative', + width: spacing[400], + height: 16, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', - marginLeft: '-16px', - '@media (max-width: 640px)': { - marginLeft: '-12px', - }, + marginLeft: -spacing[400], }); -const caretIconStyles = css({ - color: '#CCCCCC', - fontSize: '12px', - lineHeight: '19px', - fontFamily: 'codicon', - userSelect: 'none', +const expandButtonStyles = css({ + margin: 0, + padding: 0, + border: 'none', + background: 'none', + display: 'flex', cursor: 'pointer', - transition: 'transform 0.1s ease', - '&:hover': { - color: '#FFFFFF', - }, }); const clickableRowStyle = css({ cursor: 'pointer', }); -const caretExpandedStyles = css({ - transform: 'rotate(90deg)', -}); - const childrenContainerStyles = css({ - paddingLeft: '16px', - '@media (max-width: 640px)': { - paddingLeft: '12px', - }, + paddingLeft: spacing[400], }); const keyValueContainerStyles = css({ display: 'flex', - alignItems: 'flex-start', - position: 'relative', flexWrap: 'wrap', - gap: '0', }); -const keyStyles = css({ - color: '#9CDCFE', - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - fontSize: '13px', - lineHeight: '19px', - fontWeight: 400, +const keyStylesBase = css({ + fontWeight: 'bold', whiteSpace: 'nowrap', - '@media (max-width: 640px)': { - fontSize: '12px', - }, }); -const colonStyles = css({ - color: '#D4D4D4', - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - fontSize: '13px', - lineHeight: '19px', - fontWeight: 400, - '@media (max-width: 640px)': { - fontSize: '12px', - }, +const keyStylesLight = css({ + color: palette.gray.dark3, }); -const valueStyles = css({ - color: '#CE9178', - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - fontSize: '13px', - lineHeight: '19px', - fontWeight: 400, - '@media (max-width: 640px)': { - fontSize: '12px', - }, +const keyStylesDark = css({ + color: palette.gray.light2, }); -const numberValueStyles = css({ - color: '#B5CEA8', - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - fontSize: '13px', - lineHeight: '19px', - fontWeight: 400, - '@media (max-width: 640px)': { - fontSize: '12px', - }, +const dividerStylesBase = css({ + userSelect: 'none', }); -const objectValueStyles = css({ - color: '#4EC9B0', - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - fontSize: '13px', - lineHeight: '19px', - fontWeight: 400, - '@media (max-width: 640px)': { - fontSize: '12px', - }, +const dividerStylesLight = css({ + color: palette.gray.dark1, }); -const commaStyles = css({ - color: '#D4D4D4', - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - fontSize: '13px', - lineHeight: '19px', - fontWeight: 400, - '@media (max-width: 640px)': { - fontSize: '12px', - }, +const dividerStylesDark = css({ + color: palette.gray.light1, }); interface DocumentTreeViewProps { @@ -188,8 +104,32 @@ interface TreeNode { } 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); @@ -281,6 +221,24 @@ const DocumentTreeView: React.FC = ({ document }) => { }); }; + 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) => { @@ -294,20 +252,13 @@ const DocumentTreeView: React.FC = ({ document }) => {
- {hasExpandableContent && ( - toggleExpanded(itemKey)} - > - › - - )} + {hasExpandableContent && renderExpandButton(isExpanded, itemKey)}
- + {formatValue(item, type)} - {!isLast && ,} + {!isLast && ,}
{hasExpandableContent && isExpanded && ( @@ -331,22 +282,15 @@ const DocumentTreeView: React.FC = ({ document }) => {
- {hasExpandableContent && ( - toggleExpanded(itemKey)} - > - › - - )} + {hasExpandableContent && renderExpandButton(isExpanded, itemKey)}
{key} - : - + + {formatValue(val, type)} - {!isLast && ,} + {!isLast && ,}
{hasExpandableContent && isExpanded && ( @@ -361,19 +305,6 @@ const DocumentTreeView: React.FC = ({ document }) => { return []; }; - const getValueClassName = (type: TreeNode['type']): string => { - if (type === 'number') { - return numberValueStyles; - } - if (type === 'object' || type === 'array') { - return objectValueStyles; - } - if (type === 'boolean' || type === 'null') { - return numberValueStyles; - } - return valueStyles; - }; - const renderClosingBracket = ( nodeType: TreeNode['type'], isLast: boolean @@ -381,10 +312,10 @@ const DocumentTreeView: React.FC = ({ document }) => {
- + {nodeType === 'array' ? ']' : '}'} - {!isLast && ,} + {!isLast && ,}
); @@ -420,18 +351,8 @@ const DocumentTreeView: React.FC = ({ document }) => { return formatValue(node.value, node.type, isExpanded); }; - const renderExpandCaret = ( - isExpanded: boolean - ): JSX.Element => ( - - › - - ); - const getRowClassName = (hasExpandableContent: boolean): string => - `${nodeRowStyles} ${hasExpandableContent ? clickableRowStyle : ''}`; + cx(nodeRowStyles, hasExpandableContent && clickableRowStyle); const createRowClickHandler = ( hasExpandableContent: boolean, @@ -444,7 +365,8 @@ const DocumentTreeView: React.FC = ({ document }) => { const hasExpandableContent = !isIdField && (node.type === 'object' || node.type === 'array'); const isExpanded = expandedKeys.has(node.key); - const displayClassName = isIdField ? valueStyles : getValueClassName(node.type); + // For _id field, use string color; otherwise use type-based color + const valueColor = getValueColor(isIdField ? 'string' : node.type); return (
@@ -453,15 +375,15 @@ const DocumentTreeView: React.FC = ({ document }) => { onClick={createRowClickHandler(hasExpandableContent, node.key)} >
- {hasExpandableContent && renderExpandCaret(isExpanded)} + {hasExpandableContent && renderExpandButton(isExpanded, node.key)}
"{node.key}" - : - + + {getNodeDisplayValue(node, isIdField, isExpanded)} - {!isExpanded && !isLast && ,} + {!isExpanded && !isLast && ,}
{hasExpandableContent && isExpanded && ( @@ -478,9 +400,9 @@ const DocumentTreeView: React.FC = ({ document }) => { return (
-
+ {nodes.map((node, index) => renderNode(node, index === nodes.length - 1))} -
+
); }; 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..61e296384 --- /dev/null +++ b/src/views/data-browsing-app/extension-app-message-constants.ts @@ -0,0 +1,63 @@ +// 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/preview-app.tsx b/src/views/data-browsing-app/preview-app.tsx index 072d3c69e..05ea3b8e8 100644 --- a/src/views/data-browsing-app/preview-app.tsx +++ b/src/views/data-browsing-app/preview-app.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useRef } from 'react'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; import { LeafyGreenProvider, Icon, @@ -11,93 +11,74 @@ import { spacing, } from '@mongodb-js/compass-components'; import { useDetectVsCodeDarkMode } from './use-detect-vscode-dark-mode'; +import { + PreviewMessageType, + type MessageFromExtensionToWebview, + type SortOption, +} from './extension-app-message-constants'; +import { + sendGetDocuments, + sendRefreshDocuments, + sendSortDocuments, +} from './vscode-api'; import DocumentTreeView from './document-tree-view'; -declare const acquireVsCodeApi: () => { - postMessage: (message: unknown) => void; - getState: () => unknown; - setState: (state: unknown) => void; -}; - interface PreviewDocument { [key: string]: unknown; } - -type SortOption = 'default' | 'asc' | 'desc'; type ViewType = 'tree' | 'json' | 'table'; const ITEMS_PER_PAGE_OPTIONS = [10, 25, 50, 100]; -// Styles const toolbarStyles = css({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: `${spacing[2]}px ${spacing[3]}px`, + padding: `${spacing[200]}px ${spacing[300]}px`, borderBottom: '1px solid var(--vscode-panel-border, #444)', - gap: spacing[3], + gap: spacing[300], flexWrap: 'wrap', }); -const toolbarLeftStyles = css({ - display: 'flex', - alignItems: 'center', - gap: spacing[2], -}); - -const toolbarRightStyles = css({ +const toolbarGroupStyles = css({ display: 'flex', alignItems: 'center', - gap: spacing[3], + gap: spacing[200], }); -const toolbarGroupStyles = css({ +const toolbarGroupWideStyles = css({ display: 'flex', alignItems: 'center', - gap: spacing[2], + gap: spacing[300], }); const toolbarLabelStyles = css({ - fontSize: '13px', + fontSize: 13, fontWeight: 500, }); const paginationInfoStyles = css({ - fontSize: '13px', + fontSize: 13, whiteSpace: 'nowrap', }); const selectWrapperStyles = css({ - // Style the select button to fit content width '& button': { width: 'auto', minWidth: 'unset', }, }); -const narrowSelectStyles = css({ - // Style the select button to fit content width - '& button': { - width: 'auto', - minWidth: 'unset', - }, -}); - -const settingsMenuStyles = css({ - position: 'relative', -}); - const refreshButtonStyles = css({ display: 'flex', alignItems: 'center', - gap: '4px', + gap: spacing[100], background: 'none', border: 'none', - color: 'inherit', cursor: 'pointer', - padding: '4px 8px', - borderRadius: '4px', - fontSize: '13px', + padding: `${spacing[100]}px ${spacing[200]}px`, + borderRadius: spacing[100], + fontSize: 13, fontWeight: 500, '&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.1)', @@ -107,7 +88,6 @@ const refreshButtonStyles = css({ const paginationArrowsStyles = css({ display: 'flex', alignItems: 'center', - gap: '0', }); const spinnerKeyframes = ` @@ -121,14 +101,13 @@ const loadingOverlayStyles = css({ display: 'flex', alignItems: 'center', justifyContent: 'center', - padding: '32px', + padding: spacing[600], flexDirection: 'column', - gap: '12px', + gap: spacing[300], }); const spinnerStyles = css({ animation: 'spin 1s linear infinite', - display: 'inline-block', }); const PreviewApp: React.FC = () => { @@ -163,17 +142,14 @@ const PreviewApp: React.FC = () => { const startItem = totalDocuments === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1; const endItem = Math.min(currentPage * itemsPerPage, totalDocuments); - // Store vscode API reference - acquireVsCodeApi should only be called once - const vscodeApi = useMemo(() => acquireVsCodeApi(), []); - // 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 = event.data; - if (message.command === 'LOAD_DOCUMENTS') { + 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); @@ -186,7 +162,7 @@ const PreviewApp: React.FC = () => { setCurrentPage(1); // Reset to first page when new documents are loaded setIsLoading(false); }, remainingTime); - } else if (message.command === 'REFRESH_ERROR') { + } else if (message.command === PreviewMessageType.refreshError) { const elapsed = Date.now() - loadingStartTimeRef.current; const remainingTime = Math.max(0, MIN_LOADING_DURATION_MS - elapsed); @@ -201,17 +177,17 @@ const PreviewApp: React.FC = () => { window.addEventListener('message', handleMessage); // Request initial documents - vscodeApi.postMessage({ command: 'GET_DOCUMENTS' }); + sendGetDocuments(); return () => { window.removeEventListener('message', handleMessage); }; - }, [vscodeApi]); + }, []); const handleRefresh = (): void => { loadingStartTimeRef.current = Date.now(); setIsLoading(true); - vscodeApi.postMessage({ command: 'REFRESH_DOCUMENTS' }); + sendRefreshDocuments(); }; const handlePrevPage = (): void => { @@ -231,7 +207,7 @@ const PreviewApp: React.FC = () => { setSortOption(newSortOption); loadingStartTimeRef.current = Date.now(); setIsLoading(true); - vscodeApi.postMessage({ command: 'SORT_DOCUMENTS', sort: newSortOption }); + sendSortDocuments(newSortOption); }; const handleItemsPerPageChange = (value: string): void => { @@ -261,7 +237,7 @@ const PreviewApp: React.FC = () => { {/* Toolbar */}
{/* Left side - Insert Document */} -
+
{
{/* Right side - Actions */} -
+
{/* Refresh - single button with icon and text */}
{/* Items per page */} -
+