diff --git a/src/controllers/mainController.ts b/src/controllers/mainController.ts index 7ba5d469f2..1318a77edf 100644 --- a/src/controllers/mainController.ts +++ b/src/controllers/mainController.ts @@ -2318,7 +2318,7 @@ export default class MainController implements vscode.Disposable { } let editor = self._vscodeWrapper.activeTextEditor; - let uri = self._vscodeWrapper.activeTextEditorUri; + let editorInstanceKey = self._vscodeWrapper.activeTextEditorInstanceKey; let title = path.basename(editor.document.fileName); // return early if the document does contain any text @@ -2334,9 +2334,10 @@ export default class MainController implements vscode.Disposable { endColumn: 0, }; + // Run query using editor instance key so each editor has its own execution context await self._outputContentProvider.runCurrentStatement( self._statusview, - uri, + editorInstanceKey, querySelection, title, ); @@ -2361,6 +2362,7 @@ export default class MainController implements vscode.Disposable { let editor = this._vscodeWrapper.activeTextEditor; let uri = this._vscodeWrapper.activeTextEditorUri; + let editorInstanceKey = this._vscodeWrapper.activeTextEditorInstanceKey; // Do not execute when there are multiple selections in the editor until it can be properly handled. // Otherwise only the first selection will be executed and cause unexpected issues. @@ -2392,7 +2394,7 @@ export default class MainController implements vscode.Disposable { }; } - // create new connection + // create new connection - connections are shared per document URI if (!this.connectionManager.isConnected(uri)) { await this.onNewConnection(); sendActionEvent(TelemetryViews.QueryEditor, TelemetryActions.CreateConnection); @@ -2401,11 +2403,12 @@ export default class MainController implements vscode.Disposable { await this._connectionMgr.refreshAzureAccountToken(uri); // Delete stored filters and dimension states for result grid when a new query is executed - store.deleteUriState(uri); + store.deleteUriState(editorInstanceKey); + // Run query using editor instance key so each editor has its own execution context await this._outputContentProvider.runQuery( this._statusview, - uri, + editorInstanceKey, querySelection, title, executionPlanOptions, diff --git a/src/controllers/queryRunner.ts b/src/controllers/queryRunner.ts index b1193728f0..45f34d619e 100644 --- a/src/controllers/queryRunner.ts +++ b/src/controllers/queryRunner.ts @@ -156,6 +156,7 @@ export default class QueryRunner { private _client?: SqlToolsServerClient, private _notificationHandler?: QueryNotificationHandler, private _vscodeWrapper?: VscodeWrapper, + private _editorInstanceKey?: string, ) { if (!_client) { this._client = SqlToolsServerClient.instance; @@ -169,6 +170,11 @@ export default class QueryRunner { this._vscodeWrapper = new VscodeWrapper(); } + // If no editor instance key provided, use the owner URI + if (!_editorInstanceKey) { + this._editorInstanceKey = _ownerUri; + } + // Store the state this._isExecuting = false; this._totalElapsedMilliseconds = 0; @@ -185,6 +191,14 @@ export default class QueryRunner { this._ownerUri = uri; } + get editorInstanceKey(): string { + return this._editorInstanceKey; + } + + set editorInstanceKey(key: string) { + this._editorInstanceKey = key; + } + get title(): string { return this._editorTitle; } @@ -352,7 +366,7 @@ export default class QueryRunner { this._isExecuting = true; this._totalElapsedMilliseconds = 0; // Update the status view to show that we're executing - this._statusView.executingQuery(this.uri); + this._statusView.executingQuery(this._editorInstanceKey); QueryRunner.addRunningQuery(this._ownerUri); @@ -383,9 +397,9 @@ export default class QueryRunner { promise.resolve(); this._uriToQueryPromiseMap.delete(result.ownerUri); } - this._statusView.executedQuery(result.ownerUri); + this._statusView.executedQuery(this._editorInstanceKey); this._statusView.setExecutionTime( - result.ownerUri, + this._editorInstanceKey, Utils.parseNumAsTimeString(this._totalElapsedMilliseconds), ); let hasError = this._batchSets.some((batch) => batch.hasError === true); @@ -495,9 +509,9 @@ export default class QueryRunner { // Set row count on status bar if there are no errors if (!obj.message.isError) { - this._statusView.showRowCount(obj.ownerUri, obj.message.message); + this._statusView.showRowCount(this._editorInstanceKey, obj.message.message); } else { - this._statusView.hideRowCount(obj.ownerUri, true); + this._statusView.hideRowCount(this._editorInstanceKey, true); } } @@ -542,7 +556,7 @@ export default class QueryRunner { totalMilliseconds: Utils.parseNumAsTimeString(this._totalElapsedMilliseconds), hasError: !!error, }); - this._statusView.executedQuery(this._ownerUri); + this._statusView.executedQuery(this._editorInstanceKey); this._notificationHandler.unregisterRunner(this._ownerUri); diff --git a/src/controllers/vscodeWrapper.ts b/src/controllers/vscodeWrapper.ts index 3b75f69a5c..63e0bc9687 100644 --- a/src/controllers/vscodeWrapper.ts +++ b/src/controllers/vscodeWrapper.ts @@ -8,6 +8,7 @@ import * as vscode from "vscode"; import { TextDocumentShowOptions } from "vscode"; import { AzureLoginStatus } from "../models/interfaces"; import * as Constants from "./../constants/constants"; +import { getEditorInstanceKey } from "../utils/utils"; export import TextEditor = vscode.TextEditor; export import ConfigurationTarget = vscode.ConfigurationTarget; @@ -70,6 +71,15 @@ export default class VscodeWrapper { return undefined; } + /** + * Get the unique editor instance key for the current active text editor. + * This key includes the document URI and the view column, allowing multiple + * editors of the same document to have independent execution contexts. + */ + public get activeTextEditorInstanceKey(): string | undefined { + return getEditorInstanceKey(vscode.window.activeTextEditor); + } + /** * Create an output channel in vscode. */ diff --git a/src/models/sqlOutputContentProvider.ts b/src/models/sqlOutputContentProvider.ts index 0a88716099..e81a1ae323 100644 --- a/src/models/sqlOutputContentProvider.ts +++ b/src/models/sqlOutputContentProvider.ts @@ -22,7 +22,7 @@ import * as qr from "../sharedInterfaces/queryResult"; import { ExecutionPlanService } from "../services/executionPlanService"; import { countResultSets, isOpenQueryResultsInTabByDefaultEnabled } from "../queryResult/utils"; import { ApiStatus, StateChangeNotification } from "../sharedInterfaces/webview"; -import { getErrorMessage } from "../utils/utils"; +import { getErrorMessage, getDocumentUriFromEditorKey } from "../utils/utils"; // tslint:disable-next-line:no-require-imports const pd = require("pretty-data").pd; @@ -263,7 +263,7 @@ export class SqlOutputContentProvider { /** * Runs a query against the database. * @param statusView The status view to use for showing query progress - * @param uri The URI of the editor to run the query for + * @param editorInstanceKey The editor instance key (URI::viewColumn) for the editor running the query * @param selection The selection in the editor to run the query for * @param title The title of the editor to run the query for * @param executionPlanOptions Options for including execution plans @@ -271,7 +271,7 @@ export class SqlOutputContentProvider { */ public async runQuery( statusView: StatusView, - uri: string, + editorInstanceKey: string, selection: ISelectionData, title: string, executionPlanOptions?: ExecutionPlanOptions, @@ -279,7 +279,7 @@ export class SqlOutputContentProvider { ): Promise { const runner = await this.initializeRunnerAndWebviewState( statusView ? statusView : this._statusView, - uri, + editorInstanceKey, title, executionPlanOptions, ); @@ -293,7 +293,7 @@ export class SqlOutputContentProvider { const includeExecutionPlanXml = executionPlanOptions?.includeActualExecutionPlanXml ?? - this._actualPlanStatuses.includes(uri); + this._actualPlanStatuses.includes(editorInstanceKey); const includeEstimatedExecutionPlanXml = executionPlanOptions?.includeEstimatedExecutionPlanXml ?? false; @@ -311,19 +311,19 @@ export class SqlOutputContentProvider { * Runs a query against the database for the current statement based on the cursor position. * If there is a selection, it will run the selection else it will run the current statement. * @param statusView The status view to use for showing query progress - * @param uri The URI of the editor to run the query for + * @param editorInstanceKey The editor instance key (URI::viewColumn) for the editor running the query * @param selection The selection in the editor to run the query for * @param title The title of the editor to run the query for */ public async runCurrentStatement( statusView: StatusView, - uri: string, + editorInstanceKey: string, selection: ISelectionData, title: string, ): Promise { const runner = await this.initializeRunnerAndWebviewState( statusView ? statusView : this._statusView, - uri, + editorInstanceKey, title, ); @@ -336,50 +336,54 @@ export class SqlOutputContentProvider { private async initializeRunnerAndWebviewState( statusView: StatusView, - uri: string, + editorInstanceKey: string, title: string, executionPlanOptions?: ExecutionPlanOptions, ): Promise { let queryRunner = await this.createQueryRunner( statusView ? statusView : this._statusView, - uri, + editorInstanceKey, title, ); if (!queryRunner) { return; } this._queryResultWebviewController.addQueryResultState( - uri, + editorInstanceKey, title, executionPlanOptions?.includeEstimatedExecutionPlanXml || - this._actualPlanStatuses.includes(uri) || + this._actualPlanStatuses.includes(editorInstanceKey) || executionPlanOptions?.includeActualExecutionPlanXml, ); if (isOpenQueryResultsInTabByDefaultEnabled()) { - await this._queryResultWebviewController.createPanelController(queryRunner.uri); + await this._queryResultWebviewController.createPanelController(editorInstanceKey); } return queryRunner; } /** - * Creates a new query runner for the given URI if one does not already exist. + * Creates a new query runner for the given editor instance key if one does not already exist. * If one already exists and is not currently executing a query, it will be reused. * If one already exists and is currently executing a query, undefined will be returned. * @param statusView The status view to use for showing query progress - * @param uri The URI of the editor to run the query for + * @param editorInstanceKey The editor instance key (URI::viewColumn) to run the query for * @param title The title of the editor to run the query for * @returns A promise that resolves to the query runner or undefined if a query is already in progress */ public async createQueryRunner( statusView: StatusView, - uri: string, + editorInstanceKey: string, title: string, ): Promise { - // Reuse existing query runner if it exists + // Extract document URI from editor instance key + const documentUri = getDocumentUriFromEditorKey(editorInstanceKey); + + // Reuse existing query runner if it exists for this editor instance let queryRunner: QueryRunner; - if (this._queryResultsMap.has(uri)) { - let existingRunner: QueryRunner = this._queryResultsMap.get(uri).queryRunner; + if (this._queryResultsMap.has(editorInstanceKey)) { + let existingRunner: QueryRunner = + this._queryResultsMap.get(editorInstanceKey).queryRunner; // If the query is already in progress, don't attempt to send it if (existingRunner.isExecutingQuery) { @@ -396,12 +400,22 @@ export class SqlOutputContentProvider { queryRunner = existingRunner; queryRunner.resetHasCompleted(); } else { - // We do not have a query runner for this editor, so create a new one - // and map it to the results uri - queryRunner = new QueryRunner(uri, title, statusView ? statusView : this._statusView); + // We do not have a query runner for this editor instance, so create a new one + // NOTE: QueryRunner uses documentUri for SQL Tools Service calls (connection lookup) + // but we key it in our map by editorInstanceKey for per-editor execution state + // Pass editorInstanceKey to QueryRunner so it can update StatusView per editor instance + queryRunner = new QueryRunner( + documentUri, + title, + statusView ? statusView : this._statusView, + undefined, // client + undefined, // notificationHandler + undefined, // vscodeWrapper + editorInstanceKey, + ); const startFailedListener = queryRunner.onStartFailed(async (error) => { - this.updateWebviewState(queryRunner.uri, { + this.updateWebviewState(editorInstanceKey, { initializationError: getErrorMessage(error), resultSetSummaries: {}, executionPlanState: {}, @@ -411,14 +425,13 @@ export class SqlOutputContentProvider { }); const startListener = queryRunner.onStart(async (_panelUri) => { - const resultWebviewState = this._queryResultWebviewController.getQueryResultState( - queryRunner.uri, - ); + const resultWebviewState = + this._queryResultWebviewController.getQueryResultState(editorInstanceKey); resultWebviewState.tabStates.resultPaneTab = QueryResultPaneTabs.Messages; resultWebviewState.isExecutionPlan = false; resultWebviewState.initializationError = undefined; - this.updateWebviewState(queryRunner.uri, resultWebviewState); - this.revealQueryResult(queryRunner.uri); + this.updateWebviewState(editorInstanceKey, resultWebviewState); + this.revealQueryResult(editorInstanceKey); sendActionEvent(TelemetryViews.QueryResult, TelemetryActions.OpenQueryResult, { defaultLocation: isOpenQueryResultsInTabByDefaultEnabled() ? "tab" : "pane", }); @@ -427,7 +440,7 @@ export class SqlOutputContentProvider { const resultSetAvailableListener = queryRunner.onResultSetAvailable( async (resultSet: ResultSetSummary) => { const resultWebviewState = - this._queryResultWebviewController.getQueryResultState(queryRunner.uri); + this._queryResultWebviewController.getQueryResultState(editorInstanceKey); const batchId = resultSet.batchId; const resultId = resultSet.id; if (!resultWebviewState.resultSetSummaries[batchId]) { @@ -438,14 +451,14 @@ export class SqlOutputContentProvider { if (countResultSets(resultWebviewState.resultSetSummaries) === 1) { resultWebviewState.tabStates.resultPaneTab = QueryResultPaneTabs.Results; } - this.updateWebviewState(queryRunner.uri, resultWebviewState); + this.updateWebviewState(editorInstanceKey, resultWebviewState); }, ); const resultSetUpdatedListener = queryRunner.onResultSetUpdated( async (resultSet: ResultSetSummary) => { const resultWebviewState = - this._queryResultWebviewController.getQueryResultState(queryRunner.uri); + this._queryResultWebviewController.getQueryResultState(editorInstanceKey); const batchId = resultSet.batchId; const resultId = resultSet.id; if (!resultWebviewState.resultSetSummaries[batchId]) { @@ -459,7 +472,7 @@ export class SqlOutputContentProvider { const resultSetCompleteListener = queryRunner.onResultSetComplete( async (resultSet: ResultSetSummary) => { const resultWebviewState = - this._queryResultWebviewController.getQueryResultState(queryRunner.uri); + this._queryResultWebviewController.getQueryResultState(editorInstanceKey); const batchId = resultSet.batchId; const resultId = resultSet.id; if (!resultWebviewState.resultSetSummaries[batchId]) { @@ -467,7 +480,7 @@ export class SqlOutputContentProvider { } resultWebviewState.resultSetSummaries[batchId][resultId] = resultSet; - this.updateWebviewState(queryRunner.uri, resultWebviewState); + this.updateWebviewState(editorInstanceKey, resultWebviewState); }, ); @@ -488,25 +501,23 @@ export class SqlOutputContentProvider { text: LocalizedConstants.runQueryBatchStartLine( batch.selection.startLine + 1, ), - uri: queryRunner.uri, + uri: editorInstanceKey, }, }; - const resultWebviewState = this._queryResultWebviewController.getQueryResultState( - queryRunner.uri, - ); + const resultWebviewState = + this._queryResultWebviewController.getQueryResultState(editorInstanceKey); resultWebviewState.messages.push(message); - this.scheduleThrottledUpdate(queryRunner.uri); + this.scheduleThrottledUpdate(editorInstanceKey); }); const onMessageListener = queryRunner.onMessage(async (message) => { - const resultWebviewState = this._queryResultWebviewController.getQueryResultState( - queryRunner.uri, - ); + const resultWebviewState = + this._queryResultWebviewController.getQueryResultState(editorInstanceKey); resultWebviewState.messages.push(message); - this.scheduleThrottledUpdate(queryRunner.uri); + this.scheduleThrottledUpdate(editorInstanceKey); }); const onCompleteListener = queryRunner.onComplete(async (e) => { @@ -515,14 +526,13 @@ export class SqlOutputContentProvider { // only update query history with new queries this._vscodeWrapper.executeCommand( Constants.cmdRefreshQueryHistory, - queryRunner.uri, + editorInstanceKey, hasError, ); } - const resultWebviewState = this._queryResultWebviewController.getQueryResultState( - queryRunner.uri, - ); + const resultWebviewState = + this._queryResultWebviewController.getQueryResultState(editorInstanceKey); resultWebviewState.messages.push({ message: LocalizedConstants.elapsedTimeLabel(totalMilliseconds), isError: false, // Elapsed time messages are never displayed as errors @@ -543,7 +553,7 @@ export class SqlOutputContentProvider { } } resultWebviewState.tabStates.resultPaneTab = tabState; - this.updateWebviewState(queryRunner.uri, resultWebviewState); + this.updateWebviewState(editorInstanceKey, resultWebviewState); }); const onExecutionPlanListener = queryRunner.onExecutionPlan(async (e) => { @@ -552,9 +562,8 @@ export class SqlOutputContentProvider { graphFileType: "xml", }); - const resultWebviewState = this._queryResultWebviewController.getQueryResultState( - e.uri, - ); + const resultWebviewState = + this._queryResultWebviewController.getQueryResultState(editorInstanceKey); const existingGraphs = resultWebviewState.executionPlanState.executionPlanGraphs; existingGraphs.push(...planGraphs.graphs); @@ -574,7 +583,7 @@ export class SqlOutputContentProvider { xmlPlans: xmlPlans, }; - this.updateWebviewState(queryRunner.uri, resultWebviewState); + this.updateWebviewState(editorInstanceKey, resultWebviewState); }); const onSelectionSummaryListener = queryRunner.onSummaryChanged(async (e) => { @@ -605,7 +614,8 @@ export class SqlOutputContentProvider { onSelectionSummaryListener, ); - this._queryResultsMap.set(uri, queryRunnerState); + // Store the query runner keyed by editor instance key + this._queryResultsMap.set(editorInstanceKey, queryRunnerState); } return queryRunner; @@ -665,46 +675,63 @@ export class SqlOutputContentProvider { /** * Executed from the MainController when an untitled text document was saved to the disk. If * any queries were executed from the untitled document, the queryrunner will be remapped to - * a new resuls uri based on the uri of the newly saved file. + * a new results uri based on the uri of the newly saved file. * @param untitledUri The URI of the untitled file * @param savedUri The URI of the file after it was saved */ public onUntitledFileSaved(untitledUri: string, savedUri: string): void { - // If we don't have any query runners mapped to this uri, don't do anything - let untitledResultsUri = decodeURIComponent(untitledUri); - if (!this._queryResultsMap.has(untitledResultsUri)) { - return; + const untitledResultsUri = decodeURIComponent(untitledUri); + const savedResultUri = decodeURIComponent(savedUri); + + // Remap all editor instances of this document + for (const [key, runner] of this._queryResultsMap.entries()) { + // Check if this is the untitled document or an editor instance of it + if (key === untitledResultsUri || key.startsWith(`${untitledResultsUri}::`)) { + // Extract the view column suffix if present + const viewColumnSuffix = key.includes("::") ? key.substring(key.indexOf("::")) : ""; + const newKey = `${savedResultUri}${viewColumnSuffix}`; + + // NOTE: We don't need to remap the query in the service because the queryrunner still has + // the old uri. As long as we make requests to the service against that uri, we'll be good. + + // Remap the query runner in the map + this._queryResultsMap.set(newKey, runner); + this._queryResultsMap.delete(key); + } } - - // NOTE: We don't need to remap the query in the service because the queryrunner still has - // the old uri. As long as we make requests to the service against that uri, we'll be good. - - // Remap the query runner in the map - let savedResultUri = decodeURIComponent(savedUri); - this._queryResultsMap.set(savedResultUri, this._queryResultsMap.get(untitledResultsUri)); - this._queryResultsMap.delete(untitledResultsUri); } public async updateQueryRunnerUri(oldUri: string, newUri: string): Promise { - let queryRunner = this.getQueryRunner(oldUri); - if (queryRunner) { - queryRunner.updateQueryRunnerUri(oldUri, newUri); - } + // Update all editor instances of this document + for (const [key, _value] of this._queryResultsMap.entries()) { + if (key === oldUri || key.startsWith(`${oldUri}::`)) { + const queryRunner = this._queryResultsMap.get(key).queryRunner; + if (queryRunner) { + queryRunner.updateQueryRunnerUri(oldUri, newUri); + } - let state = this._queryResultWebviewController.getQueryResultState(oldUri); - if (state) { - state.uri = newUri; - /** - * TODO: aaskhan - * Remove adhoc state updates. - */ - await this._queryResultWebviewController.sendNotification( - StateChangeNotification.type(), - state, - ); - //Update the URI in the query result webview state - this._queryResultWebviewController.setQueryResultState(newUri, state); - this._queryResultWebviewController.deleteQueryResultState(oldUri); + const state = this._queryResultWebviewController.getQueryResultState(key); + if (state) { + // Extract the view column suffix if present + const viewColumnSuffix = key.includes("::") + ? key.substring(key.indexOf("::")) + : ""; + const newKey = `${newUri}${viewColumnSuffix}`; + + state.uri = newKey; + /** + * TODO: aaskhan + * Remove adhoc state updates. + */ + await this._queryResultWebviewController.sendNotification( + StateChangeNotification.type(), + state, + ); + //Update the URI in the query result webview state + this._queryResultWebviewController.setQueryResultState(newKey, state); + this._queryResultWebviewController.deleteQueryResultState(key); + } + } } } @@ -717,8 +744,10 @@ export class SqlOutputContentProvider { public async onDidCloseTextDocument(doc: vscode.TextDocument): Promise { const closedDocumentUri = doc.uri.toString(true); + // Clean up all query runners for all editor instances of this document for (let [key, _value] of this._queryResultsMap.entries()) { - if (closedDocumentUri === key) { + // Check if this key is for this document (either exact match or starts with documentUri::) + if (key === closedDocumentUri || key.startsWith(`${closedDocumentUri}::`)) { /** * If the result is in a webview view, immediately dispose the runner * For panel results, we wait until the panel is closed to dispose the runner @@ -727,12 +756,18 @@ export class SqlOutputContentProvider { } } + // Clean up actual plan statuses for this document if (this._actualPlanStatuses.includes(closedDocumentUri)) { this._actualPlanStatuses = this._actualPlanStatuses.filter( (uri) => uri !== closedDocumentUri, ); this.updateActualPlanContext(); } + // Also clean up actual plan statuses for editor instances + this._actualPlanStatuses = this._actualPlanStatuses.filter( + (uri) => !uri.startsWith(`${closedDocumentUri}::`), + ); + this.updateActualPlanContext(); } /** diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 4f779a5faa..f24d7dbe77 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -105,6 +105,35 @@ export function getUriKey(uri: vscode.Uri): string { return uri?.toString(true); } +/** + * Gets a unique key for an editor instance by combining the document URI with the editor's view column. + * This allows multiple editors viewing the same document to have separate execution contexts. + * @param editor The text editor to get the unique key for, or undefined to get the active editor's key + * @returns A unique string key for the editor instance, or undefined if no editor is provided/active + */ +export function getEditorInstanceKey(editor?: vscode.TextEditor): string | undefined { + const targetEditor = editor || vscode.window.activeTextEditor; + if (!targetEditor?.document?.uri) { + return undefined; + } + const uriKey = getUriKey(targetEditor.document.uri); + const viewColumn = targetEditor.viewColumn ?? vscode.ViewColumn.One; + return `${uriKey}::${viewColumn}`; +} + +/** + * Extracts the document URI from an editor instance key. + * @param editorInstanceKey The editor instance key to extract the URI from + * @returns The document URI portion of the key, or undefined if the key is invalid + */ +export function getDocumentUriFromEditorKey(editorInstanceKey: string): string | undefined { + if (!editorInstanceKey) { + return undefined; + } + const parts = editorInstanceKey.split("::"); + return parts[0]; +} + /** * Gets the end-of-line character sequence configured in the editor. * @returns The end-of-line character sequence. diff --git a/test/unit/editorInstanceKey.test.ts b/test/unit/editorInstanceKey.test.ts new file mode 100644 index 0000000000..0bd148633b --- /dev/null +++ b/test/unit/editorInstanceKey.test.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from "assert"; +import * as vscode from "vscode"; +import { getEditorInstanceKey, getDocumentUriFromEditorKey } from "../../src/utils/utils"; + +suite("Editor Instance Key Tests", () => { + const testUri = vscode.Uri.file("/test/file.sql"); + const testUriString = testUri.toString(true); + + test("getEditorInstanceKey should generate key with view column", () => { + const mockEditor = { + document: { + uri: testUri, + }, + viewColumn: vscode.ViewColumn.Two, + } as vscode.TextEditor; + + const key = getEditorInstanceKey(mockEditor); + assert.strictEqual(key, `${testUriString}::2`); + }); + + test("getEditorInstanceKey should use ViewColumn.One as default", () => { + const mockEditor = { + document: { + uri: testUri, + }, + viewColumn: undefined, + } as vscode.TextEditor; + + const key = getEditorInstanceKey(mockEditor); + assert.strictEqual(key, `${testUriString}::1`); + }); + + test("getEditorInstanceKey should return undefined for invalid editor", () => { + const key = getEditorInstanceKey(undefined); + assert.strictEqual(key, undefined); + }); + + test("getDocumentUriFromEditorKey should extract URI from editor key", () => { + const editorKey = `${testUriString}::2`; + const uri = getDocumentUriFromEditorKey(editorKey); + assert.strictEqual(uri, testUriString); + }); + + test("getDocumentUriFromEditorKey should handle plain URI without view column", () => { + const uri = getDocumentUriFromEditorKey(testUriString); + assert.strictEqual(uri, testUriString); + }); + + test("getDocumentUriFromEditorKey should return undefined for invalid key", () => { + const uri = getDocumentUriFromEditorKey(undefined); + assert.strictEqual(uri, undefined); + }); + + test("Different view columns should generate different keys", () => { + const editor1 = { + document: { uri: testUri }, + viewColumn: vscode.ViewColumn.One, + } as vscode.TextEditor; + + const editor2 = { + document: { uri: testUri }, + viewColumn: vscode.ViewColumn.Two, + } as vscode.TextEditor; + + const key1 = getEditorInstanceKey(editor1); + const key2 = getEditorInstanceKey(editor2); + + assert.notStrictEqual(key1, key2); + assert.strictEqual(key1, `${testUriString}::1`); + assert.strictEqual(key2, `${testUriString}::2`); + }); + + test("Same view column should generate same key", () => { + const editor1 = { + document: { uri: testUri }, + viewColumn: vscode.ViewColumn.Two, + } as vscode.TextEditor; + + const editor2 = { + document: { uri: testUri }, + viewColumn: vscode.ViewColumn.Two, + } as vscode.TextEditor; + + const key1 = getEditorInstanceKey(editor1); + const key2 = getEditorInstanceKey(editor2); + + assert.strictEqual(key1, key2); + }); + + test("Editor instance key should contain document URI", () => { + const editor = { + document: { uri: testUri }, + viewColumn: vscode.ViewColumn.Three, + } as vscode.TextEditor; + + const key = getEditorInstanceKey(editor); + assert.ok(key.startsWith(testUriString)); + assert.ok(key.includes("::")); + }); +});