diff --git a/images/dark/search-icon.svg b/images/dark/search-icon.svg new file mode 100644 index 00000000..5f31154b --- /dev/null +++ b/images/dark/search-icon.svg @@ -0,0 +1 @@ + diff --git a/images/light/search-icon.svg b/images/light/search-icon.svg new file mode 100644 index 00000000..8a7fbf3f --- /dev/null +++ b/images/light/search-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index b149c708..3039ec6a 100644 --- a/package.json +++ b/package.json @@ -357,6 +357,18 @@ "SQL++" ], "configuration": "./language/language-configuration.json" + }, + + { + "id":"searchQuery", + "extensions": [ + ".cbs.json" + ], + "aliases": [ + "SEARCH" + ], + "configuration": "./language/language-configuration.json" + } ], "grammars": [ @@ -434,6 +446,12 @@ "category": "Couchbase", "icon": "images/create.svg" }, + { + "command": "vscode-couchbase.openSearchWorkbench", + "title": "New Search Workbench", + "category": "Couchbase", + "icon": "images/create.svg" + }, { "command": "vscode-couchbase.openQueryWorkbench", "title": "New Workbench", @@ -546,6 +564,14 @@ "title": "Show Output Console", "category": "Couchbase" }, + { + "command": "vscode-couchbase.runSearch", + "title": "Run Search", + "category": "Couchbase", + "icon": "$(play)", + "tooltip": "Run Search", + "enablement": "vscode-couchbase.runSearchButtonEnabled" + }, { "command": "vscode-couchbase.runQuery", "title": "Run Query", @@ -569,6 +595,12 @@ "title": "MongoDB Migrate", "category": "Couchbase" }, + { + "title": "Search Context", + "command": "vscode-couchbase.searchContext", + "category": "Couchbase", + "icon": "$(group-by-ref-type)" + }, { "command": "vscode-couchbase.tools.dataImport", "title": "Data Import", @@ -695,6 +727,12 @@ "key": "ctrl+shift+e", "mac": "cmd+shift+e", "when": "(editorLangId == sqlpp || resourceFilename =~ /.sqlpp$/) && !isKVCluster" + }, + { + "command": "vscode-couchbase.runSearch", + "key": "ctrl+shift+e", + "mac": "cmd+shift+e", + "when": "(editorLangId == cbs.json || resourceFilename =~ /.cbs.json$/) && !isKVCluster" } ], "menus": { @@ -705,6 +743,12 @@ "when": "(editorLangId == sqlpp || resourceFilename =~ /.sqlpp$/) && !isKVCluster", "group": "navigation@1" }, + { + "command": "vscode-couchbase.runSearch", + "category": "Couchbase", + "when": "(editorLangId == cbs.json || resourceFilename =~ /.cbs.json$/) && !isKVCluster", + "group": "navigation@1" + }, { "title": "Query Context", "command": "vscode-couchbase.queryContext", @@ -712,6 +756,13 @@ "group": "navigation@2", "when": "(editorLangId == sqlpp || resourceFilename =~ /.sqlpp$/) && !isKVCluster" }, + { + "title": "Search Context", + "command": "vscode-couchbase.searchContext", + "category": "Couchbase", + "group": "navigation@2", + "when": "(editorLangId == cbs.json || resourceFilename =~ /.cbs.json$/) && !isKVCluster" + }, { "title": "Show Favorite Queries", "command": "vscode-couchbase.showFavoriteQueries", @@ -760,6 +811,11 @@ "command": "vscode-couchbase.runQuery", "when": "(editorLangId == sqlpp || resourceFilename =~ /.sqlpp$/) && !isKVCluster", "group": "navigation@1" + }, + { + "command": "vscode-couchbase.runSearch", + "when": "(editorLangId == .cbs.json || resourceFilename =~ /.cbs.json$/) && !isKVCluster", + "group": "navigation@1" } ], "commandPalette": [ @@ -779,6 +835,10 @@ "command": "vscode-couchbase.openQueryWorkbench", "when": "false" }, + { + "command": "vscode-couchbase.openSearchWorkbench", + "when": "false" + }, { "command": "vscode-couchbase.deleteClusterConnection", "when": "false" @@ -862,6 +922,10 @@ "command": "vscode-couchbase.queryContext", "when": "false" }, + { + "command": "vscode-couchbase.searchContext", + "when": "false" + }, { "command": "vscode-couchbase.showFavoriteQueries", "when": "false" @@ -957,6 +1021,11 @@ "when": "view == couchbase && viewItem == active_connection && !isKVCluster", "group": "workbench@1" }, + { + "command": "vscode-couchbase.openSearchWorkbench", + "when": "view == couchbase && viewItem == searchIndex && !isKVCluster", + "group": "workbench@1" + }, { "submenu": "vscode-couchbase.toolsMenu", "when": "view == couchbase && viewItem == active_connection" diff --git a/src/commands/extensionCommands/commands.ts b/src/commands/extensionCommands/commands.ts index eb74b5f1..2c4d7ecb 100644 --- a/src/commands/extensionCommands/commands.ts +++ b/src/commands/extensionCommands/commands.ts @@ -29,6 +29,7 @@ export namespace Commands { export const getBucketMetaData: string = "vscode-couchbase.getBucketInfo"; export const createDocument: string = "vscode-couchbase.createDocument"; export const openDocument: string = "vscode-couchbase.openDocument"; + export const openSearchIndex: string = "vscode-couchbase.openSearchIndex"; export const removeDocument: string = "vscode-couchbase.removeDocument"; export const getDocumentMetaData: string = "vscode-couchbase.getDocumentMetaData"; export const searchDocument: string = "vscode-couchbase.searchDocument"; @@ -36,13 +37,17 @@ export namespace Commands { export const refreshIndexes: string = "vscode-couchbase.refreshIndexes"; export const openQueryNotebook: string = "vscode-couchbase.openQueryNotebook"; export const openQueryWorkbench: string = "vscode-couchbase.openQueryWorkbench"; + export const openSearchWorkbench: string = "vscode-couchbase.openSearchWorkbench"; export const getSampleProjects: string = "vscode-couchbase.openSampleProjects"; export const loadMore: string = "vscode-couchbase.loadMore"; export const showOutputConsole: string = "vscode-couchbase.showOutputConsole"; export const runQuery: string = "vscode-couchbase.runQuery"; + export const runSearchQuery: string = "vscode-couchbase.runSearch"; export const queryWorkbench: string = "vscode-couchbase.couchbase-query-workbench"; + export const searchWorkbench: string = "vscode-couchbase.couchbase-search-workbench"; export const getClusterOverview: string = "vscode-couchbase.getClusterOverview"; export const queryContext: string = "vscode-couchbase.queryContext"; + export const searchContext: string = "vscode-couchbase.searchContext"; export const showFavoriteQueries: string = "vscode-couchbase.showFavoriteQueries"; export const markFavoriteQuery: string = "vscode-couchbase.markFavoriteQuery"; export const showNamedParameters: string = "vscode-couchbase.showNamedParameters"; diff --git a/src/commands/fts/SearchWorkbench/controller.ts b/src/commands/fts/SearchWorkbench/controller.ts new file mode 100644 index 00000000..1195d81e --- /dev/null +++ b/src/commands/fts/SearchWorkbench/controller.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2011-2020 Couchbase, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as vscode from 'vscode'; +import { MemFS } from '../../../util/fileSystemProvider'; +import SearchIndexNode from '../../../model/SearchIndexNode'; + +class SearchJsonDocumentContentProvider implements vscode.TextDocumentContentProvider { + provideTextDocumentContent(uri: vscode.Uri): string { + return ''; + } +} + +export default class UntitledSearchJsonDocumentService { + public searchJsonProvider = new SearchJsonDocumentContentProvider(); + private untitledCount: number = 1; + private searchJsonScheme: string = '.cbs.json'; + + public disposable = vscode.workspace.registerTextDocumentContentProvider(this.searchJsonScheme, this.searchJsonProvider); + + constructor() { + } + + + + public async openSearchJsonTextDocument(searchIndexNode: SearchIndexNode,memFs: MemFS): Promise { + const uri = vscode.Uri.parse(`couchbase:/search-workbench-${searchIndexNode.searchIndexName}-${this.untitledCount}.cbs.json`); + this.untitledCount++; + let documentContent = Buffer.from(''); + memFs.writeFile(uri, documentContent, { + create: true, + overwrite: true, + }); + + + const document = await vscode.workspace.openTextDocument(uri); + document.save(); + await vscode.window.showTextDocument(document, { preview: false }); + return await vscode.window.showTextDocument(document, { preview: false }); + } + + + public showTextDocument(document: vscode.TextDocument, column?: vscode.ViewColumn, preserveFocus?: boolean): Thenable { + return vscode.window.showTextDocument(document, column, preserveFocus); + } + + + public newQuery(searchIndexNode: SearchIndexNode, memFs: MemFS): Promise { + return new Promise((resolve, reject) => { + this.openSearchJsonTextDocument(searchIndexNode, memFs).then(resolve).catch(reject); + }); + } +} \ No newline at end of file diff --git a/src/commands/fts/SearchWorkbench/openSearchIndex.ts b/src/commands/fts/SearchWorkbench/openSearchIndex.ts new file mode 100644 index 00000000..37c042ab --- /dev/null +++ b/src/commands/fts/SearchWorkbench/openSearchIndex.ts @@ -0,0 +1,51 @@ +import * as vscode from "vscode"; +import SearchIndexNode from "../../../model/SearchIndexNode"; +import { DocumentNotFoundError } from "couchbase/dist/errors"; +import { logger } from "../../../logger/logger"; +import { MemFS } from "../../../util/fileSystemProvider"; +import ClusterConnectionTreeProvider from "../../../tree/ClusterConnectionTreeProvider"; +import { getActiveConnection } from "../../../util/connections"; +import { CouchbaseRestAPI } from "../../../util/apis/CouchbaseRestAPI"; + +export const openSearchIndex = async (searchIndexNode: SearchIndexNode, clusterConnectionTreeProvider: ClusterConnectionTreeProvider, uriToCasMap: Map, memFs: MemFS) => { + try { + const connection = getActiveConnection(); + if (!connection) { + return false; + } + const api = new CouchbaseRestAPI(connection); + const result = await api.fetchSearchIndexDefinition(searchIndexNode.indexName); + + if (result?.indexDef instanceof Uint8Array || result?.indexDef instanceof Uint16Array || result?.indexDef instanceof Uint32Array) { + vscode.window.showInformationMessage("Unable to open Index definition: It is not a valid JSON", { modal: true }); + return false; + } + const uri = vscode.Uri.parse( + `couchbase:/${searchIndexNode.bucketName}/${searchIndexNode.scopeName}/Search/${searchIndexNode.indexName}.json` + ); + if (result) { + uriToCasMap.set(uri.toString(), result.indexDef.toString()); + } + try { + memFs.writeFile( + uri, + Buffer.from(JSON.stringify(result?.indexDef, null, 2)), + { create: true, overwrite: true } + ); + } catch (error) { + vscode.window.showInformationMessage("Unable to open Index definition: It is not a valid JSON ", { modal: true }); + return false; + } + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { preview: false }); + return true; + } catch (err: any) { + if (err instanceof vscode.FileSystemError && err.name === 'EntryNotFound (FileSystemError)' || err instanceof DocumentNotFoundError) { + clusterConnectionTreeProvider.refresh(); + } + logger.error("Failed to open Document"); + logger.debug(err); + } + + +} \ No newline at end of file diff --git a/src/commands/fts/SearchWorkbench/searchWorkbench.ts b/src/commands/fts/SearchWorkbench/searchWorkbench.ts new file mode 100644 index 00000000..18506303 --- /dev/null +++ b/src/commands/fts/SearchWorkbench/searchWorkbench.ts @@ -0,0 +1,105 @@ +import * as vscode from 'vscode'; +import { getActiveConnection } from '../../../util/connections'; +import UntitledSqlppDocumentService from './controller'; +import { WorkbenchWebviewProvider } from '../../../workbench/workbenchWebviewProvider'; +import { CouchbaseError, QueryOptions, QueryProfileMode, QueryStatus } from "couchbase"; +import { ISearchQueryContext } from '../../../types/ISearchQueryContext'; +import { CouchbaseRestAPI } from '../../../util/apis/CouchbaseRestAPI'; +import { MemFS } from "../../../util/fileSystemProvider"; +import UntitledSearchJsonDocumentService from './controller'; +import SearchIndexNode from '../../../model/SearchIndexNode'; + +export class SearchWorkbench { + private _untitledSearchJsonDocumentService: UntitledSearchJsonDocumentService; + public editorToContext: Map; + + constructor() { + this._untitledSearchJsonDocumentService = + new UntitledSearchJsonDocumentService(); + this.editorToContext = new Map(); + } + + + runCouchbaseSearchQuery = async ( + workbenchWebviewProvider: WorkbenchWebviewProvider + ) => { + const connection = getActiveConnection(); + if (!connection) { + vscode.window.showInformationMessage( + "Kindly establish a connection with the cluster before running an index query." + ); + return false; + } + + // Get the active text editor + const activeTextEditor = vscode.window.activeTextEditor; + if (activeTextEditor && activeTextEditor.document.languageId === "searchQuery") { + + activeTextEditor.document.save(); + const indexQueryPayload = activeTextEditor.selection.isEmpty ? activeTextEditor.document.getText() : activeTextEditor.document.getText(activeTextEditor.selection); + const queryContext = this.editorToContext.get(activeTextEditor.document.uri.toString()); + + try { + // Reveal the webview when the extension is activated + vscode.commands.executeCommand('workbench.view.extension.couchbase-workbench-panel'); + vscode.commands.executeCommand("workbench.action.focusPanel"); + await new Promise((resolve) => setTimeout(resolve, 500)); + await workbenchWebviewProvider.sendQueryResult(JSON.stringify([{ "status": "Executing statement" }]), { queryStatus: QueryStatus.Running }, null); + const explainPlan = JSON.stringify(""); + const couchbbaseRestAPI = new CouchbaseRestAPI(connection); + const searchQueryResult = await couchbbaseRestAPI.runSearchIndexes(queryContext?.indexName, indexQueryPayload); + workbenchWebviewProvider.setSearchQueryResult( + JSON.stringify(searchQueryResult?.hits), + {}, + explainPlan + ); + } catch (err) { + const errorArray = []; + if (err instanceof CouchbaseError) { + const { first_error_code, first_error_message, statement } = + err.cause as any; + if ( + first_error_code !== undefined || + first_error_message !== undefined || + statement !== undefined + ) { + errorArray.push({ + code: first_error_code, + msg: first_error_message, + query: statement, + }); + } else { + errorArray.push(err); + } + } else { + errorArray.push(err); + } + const queryStatusProps = { + queryStatus: QueryStatus.Fatal, + rtt: "-", + elapsed: "-", + executionTime: "-", + numDocs: "-", + size: "-", + }; + workbenchWebviewProvider.setQueryResult( + JSON.stringify(errorArray), + queryStatusProps, + null + ); + } + + } + + } + openSearchWorkbench(searchIndexNode: SearchIndexNode, memFs: MemFS) { + try { + return this._untitledSearchJsonDocumentService.newQuery(searchIndexNode, memFs); + } catch (error) { + console.log("Error in openSearchWorkbench:", error); + throw error; + } + } + + +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index d3ff3f04..be9a9baa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -52,7 +52,7 @@ import { WorkbenchWebviewProvider } from "./workbench/workbenchWebviewProvider"; import { fetchClusterOverview } from "./pages/overviewCluster/overviewCluster"; import DependenciesDownloader from "./handlers/handleCLIDownloader"; import { sqlppFormatter } from "./commands/formatting/sqlppFormatter"; -import { fetchQueryContext } from "./pages/queryContext/queryContext"; +import { fetchQueryContext, fetchSearchContext } from "./pages/queryContext/queryContext"; import { fetchFavoriteQueries } from "./pages/FavoriteQueries/FavoriteQueries"; import { markFavoriteQuery } from "./commands/favoriteQueries/markFavoriteQuery"; import { QueryHistoryTreeProvider } from "./tree/QueryHistoryTreeProvider"; @@ -76,6 +76,10 @@ import { newChatHandler } from "./commands/iq/chat/newChatHandler"; import { SecretService } from "./util/secretService"; import { kvTypeFilterDocuments } from "./commands/documents/documentFilters/kvTypeFilterDocuments"; import { fetchNamedParameters } from "./pages/namedParameters/namedParameters"; +import { SearchWorkbench } from "./commands/fts/SearchWorkbench/searchWorkbench"; +import SearchIndexNode from "./model/SearchIndexNode"; +import { openSearchIndex } from "./commands/fts/SearchWorkbench/openSearchIndex"; +import { handleSearchContextStatusbar } from "./handlers/handleSearchQueryContextStatusBar"; export function activate(context: vscode.ExtensionContext) { Global.setState(context.globalState); @@ -92,6 +96,10 @@ export function activate(context: vscode.ExtensionContext) { const uriToCasMap = new Map(); const workbench = new QueryWorkbench(); + const searchWorkbench = new SearchWorkbench(); + + let currentSearchIndexNode: SearchIndexNode; + let currentSearchWorkbench: SearchWorkbench; const subscriptions = context.subscriptions; const cacheService = new CacheService(); @@ -185,7 +193,8 @@ export function activate(context: vscode.ExtensionContext) { if ( editor && editor.document.languageId === "json" && - editor.document.uri.scheme === "couchbase" + editor.document.uri.scheme === "couchbase" && + !editor.document.uri.path.includes("Search") ) { await handleActiveEditorChange(editor, uriToCasMap, memFs); } @@ -291,6 +300,15 @@ export function activate(context: vscode.ExtensionContext) { ) ); + subscriptions.push( + vscode.commands.registerCommand( + Commands.openSearchIndex, + async (searchIndexNode: SearchIndexNode) => { + await openSearchIndex(searchIndexNode, clusterConnectionTreeProvider, uriToCasMap, memFs); + } + ) + ); + subscriptions.push( vscode.commands.registerCommand( Commands.openIndexInfo, @@ -520,6 +538,10 @@ export function activate(context: vscode.ExtensionContext) { ) ); + // Initialize the global status bar item which will be used for query and search query context + let globalStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 1000); + globalStatusBarItem.hide(); + vscode.languages.registerDocumentFormattingEditProvider('SQL++', { provideDocumentFormattingEdits(document: vscode.TextDocument): vscode.TextEdit[] { return sqlppFormatter(document); @@ -573,19 +595,19 @@ export function activate(context: vscode.ExtensionContext) { subscriptions.push( vscode.commands.registerCommand(Commands.queryContext, () => { - fetchQueryContext(workbench, context); + fetchQueryContext(workbench, context, globalStatusBarItem); }) ); // subscription to make sure query context status bar is only visible on sqlpp files subscriptions.push( vscode.window.onDidChangeActiveTextEditor(async (editor) => { - await handleQueryContextStatusbar(editor, workbench); + await handleQueryContextStatusbar(editor, workbench, globalStatusBarItem); }) ); - // Handle initial view of context status bar + // // Handle initial view of context status bar const activeEditor = vscode.window.activeTextEditor; - handleQueryContextStatusbar(activeEditor, workbench); + handleQueryContextStatusbar(activeEditor, workbench, globalStatusBarItem); subscriptions.push( vscode.commands.registerCommand( @@ -609,7 +631,7 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand( Commands.showNamedParameters, () => { - fetchNamedParameters(); + fetchNamedParameters(); } ) ); @@ -668,6 +690,37 @@ export function activate(context: vscode.ExtensionContext) { }) ); + const searchContextCommand = vscode.commands.registerCommand(Commands.searchContext, () => { + fetchSearchContext(currentSearchIndexNode, currentSearchWorkbench, context, globalStatusBarItem); + }); + context.subscriptions.push(searchContextCommand); + + + + const openSearchWorkbenchCommand = vscode.commands.registerCommand(Commands.openSearchWorkbench, async (searchIndexNode: SearchIndexNode) => { + const connection = Memory.state.get(Constants.ACTIVE_CONNECTION); + if (!connection) { + vscode.window.showErrorMessage("No active connection available."); + return; + } + + currentSearchIndexNode = searchIndexNode; + currentSearchWorkbench = searchWorkbench; + + searchWorkbench.openSearchWorkbench(searchIndexNode, memFs); + + const editorChangeSubscription = vscode.window.onDidChangeActiveTextEditor(async (editor) => { + if (editor && editor.document.languageId === "searchQuery") { + await handleSearchContextStatusbar(editor, searchIndexNode, searchWorkbench, globalStatusBarItem); + } + }); + context.subscriptions.push(editorChangeSubscription); + + }); + context.subscriptions.push(openSearchWorkbenchCommand); + + + context.subscriptions.push( vscode.workspace.registerNotebookSerializer( Constants.notebookType, new QueryContentSerializer(), { transientOutputs: true } @@ -699,6 +752,30 @@ export function activate(context: vscode.ExtensionContext) { true ); // Required to enable run query button at the start + context.subscriptions.push( + vscode.commands.registerCommand(Commands.runSearchQuery, async (searchIndexNode: SearchIndexNode) => { + vscode.commands.executeCommand( + "setContext", + "vscode-couchbase.runSearchButtonEnabled", + undefined + ); + await searchWorkbench.runCouchbaseSearchQuery( + workbenchWebviewProvider + ); + vscode.commands.executeCommand( + "setContext", + "vscode-couchbase.runSearchButtonEnabled", + true + ); + }) + ); + vscode.commands.executeCommand( + "setContext", + "vscode-couchbase.runSearchButtonEnabled", + true + ); // Required to enable run search query button at the start + + context.subscriptions.push( vscode.commands.registerCommand( Commands.checkAndCreatePrimaryIndex, diff --git a/src/handlers/handleQueryContextStatusbar.ts b/src/handlers/handleQueryContextStatusbar.ts index 1f6c1f3a..df4895f9 100644 --- a/src/handlers/handleQueryContextStatusbar.ts +++ b/src/handlers/handleQueryContextStatusbar.ts @@ -1,12 +1,12 @@ import * as vscode from 'vscode'; import { QueryWorkbench } from '../workbench/queryWorkbench'; -import { hideQueryContextStatusbar, showQueryContextStatusbar } from '../util/queryContextUtils'; -export const handleQueryContextStatusbar = async (editor: vscode.TextEditor | undefined, workbench: QueryWorkbench) => { +import { showQueryContextStatusbar } from '../util/queryContextUtils'; +export const handleQueryContextStatusbar = async (editor: vscode.TextEditor | undefined, workbench: QueryWorkbench, globalStatusBarItem: vscode.StatusBarItem) => { if( editor && editor.document.languageId === "SQL++"){ // Case 1: Show Status bar - showQueryContextStatusbar(editor, workbench); + showQueryContextStatusbar(editor, workbench, globalStatusBarItem); } else { // Case 2: Don't show status bar - hideQueryContextStatusbar(); + globalStatusBarItem.hide(); } }; \ No newline at end of file diff --git a/src/handlers/handleSearchQueryContextStatusBar.ts b/src/handlers/handleSearchQueryContextStatusBar.ts new file mode 100644 index 00000000..478e6eb6 --- /dev/null +++ b/src/handlers/handleSearchQueryContextStatusBar.ts @@ -0,0 +1,14 @@ +import * as vscode from 'vscode'; +import { SearchWorkbench } from '../commands/fts/SearchWorkbench/searchWorkbench'; +import { showSearchContextStatusbar } from '../util/queryContextUtils'; +import SearchIndexNode from '../model/SearchIndexNode'; + +export const handleSearchContextStatusbar = async (editor: vscode.TextEditor | undefined,searchNode:SearchIndexNode, workbench:SearchWorkbench, globalStatusBarItem: vscode.StatusBarItem) => { + if( editor && editor.document.languageId === "searchQuery"){ + // Case 1: Show Status bar + showSearchContextStatusbar(editor,searchNode, workbench, globalStatusBarItem); + } else { + // Case 2: Don't show status bar + globalStatusBarItem.hide(); + } +}; \ No newline at end of file diff --git a/src/model/CollectionDirectory.ts b/src/model/CollectionDirectory.ts new file mode 100644 index 00000000..fbcba068 --- /dev/null +++ b/src/model/CollectionDirectory.ts @@ -0,0 +1,99 @@ +import * as vscode from "vscode"; +import { IConnection } from "../types/IConnection"; +import { INode } from "../types/INode"; +import { logger } from "../logger/logger"; +import { CouchbaseRestAPI } from "../util/apis/CouchbaseRestAPI"; +import { CacheService } from "../util/cacheService/cacheService"; +import { getActiveConnection } from "../util/connections"; +import { Memory } from "../util/util"; +import { hasQueryService } from "../util/common"; +import { ParsingFailureError, PlanningFailureError } from "couchbase"; +import CollectionNode from "./CollectionNode"; +import InformationNode from "./InformationNode"; + +export class CollectionDirectory implements INode { + constructor( + public readonly parentNode: INode, + public readonly scopeName: string, + public readonly bucketName: string, + public readonly collections: any[], + public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public cacheService: CacheService + ) {} + + public getTreeItem(): vscode.TreeItem { + return { + label: `Collections`, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + contextValue: "collectionDirectory", + }; + } + + public async getChildren(): Promise { + const connection = getActiveConnection(); + if (!connection) { + return []; + } + const collectionList: any[] = []; + const couchbaseRestAPI = new CouchbaseRestAPI(connection); + const KVCollectionCount: Map = await couchbaseRestAPI.getKVDocumentCount(this.bucketName, this.scopeName); + + for (const collection of this.collections) { + try { + const queryTypeFilter = Memory.state.get( + `queryTypeFilterDocuments-${connection.connectionIdentifier}-${this.bucketName}-${this.scopeName}-${collection.name}` + ) ?? ""; + + const filterDocumentsType = Memory.state.get( + `filterDocumentsType-${connection.connectionIdentifier}-${this.bucketName}-${this.scopeName}-${collection.name}` + ) ?? ""; + + let rowCount = 0; + try { + if (!hasQueryService(connection?.services) || filterDocumentsType !== "query") { + rowCount = KVCollectionCount.get(`kv_collection_item_count-${this.bucketName}-${this.scopeName}-${collection.name}`) ?? 0; + + if(filterDocumentsType === "kv") { + rowCount = -1; + } + } + else { + const queryResult = await connection?.cluster?.query( + `select count(1) as count from \`${this.bucketName}\`.\`${this.scopeName + }\`.\`${collection.name}\` ${queryTypeFilter.length > 0 ? "WHERE " + queryTypeFilter : "" + };` + ); + rowCount = queryResult?.rows[0].count; + } + } catch (err: any) { + if (err instanceof PlanningFailureError) { + vscode.window.showErrorMessage( + "Unable to find primary index for document and filter seems to be applied, showing count as 0" + ); + } else if (err instanceof ParsingFailureError) { + logger.error(`In Scope Node: ${this.scopeName}: Parsing Failed: Incorrect filter definition`); + } + } + + const collectionTreeItem = new CollectionNode( + this, + this.scopeName, + rowCount, + this.bucketName, + collection.name, + vscode.TreeItemCollapsibleState.None, + this.cacheService + ); + collectionList.push(collectionTreeItem); + } catch (err: any) { + logger.error("Failed to load Collections"); + logger.debug(err); + throw new Error(err); + } + } + if (collectionList.length === 0) { + collectionList.push(new InformationNode("No Collections found")); + } + return collectionList; + } +} \ No newline at end of file diff --git a/src/model/ScopeNode.ts b/src/model/ScopeNode.ts index 265e3295..51bebee4 100644 --- a/src/model/ScopeNode.ts +++ b/src/model/ScopeNode.ts @@ -16,15 +16,10 @@ import * as vscode from "vscode"; import * as path from "path"; import { INode } from "../types/INode"; -import { Memory } from "../util/util"; import { getActiveConnection } from "../util/connections"; -import CollectionNode from "./CollectionNode"; -import { logger } from "../logger/logger"; -import InformationNode from "./InformationNode"; -import { ParsingFailureError, PlanningFailureError } from "couchbase"; -import { hasQueryService } from "../util/common"; -import { CouchbaseRestAPI } from "../util/apis/CouchbaseRestAPI"; import { CacheService } from "../../src/util/cacheService/cacheService"; +import { SearchDirectory } from "./SearchDirectory"; +import { CollectionDirectory } from "./CollectionDirectory"; export class ScopeNode implements INode { @@ -35,7 +30,13 @@ export class ScopeNode implements INode { public readonly collections: any[], public readonly collapsibleState: vscode.TreeItemCollapsibleState, public cacheService: CacheService - ) { } + ) { + vscode.workspace.fs.createDirectory( + vscode.Uri.parse( + `couchbase:/${bucketName}/${scopeName}/Search` + ) + ); +} public getTreeItem(): vscode.TreeItem { return { @@ -64,70 +65,36 @@ export class ScopeNode implements INode { * @returns Two Directory one contains Index definitions and other contains Collections * */ public async getChildren(): Promise { + const connection = getActiveConnection(); if (!connection) { return []; } - const collectionList: any[] = []; - const couchbaseRestAPI = new CouchbaseRestAPI(connection); - const KVCollectionCount: Map = await couchbaseRestAPI.getKVDocumentCount(this.bucketName, this.scopeName); - for (const collection of this.collections) { - try { - const queryTypeFilter = Memory.state.get( - `queryTypeFilterDocuments-${connection.connectionIdentifier}-${this.bucketName}-${this.scopeName}-${collection.name}` - ) ?? ""; + const childrenDirectoriesList: INode[] = []; - const filterDocumentsType = Memory.state.get( - `filterDocumentsType-${connection.connectionIdentifier}-${this.bucketName}-${this.scopeName}-${collection.name}` - ) ?? ""; + // Search Directory + childrenDirectoriesList.push( + new SearchDirectory( + this, + "Search", + this.bucketName, + this.scopeName + ) + ); - let rowCount = 0; - try { - if (!hasQueryService(connection?.services) || filterDocumentsType !== "query") { - rowCount = KVCollectionCount.get(`kv_collection_item_count-${this.bucketName}-${this.scopeName}-${collection.name}`) ?? 0; + // Collections Directory + const collectionDirectory = new CollectionDirectory( + this, + this.scopeName, + this.bucketName, + this.collections, + vscode.TreeItemCollapsibleState.Collapsed, + this.cacheService + ); + childrenDirectoriesList.push(collectionDirectory); - if(filterDocumentsType === "kv") { - rowCount = -1; - } - } - else { - const queryResult = await connection?.cluster?.query( - `select count(1) as count from \`${this.bucketName}\`.\`${this.scopeName - }\`.\`${collection.name}\` ${queryTypeFilter.length > 0 ? "WHERE " + queryTypeFilter : "" - };` - ); - rowCount = queryResult?.rows[0].count; - } - } catch (err: any) { - if (err instanceof PlanningFailureError) { - vscode.window.showErrorMessage( - "Unable to find primary index for document and filter seems to be applied, showing count as 0" - ); - } else if (err instanceof ParsingFailureError) { - logger.error(`In Scope Node: ${this.scopeName}: Parsing Failed: Incorrect filter definition`); - } - } + return childrenDirectoriesList; - const collectionTreeItem = new CollectionNode( - this, - this.scopeName, - rowCount, - this.bucketName, - collection.name, - vscode.TreeItemCollapsibleState.None, - this.cacheService - ); - collectionList.push(collectionTreeItem); - } catch (err: any) { - logger.error("Failed to load Collections"); - logger.debug(err); - throw new Error(err); - } - } - if (collectionList.length === 0) { - collectionList.push(new InformationNode("No Collections found")); - } - return collectionList; } -} +} \ No newline at end of file diff --git a/src/model/SearchDirectory.ts b/src/model/SearchDirectory.ts new file mode 100644 index 00000000..fcb1ef87 --- /dev/null +++ b/src/model/SearchDirectory.ts @@ -0,0 +1,78 @@ +import { TreeItem, TreeItemCollapsibleState } from "vscode"; +import { IConnection } from "../types/IConnection"; +import { INode } from "../types/INode"; +import SearchIndexNode from "./SearchIndexNode"; +import { logger } from "../logger/logger"; +import InformationNode from "./InformationNode"; +import { CouchbaseRestAPI } from "../util/apis/CouchbaseRestAPI"; +import { getActiveConnection } from "../util/connections"; +import * as path from "path"; + +export class SearchDirectory implements INode { + constructor(public readonly parentNode: INode, + public readonly itemName: string, + public readonly bucketName: string, + public readonly scopeName: string) { + } + + public getTreeItem(): TreeItem { + return { + label: `${this.itemName}`, + collapsibleState: TreeItemCollapsibleState.Collapsed, + contextValue: "searchDirectory", + iconPath: { + light: path.join( + __filename, + "..", + "..", + "images/light", + "search-icon.svg" + ), + dark: path.join( + __filename, + "..", + "..", + "images/dark", + "search-icon.svg" + ), + } + }; + } + + public async getChildren(): Promise { + try { + // get all search indexes + const connection = getActiveConnection(); + if (!connection){ + + return [] + } + const searchIndexesManager = connection?.cluster?.searchIndexes(); + const ftsIndexes = await searchIndexesManager?.getAllIndexes(); + const bucketIndexes = ftsIndexes?.filter(index => index.sourceName === this.bucketName); + if (bucketIndexes === undefined) { + return []; + } + const searchIndexChildren: INode[] = bucketIndexes.map((searchIndex) => + new SearchIndexNode( + searchIndex.name, + this.bucketName, + this.scopeName, + searchIndex.name + ) + + + + ) || []; + + if (searchIndexChildren.length === 0) { + searchIndexChildren.push(new InformationNode("No search indexes found")); + } + return searchIndexChildren; + } + catch (error) { + logger.error(`Error getting search indexes: ${error}`); + return []; + } + } +} diff --git a/src/model/SearchIndexNode.ts b/src/model/SearchIndexNode.ts new file mode 100644 index 00000000..53f0e35a --- /dev/null +++ b/src/model/SearchIndexNode.ts @@ -0,0 +1,44 @@ +import * as vscode from "vscode"; +import { INode } from "../types/INode"; +import * as path from "path"; + +export default class SearchIndexNode implements INode { + constructor( + public readonly searchIndexName: string, + public readonly bucketName: string, + public readonly scopeName: string, + public readonly indexName: string, + ) { } + public async getTreeItem(): Promise { + return { + label: `${this.searchIndexName}`, + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextValue: "searchIndex", + command: { + command: "vscode-couchbase.openSearchIndex", + title: "Open Search Index", + arguments: [this], + }, + iconPath: { + light: path.join( + __filename, + "..", + "..", + "images/light", + "document.svg" + ), + dark: path.join( + __filename, + "..", + "..", + "images/dark", + "document.svg" + ), + }, + }; + } + + public async getChildren(): Promise { + return []; + } +} \ No newline at end of file diff --git a/src/pages/queryContext/queryContext.ts b/src/pages/queryContext/queryContext.ts index 8d78dea8..30b5051c 100644 --- a/src/pages/queryContext/queryContext.ts +++ b/src/pages/queryContext/queryContext.ts @@ -5,8 +5,10 @@ import { logger } from "../../logger/logger"; import { Bucket, BucketSettings } from "couchbase"; import { QueryWorkbench } from "../../workbench/queryWorkbench"; import { showQueryContextStatusbar } from "../../util/queryContextUtils"; -import { Constants } from "../../util/constants"; import { getActiveConnection } from "../../util/connections"; +import { SearchWorkbench } from "../../commands/fts/SearchWorkbench/searchWorkbench"; +import SearchIndexNode from "../../model/SearchIndexNode"; +import { Commands } from "../../commands/extensionCommands/commands"; const fetchBucketNames = (bucketsSettings: BucketSettings[] | undefined, connection: IConnection): Array => { const allBuckets: Array = []; @@ -22,7 +24,7 @@ const fetchBucketNames = (bucketsSettings: BucketSettings[] | undefined, connect return allBuckets; }; -export async function fetchQueryContext(workbench: QueryWorkbench, context: vscode.ExtensionContext) { +export async function fetchQueryContext(workbench: QueryWorkbench, context: vscode.ExtensionContext, globalStatusBarItem:any) { const connection = getActiveConnection(); if (!connection) { @@ -36,7 +38,7 @@ export async function fetchQueryContext(workbench: QueryWorkbench, context: vsco !(activeEditor && activeEditor.document.languageId === "SQL++") ) { - vscode.window.showErrorMessage("workbench is not active"); + vscode.window.showErrorMessage("Please ensure that the workbench is open/active"); return; } @@ -67,7 +69,7 @@ export async function fetchQueryContext(workbench: QueryWorkbench, context: vsco const bucketNameSelected = selectedItem.label; if (bucketNameSelected === 'Clear Context') { workbench.editorToContext.delete(activeEditor.document.uri.toString()); - showQueryContextStatusbar(activeEditor, workbench); + showQueryContextStatusbar(activeEditor, workbench,globalStatusBarItem); return; } const scopes = await connection.cluster @@ -88,10 +90,93 @@ export async function fetchQueryContext(workbench: QueryWorkbench, context: vsco bucketName: bucketNameSelected, scopeName: scopeNameSelected.label }); - showQueryContextStatusbar(activeEditor, workbench); + showQueryContextStatusbar(activeEditor, workbench,globalStatusBarItem); } catch (err) { logger.error(`failed to open and set query context: ${err}`); logger.debug(err); } -} \ No newline at end of file +} + +export async function fetchSearchContext(searchIndexNode: SearchIndexNode, workbench: SearchWorkbench, context: vscode.ExtensionContext, globalStatusBarItem: vscode.StatusBarItem) { + const connection = getActiveConnection(); + if (!connection) { + vscode.window.showErrorMessage("Please connect to a cluster before setting query context"); + return; + } + try { + const activeEditor = vscode.window.activeTextEditor; + if (!(activeEditor && activeEditor.document.languageId === "searchQuery")) { + vscode.window.showErrorMessage("Please ensure that the workbench is open/active"); + return; + } + + // Fetching bucket names + const bucketsSettings = await connection?.cluster?.buckets().getAllBuckets(); + const allBuckets = fetchBucketNames(bucketsSettings, connection); + if (!allBuckets || allBuckets.length === 0) { + vscode.window.showErrorMessage('No buckets found.'); + return; + } + + // Displaying QuickPick for buckets + const selectedItem = await vscode.window.showQuickPick(allBuckets.map(bucket => ({ + label: bucket.name, + iconPath: new vscode.ThemeIcon("database") + })), { + placeHolder: 'Query Context: Select a bucket', + canPickMany: false + }); + + if (!selectedItem) { + vscode.window.showInformationMessage("No buckets selected."); + return; + } + + const bucketNameSelected = selectedItem.label; + + // Fetching search indexes specific to the selected bucket + const searchIndexesManager = connection?.cluster?.searchIndexes(); + const ftsIndexes = await searchIndexesManager?.getAllIndexes(); + const bucketIndexes = ftsIndexes?.filter(index => index.sourceName === bucketNameSelected); + if (!bucketIndexes || bucketIndexes.length === 0) { + vscode.window.showErrorMessage('No Indexes found.'); + return; + } + + // Displaying QuickPick for indexes + const indexNameSelected = await vscode.window.showQuickPick(bucketIndexes.map(index => ({ + label: index.name, + iconPath: new vscode.ThemeIcon("file-submodule") + })), { + placeHolder: 'Query Context: Select an Index', + canPickMany: false + }); + + if (!indexNameSelected) { + vscode.window.showInformationMessage('No index selected.'); + return; + } + + const editorId = activeEditor.document.uri.toString(); + + // Setting new context + workbench.editorToContext.set(editorId, { + bucketName: bucketNameSelected, + indexName: indexNameSelected.label, + statusBarItem: globalStatusBarItem, + searchNode: searchIndexNode + }); + + // Update the status bar directly + let displayBucketName = bucketNameSelected.length > 15 ? `${bucketNameSelected.substring(0, 13)}...` : bucketNameSelected; + let displayIndexName = indexNameSelected.label.length > 15 ? `${indexNameSelected.label.substring(0, 13)}...` : indexNameSelected.label; + globalStatusBarItem.text = `$(group-by-ref-type) ${displayBucketName} > ${displayIndexName}`; + globalStatusBarItem.tooltip = "Search Query Context"; + globalStatusBarItem.command = Commands.searchContext; + + } catch (err) { + logger.error(`Failed to open and set query context: ${err}`); + logger.debug(err); + } +} diff --git a/src/types/ISearchQueryContext.ts b/src/types/ISearchQueryContext.ts new file mode 100644 index 00000000..3e9527ab --- /dev/null +++ b/src/types/ISearchQueryContext.ts @@ -0,0 +1,9 @@ +import * as vscode from 'vscode'; +import SearchIndexNode from '../model/SearchIndexNode'; + +export interface ISearchQueryContext { + bucketName: string; + indexName: string; + statusBarItem: vscode.StatusBarItem; + searchNode: SearchIndexNode; +} diff --git a/src/util/apis/CouchbaseRestAPI.ts b/src/util/apis/CouchbaseRestAPI.ts index 0b12425c..e6e5ad8a 100644 --- a/src/util/apis/CouchbaseRestAPI.ts +++ b/src/util/apis/CouchbaseRestAPI.ts @@ -130,6 +130,57 @@ export class CouchbaseRestAPI { } } + public async runSearchIndexes(indexName:string | undefined, payload:any) { + const username = this.connection.username; + const secretService = SecretService.getInstance(); + const password = await secretService.get(`${Constants.extensionID}-${getConnectionId(this.connection)}`); + if (!password) { + return undefined; + } + let url = (await getServerURL(this.connection.url))[0]; + url = (this.connection.isSecure ? `https://${url}:18094` : `http://${url}:8094`); + url += `/api/index/${indexName}/query`; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + let content = await axios.post(url, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` + }, + httpsAgent: new https.Agent({ + rejectUnauthorized: false + }) + }); + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; + return content.data; + } + + public async fetchSearchIndexDefinition(indexName:string) { + + const username = this.connection.username; + const secretService = SecretService.getInstance(); + const password = await secretService.get(`${Constants.extensionID}-${getConnectionId(this.connection)}`); + if (!password) { + return undefined; + } + let url = (await getServerURL(this.connection.url))[0]; + url = (this.connection.isSecure ? `https://${url}:18094` : `http://${url}:8094`); + url += `/api/index/${indexName}`; + + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + let content = await axios.get(url, { + method: "GET", + headers: { + Authorization: `Basic ${btoa(`${username}:${password}`)}` + }, + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }) + }); + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; + return content.data; + + } + public async getKVDocumentCount(bucketName: string, scopeName: string): Promise> { const username = this.connection.username; const secretService = SecretService.getInstance(); diff --git a/src/util/queryContextUtils.ts b/src/util/queryContextUtils.ts index 8f392b2c..3204c3d9 100644 --- a/src/util/queryContextUtils.ts +++ b/src/util/queryContextUtils.ts @@ -3,16 +3,13 @@ import { QueryWorkbench } from '../workbench/queryWorkbench'; import { Memory } from './util'; import { Constants } from './constants'; import { Commands } from '../commands/extensionCommands/commands'; +import { SearchWorkbench } from '../commands/fts/SearchWorkbench/searchWorkbench'; +import SearchIndexNode from '../model/SearchIndexNode'; -export const showQueryContextStatusbar = async (editor: vscode.TextEditor, workbench: QueryWorkbench) => { - let statusBarItem = Memory.state.get(Constants.QUERY_CONTEXT_STATUS_BAR); - if (!statusBarItem) { - statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 1000); - Memory.state.update(Constants.QUERY_CONTEXT_STATUS_BAR, statusBarItem); - } +export const showQueryContextStatusbar = async (editor: vscode.TextEditor, workbench: QueryWorkbench, globalStatusBarItem: vscode.StatusBarItem) => { let queryContext = workbench.editorToContext.get(editor.document.uri.toString()); if (!queryContext) { - statusBarItem.text = `$(group-by-ref-type) No Query Context Set`; + globalStatusBarItem.text = `$(group-by-ref-type) No Query Context Set`; } else { let bucketName = queryContext.bucketName; if (bucketName.length > 15) { @@ -22,17 +19,48 @@ export const showQueryContextStatusbar = async (editor: vscode.TextEditor, workb if (scopeName.length > 15) { scopeName = `${scopeName.substring(0, 13)}...`; } - statusBarItem.text = `$(group-by-ref-type) ${bucketName} > ${scopeName}`; + globalStatusBarItem.text = `$(group-by-ref-type) ${bucketName} > ${scopeName}`; } - statusBarItem.command = Commands.queryContext; - statusBarItem.tooltip = "Query Context"; - statusBarItem.show(); + globalStatusBarItem.command = Commands.queryContext; + globalStatusBarItem.tooltip = "Query Context"; + globalStatusBarItem.show(); }; -export const hideQueryContextStatusbar = async () => { - let statusBarItem = Memory.state.get(Constants.QUERY_CONTEXT_STATUS_BAR); - if (!statusBarItem) { - return; + +export const showSearchContextStatusbar = async (editor: vscode.TextEditor, searchNode: SearchIndexNode, workbench: SearchWorkbench, globalStatusBarItem: vscode.StatusBarItem) => { + let editorId = editor.document.uri.toString(); + let editorContext = workbench.editorToContext.get(editorId); + + // Hide all other status bar items if present + workbench.editorToContext.forEach((context, key) => { + if (key !== editorId && context.statusBarItem) { + context.statusBarItem.hide(); + } + }); + + editorContext = { + bucketName: searchNode.bucketName, + indexName: searchNode.indexName, + statusBarItem: globalStatusBarItem, + searchNode: searchNode + }; + workbench.editorToContext.set(editorId, editorContext); + + + // Update global status bar text based on Node selected by user + let displayBucketName = searchNode.bucketName.length > 15 ? `${searchNode.bucketName.substring(0, 13)}...` : searchNode.bucketName; + let displayIndexName = searchNode.searchIndexName.length > 15 ? `${searchNode.searchIndexName.substring(0, 13)}...` : searchNode.searchIndexName; + editorContext.statusBarItem.text = `$(group-by-ref-type) ${displayBucketName} > ${displayIndexName}`; + editorContext.statusBarItem.tooltip = "Search Query Context"; + editorContext.statusBarItem.command = Commands.searchContext; + + // Check and update the context if it has changed + if (editorContext.searchNode !== searchNode) { + editorContext.searchNode = searchNode; + workbench.editorToContext.set(editorId, editorContext); } - statusBarItem.hide(); + + editorContext.statusBarItem.show(); }; + + diff --git a/src/workbench/workbenchWebviewProvider.ts b/src/workbench/workbenchWebviewProvider.ts index 23541bf4..f2eea89e 100644 --- a/src/workbench/workbenchWebviewProvider.ts +++ b/src/workbench/workbenchWebviewProvider.ts @@ -69,4 +69,10 @@ export class WorkbenchWebviewProvider implements vscode.WebviewViewProvider { this._queryResult = queryResult; await this.sendQueryResult(queryResult, queryStatus, plan); } + + async setSearchQueryResult(queryResult: string, queryStatus: any, plan: string | null) { + this._view?.show(); + this._queryResult = queryResult; + await this.sendQueryResult(queryResult, queryStatus, plan); + } } \ No newline at end of file