mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-02-16 18:46:40 -05:00
Add query history persistence (#20700)
* initial * Fix and revert secret storage changes * persist to file * Add debounce and write on dispose * Fix run * No ext dependencies and show warning message * Remove test stuff * comments * Fix tests and console logs
This commit is contained in:
@@ -26,9 +26,7 @@
|
|||||||
"supported": true
|
"supported": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensionDependencies": [
|
"extensionDependencies": [ ],
|
||||||
"Microsoft.mssql"
|
|
||||||
],
|
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"configuration": [
|
"configuration": [
|
||||||
{
|
{
|
||||||
@@ -52,6 +50,11 @@
|
|||||||
"%queryHistory.doubleClickAction.open%",
|
"%queryHistory.doubleClickAction.open%",
|
||||||
"%queryHistory.doubleClickAction.run%"
|
"%queryHistory.doubleClickAction.run%"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"queryHistory.persistHistory": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "%queryHistory.persistHistory%"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,9 +191,11 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"vscode-nls": "^4.1.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@microsoft/azdata-test": "^2.0.3",
|
"@microsoft/azdata-test": "^3.0.0",
|
||||||
"@microsoft/vscodetestcover": "^1.2.1",
|
"@microsoft/vscodetestcover": "^1.2.1",
|
||||||
"@types/mocha": "^7.0.2",
|
"@types/mocha": "^7.0.2",
|
||||||
"@types/node": "^12.11.7",
|
"@types/node": "^12.11.7",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"queryHistory.doubleClickAction": "The action taken when a history item is double clicked",
|
"queryHistory.doubleClickAction": "The action taken when a history item is double clicked",
|
||||||
"queryHistory.doubleClickAction.open": "Open a new disconnected editor with the query from the selected history item",
|
"queryHistory.doubleClickAction.open": "Open a new disconnected editor with the query from the selected history item",
|
||||||
"queryHistory.doubleClickAction.run": "Open a new connected editor with the query and connection from the selected history item and automatically run the query",
|
"queryHistory.doubleClickAction.run": "Open a new connected editor with the query and connection from the selected history item and automatically run the query",
|
||||||
|
"queryHistory.persistHistory": "Whether query history is persisted across restarts. If false then the history will be cleared when the application exits.",
|
||||||
"queryHistory.open": "Open Query",
|
"queryHistory.open": "Open Query",
|
||||||
"queryHistory.run": "Run Query",
|
"queryHistory.run": "Run Query",
|
||||||
"queryHistory.delete": "Delete",
|
"queryHistory.delete": "Delete",
|
||||||
|
|||||||
@@ -6,5 +6,7 @@
|
|||||||
export const QUERY_HISTORY_CONFIG_SECTION = 'queryHistory';
|
export const QUERY_HISTORY_CONFIG_SECTION = 'queryHistory';
|
||||||
export const CAPTURE_ENABLED_CONFIG_SECTION = 'captureEnabled';
|
export const CAPTURE_ENABLED_CONFIG_SECTION = 'captureEnabled';
|
||||||
export const DOUBLE_CLICK_ACTION_CONFIG_SECTION = 'doubleClickAction';
|
export const DOUBLE_CLICK_ACTION_CONFIG_SECTION = 'doubleClickAction';
|
||||||
|
export const PERSIST_HISTORY_CONFIG_SECTION = 'persistHistory';
|
||||||
|
|
||||||
export const ITEM_SELECTED_COMMAND_ID = 'queryHistory.itemSelected';
|
export const ITEM_SELECTED_COMMAND_ID = 'queryHistory.itemSelected';
|
||||||
|
|
||||||
|
|||||||
10
extensions/query-history/src/localizedConstants.ts
Normal file
10
extensions/query-history/src/localizedConstants.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
import * as nls from 'vscode-nls';
|
||||||
|
const localize = nls.loadMessageBundle();
|
||||||
|
|
||||||
|
export const errorLoading = (err: any): string => localize('errorLoading', "Error loading saved query history items. {0}", err.message ?? err);
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import * as vscode from 'vscode';
|
|||||||
import { DOUBLE_CLICK_ACTION_CONFIG_SECTION, ITEM_SELECTED_COMMAND_ID, QUERY_HISTORY_CONFIG_SECTION } from './constants';
|
import { DOUBLE_CLICK_ACTION_CONFIG_SECTION, ITEM_SELECTED_COMMAND_ID, QUERY_HISTORY_CONFIG_SECTION } from './constants';
|
||||||
import { QueryHistoryItem } from './queryHistoryItem';
|
import { QueryHistoryItem } from './queryHistoryItem';
|
||||||
import { QueryHistoryProvider } from './queryHistoryProvider';
|
import { QueryHistoryProvider } from './queryHistoryProvider';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
|
||||||
let lastSelectedItem: { item: QueryHistoryItem | undefined, time: number | undefined } = {
|
let lastSelectedItem: { item: QueryHistoryItem | undefined, time: number | undefined } = {
|
||||||
item: undefined,
|
item: undefined,
|
||||||
@@ -19,7 +20,15 @@ let lastSelectedItem: { item: QueryHistoryItem | undefined, time: number | undef
|
|||||||
const DOUBLE_CLICK_TIMEOUT_MS = 500;
|
const DOUBLE_CLICK_TIMEOUT_MS = 500;
|
||||||
|
|
||||||
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
||||||
const treeDataProvider = new QueryHistoryProvider();
|
// Create the global storage folder now for storing the query history persistance file
|
||||||
|
try {
|
||||||
|
await fs.mkdir(context.globalStorageUri.fsPath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'EEXIST') {
|
||||||
|
console.error(`Error creating query history global storage folder ${context.globalStorageUri.fsPath}. ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const treeDataProvider = new QueryHistoryProvider(context);
|
||||||
context.subscriptions.push(treeDataProvider);
|
context.subscriptions.push(treeDataProvider);
|
||||||
const treeView = vscode.window.createTreeView('queryHistory', {
|
const treeView = vscode.window.createTreeView('queryHistory', {
|
||||||
treeDataProvider,
|
treeDataProvider,
|
||||||
@@ -63,10 +72,10 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
|
|||||||
return runQuery(item);
|
return runQuery(item);
|
||||||
}));
|
}));
|
||||||
context.subscriptions.push(vscode.commands.registerCommand('queryHistory.delete', (item: QueryHistoryItem) => {
|
context.subscriptions.push(vscode.commands.registerCommand('queryHistory.delete', (item: QueryHistoryItem) => {
|
||||||
treeDataProvider.deleteItem(item);
|
return treeDataProvider.deleteItem(item);
|
||||||
}));
|
}));
|
||||||
context.subscriptions.push(vscode.commands.registerCommand('queryHistory.clear', () => {
|
context.subscriptions.push(vscode.commands.registerCommand('queryHistory.clear', () => {
|
||||||
treeDataProvider.clearAll();
|
return treeDataProvider.clearAll();
|
||||||
}));
|
}));
|
||||||
context.subscriptions.push(vscode.commands.registerCommand('queryHistory.disableCapture', async () => {
|
context.subscriptions.push(vscode.commands.registerCommand('queryHistory.disableCapture', async () => {
|
||||||
return treeDataProvider.setCaptureEnabled(false);
|
return treeDataProvider.setCaptureEnabled(false);
|
||||||
@@ -88,6 +97,10 @@ async function runQuery(item: QueryHistoryItem): Promise<void> {
|
|||||||
{
|
{
|
||||||
content: item.queryText
|
content: item.queryText
|
||||||
}, item.connectionProfile?.providerId);
|
}, item.connectionProfile?.providerId);
|
||||||
await azdata.queryeditor.connect(doc.uri, item.connectionProfile?.connectionId || '');
|
if (item.connectionProfile) {
|
||||||
|
await doc.connect(item.connectionProfile);
|
||||||
|
} else {
|
||||||
|
await azdata.queryeditor.connect(doc.uri, '');
|
||||||
|
}
|
||||||
azdata.queryeditor.runQuery(doc.uri);
|
azdata.queryeditor.runQuery(doc.uri);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ import * as azdata from 'azdata';
|
|||||||
export interface QueryHistoryItem {
|
export interface QueryHistoryItem {
|
||||||
readonly queryText: string,
|
readonly queryText: string,
|
||||||
readonly connectionProfile: azdata.connection.ConnectionProfile | undefined,
|
readonly connectionProfile: azdata.connection.ConnectionProfile | undefined,
|
||||||
readonly timestamp: Date,
|
readonly timestamp: string,
|
||||||
readonly isSuccess: boolean
|
readonly isSuccess: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,20 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as azdata from 'azdata';
|
import * as azdata from 'azdata';
|
||||||
import { QueryHistoryItem } from './queryHistoryItem';
|
import { QueryHistoryItem } from './queryHistoryItem';
|
||||||
import { removeNewLines } from './utils';
|
import { debounce, removeNewLines } from './utils';
|
||||||
import { CAPTURE_ENABLED_CONFIG_SECTION, ITEM_SELECTED_COMMAND_ID, QUERY_HISTORY_CONFIG_SECTION } from './constants';
|
import { CAPTURE_ENABLED_CONFIG_SECTION, ITEM_SELECTED_COMMAND_ID, PERSIST_HISTORY_CONFIG_SECTION, QUERY_HISTORY_CONFIG_SECTION } from './constants';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as loc from './localizedConstants';
|
||||||
|
|
||||||
|
const STORAGE_IV_KEY = 'queryHistory.storage-iv';
|
||||||
|
const STORAGE_KEY_KEY = 'queryHistory.storage-key';
|
||||||
|
const HISTORY_STORAGE_FILE_NAME = 'queryHistory.bin';
|
||||||
|
const STORAGE_ENCRYPTION_ALGORITHM = 'aes-256-ctr';
|
||||||
|
const HISTORY_DEBOUNCE_MS = 10000;
|
||||||
const DEFAULT_CAPTURE_ENABLED = true;
|
const DEFAULT_CAPTURE_ENABLED = true;
|
||||||
|
const DEFAULT_PERSIST_HISTORY = true;
|
||||||
const successIcon = new vscode.ThemeIcon('check', new vscode.ThemeColor('testing.iconPassed'));
|
const successIcon = new vscode.ThemeIcon('check', new vscode.ThemeColor('testing.iconPassed'));
|
||||||
const failedIcon = new vscode.ThemeIcon('error', new vscode.ThemeColor('testing.iconFailed'));
|
const failedIcon = new vscode.ThemeIcon('error', new vscode.ThemeColor('testing.iconFailed'));
|
||||||
|
|
||||||
@@ -19,16 +29,30 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
|
|||||||
readonly onDidChangeTreeData: vscode.Event<QueryHistoryItem | undefined> = this._onDidChangeTreeData.event;
|
readonly onDidChangeTreeData: vscode.Event<QueryHistoryItem | undefined> = this._onDidChangeTreeData.event;
|
||||||
|
|
||||||
private _queryHistoryItems: QueryHistoryItem[] = [];
|
private _queryHistoryItems: QueryHistoryItem[] = [];
|
||||||
private _captureEnabled: boolean = true;
|
private _captureEnabled: boolean = DEFAULT_CAPTURE_ENABLED;
|
||||||
|
private _persistHistory: boolean = DEFAULT_PERSIST_HISTORY;
|
||||||
|
|
||||||
|
private _historyStorageFile: string;
|
||||||
|
|
||||||
private _disposables: vscode.Disposable[] = [];
|
private _disposables: vscode.Disposable[] = [];
|
||||||
|
|
||||||
|
private writeHistoryFileWorker: (() => void) | undefined;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping of query URIs to the query text being executed
|
* Mapping of query URIs to the query text being executed
|
||||||
*/
|
*/
|
||||||
private queryTextMappings: Map<string, string> = new Map<string, string>();
|
private queryTextMappings: Map<string, string> = new Map<string, string>();
|
||||||
|
|
||||||
constructor() {
|
constructor(private _context: vscode.ExtensionContext) {
|
||||||
|
this._historyStorageFile = path.join(this._context.globalStorageUri.fsPath, HISTORY_STORAGE_FILE_NAME);
|
||||||
|
// Kick off initialization but then continue on since that may take a while and we don't want to block extension activation
|
||||||
|
void this.initialize();
|
||||||
|
this._disposables.push(vscode.workspace.onDidChangeConfiguration(async e => {
|
||||||
|
if (e.affectsConfiguration(QUERY_HISTORY_CONFIG_SECTION)) {
|
||||||
|
await this.updateConfigurationValues();
|
||||||
|
}
|
||||||
|
}));
|
||||||
this._disposables.push(azdata.queryeditor.registerQueryEventListener({
|
this._disposables.push(azdata.queryeditor.registerQueryEventListener({
|
||||||
onQueryEvent: async (type: azdata.queryeditor.QueryEventType, document: azdata.queryeditor.QueryDocument, args: azdata.ResultSetSummary | string | undefined, queryInfo?: azdata.queryeditor.QueryInfo) => {
|
onQueryEvent: async (type: azdata.queryeditor.QueryEventType, document: azdata.queryeditor.QueryDocument, args: azdata.ResultSetSummary | string | undefined, queryInfo?: azdata.queryeditor.QueryInfo) => {
|
||||||
if (this._captureEnabled && queryInfo) {
|
if (this._captureEnabled && queryInfo) {
|
||||||
@@ -42,8 +66,9 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.queryTextMappings.delete(document.uri);
|
this.queryTextMappings.delete(document.uri);
|
||||||
this._queryHistoryItems.unshift({ queryText, connectionProfile, timestamp: new Date(), isSuccess });
|
this._queryHistoryItems.unshift({ queryText, connectionProfile, timestamp: new Date().toLocaleString(), isSuccess });
|
||||||
this._onDidChangeTreeData.fire(undefined);
|
this._onDidChangeTreeData.fire(undefined);
|
||||||
|
this.writeHistoryFile();
|
||||||
} else if (type === 'queryStart') {
|
} else if (type === 'queryStart') {
|
||||||
// We get the text and save it on queryStart because we want to get the query text immediately when
|
// We get the text and save it on queryStart because we want to get the query text immediately when
|
||||||
// the query is started but then only add the item when it finishes (so that we can properly determine the success of the execution).
|
// the query is started but then only add the item when it finishes (so that we can properly determine the success of the execution).
|
||||||
@@ -64,28 +89,120 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
this.updateCaptureEnabled();
|
}
|
||||||
this._disposables.push(vscode.workspace.onDidChangeConfiguration(e => {
|
|
||||||
if (e.affectsConfiguration(QUERY_HISTORY_CONFIG_SECTION)) {
|
/**
|
||||||
this.updateCaptureEnabled();
|
* Initializes the provider, loading the history from the previous session if it exists.
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private async initialize(): Promise<void> {
|
||||||
|
// First update our configuration values to make sure we have the settings the user has configured
|
||||||
|
await this.updateConfigurationValues();
|
||||||
|
|
||||||
|
let iv: Buffer | undefined;
|
||||||
|
try {
|
||||||
|
let ivString = await this._context.secrets.get(STORAGE_IV_KEY);
|
||||||
|
if (!ivString) {
|
||||||
|
iv = crypto.randomBytes(16);
|
||||||
|
await this._context.secrets.store(STORAGE_IV_KEY, iv.toString('binary'));
|
||||||
|
} else {
|
||||||
|
iv = Buffer.from(ivString, 'binary');
|
||||||
}
|
}
|
||||||
}));
|
} catch (err) {
|
||||||
|
console.error(`Error getting persistance storage IV: ${err}`);
|
||||||
|
// An IV is required to read/write the encrypted file so if we can't get it then just fail early
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let key: string | undefined;
|
||||||
|
try {
|
||||||
|
key = await this._context.secrets.get(STORAGE_KEY_KEY);
|
||||||
|
if (!key) {
|
||||||
|
// Generate a random key - this is internal to the extension so the user doesn't need to know it
|
||||||
|
key = crypto.createHash('sha256').update(crypto.randomBytes(64)).digest('base64').substring(0, 32);
|
||||||
|
await this._context.secrets.store(STORAGE_KEY_KEY, key);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error getting persistance storage key: ${err}`);
|
||||||
|
// A key is required to read/write the encrypted file so if we can't get it then just fail early
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writeHistoryFileWorker = (): void => {
|
||||||
|
if (this._persistHistory) {
|
||||||
|
try {
|
||||||
|
// We store the history entries in an encrypted file because they may contain sensitive information
|
||||||
|
// such as passwords (even in the query text itself)
|
||||||
|
const cipher = crypto.createCipheriv(STORAGE_ENCRYPTION_ALGORITHM, key!, iv!);
|
||||||
|
const stringifiedItems = JSON.stringify(this._queryHistoryItems);
|
||||||
|
const encryptedText = Buffer.concat([cipher.update(Buffer.from(stringifiedItems)), cipher.final()]);
|
||||||
|
// Use sync here so that we can write this out when the object is disposed
|
||||||
|
fs.writeFileSync(this._historyStorageFile, encryptedText);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error writing query history to disk: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we're not persisting the history then we can skip even trying to load the file (which shouldn't exist)
|
||||||
|
if (!this._persistHistory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read and decrypt any previous history items
|
||||||
|
const encryptedItems = await fs.promises.readFile(this._historyStorageFile);
|
||||||
|
const decipher = crypto.createDecipheriv(STORAGE_ENCRYPTION_ALGORITHM, key, iv);
|
||||||
|
const result = Buffer.concat([decipher.update(encryptedItems), decipher.final()]).toString();
|
||||||
|
this._queryHistoryItems = JSON.parse(result);
|
||||||
|
this._onDidChangeTreeData.fire(undefined);
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore ENOENT errors, those are expected if the storage file doesn't exist (on first run or if results aren't being persisted)
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
console.error(`Error deserializing stored history items: ${err}`);
|
||||||
|
void vscode.window.showWarningMessage(loc.errorLoading(err));
|
||||||
|
// Rename the file to avoid attempting to load a potentially corrupted or unreadable file every time we start up, we'll make
|
||||||
|
// a new one next time we write the history file
|
||||||
|
try {
|
||||||
|
const bakPath = path.join(path.dirname(this._historyStorageFile), `${HISTORY_STORAGE_FILE_NAME}.bak`);
|
||||||
|
await fs.promises.rename(this._historyStorageFile, bakPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error moving corrupted history file: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearAll(): void {
|
/**
|
||||||
|
* Write the query history items to our encrypted file. This is debounced to
|
||||||
|
* prevent doing unnecessary writes if the user is executing many queries in
|
||||||
|
* a row
|
||||||
|
*/
|
||||||
|
@debounce(HISTORY_DEBOUNCE_MS)
|
||||||
|
private writeHistoryFile(): void {
|
||||||
|
this.writeHistoryFileWorker?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearAll(): Promise<void> {
|
||||||
this._queryHistoryItems = [];
|
this._queryHistoryItems = [];
|
||||||
|
this.writeHistoryFile();
|
||||||
this._onDidChangeTreeData.fire(undefined);
|
this._onDidChangeTreeData.fire(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
public deleteItem(item: QueryHistoryItem): void {
|
public async deleteItem(item: QueryHistoryItem): Promise<void> {
|
||||||
this._queryHistoryItems = this._queryHistoryItems.filter(n => n !== item);
|
this._queryHistoryItems = this._queryHistoryItems.filter(n => n !== item);
|
||||||
|
this.writeHistoryFile();
|
||||||
this._onDidChangeTreeData.fire(undefined);
|
this._onDidChangeTreeData.fire(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTreeItem(item: QueryHistoryItem): vscode.TreeItem {
|
public getTreeItem(item: QueryHistoryItem): vscode.TreeItem {
|
||||||
const treeItem = new vscode.TreeItem(removeNewLines(item.queryText), vscode.TreeItemCollapsibleState.None);
|
const treeItem = new vscode.TreeItem(removeNewLines(item.queryText), vscode.TreeItemCollapsibleState.None);
|
||||||
treeItem.iconPath = item.isSuccess ? successIcon : failedIcon;
|
treeItem.iconPath = item.isSuccess ? successIcon : failedIcon;
|
||||||
treeItem.tooltip = item.queryText;
|
treeItem.tooltip = item.queryText;
|
||||||
treeItem.description = item.connectionProfile ? `${item.connectionProfile.serverName}|${item.connectionProfile.databaseName} ${item.timestamp.toLocaleString()}` : item.timestamp.toLocaleString();
|
treeItem.description = item.connectionProfile ? `${item.connectionProfile.serverName}|${item.connectionProfile.databaseName} ${item.timestamp}` : item.timestamp;
|
||||||
treeItem.command = { title: '', command: ITEM_SELECTED_COMMAND_ID, arguments: [item] };
|
treeItem.command = { title: '', command: ITEM_SELECTED_COMMAND_ID, arguments: [item] };
|
||||||
return treeItem;
|
return treeItem;
|
||||||
}
|
}
|
||||||
@@ -97,10 +214,28 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
|
|||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this._disposables.forEach(d => d.dispose());
|
this._disposables.forEach(d => d.dispose());
|
||||||
|
// Call the worker directly to skip the debounce
|
||||||
|
this.writeHistoryFileWorker?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateCaptureEnabled(): void {
|
private async updateConfigurationValues(): Promise<void> {
|
||||||
this._captureEnabled = vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION).get(CAPTURE_ENABLED_CONFIG_SECTION) ?? DEFAULT_CAPTURE_ENABLED;
|
const configSection = vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION);
|
||||||
|
this._captureEnabled = configSection.get(CAPTURE_ENABLED_CONFIG_SECTION, DEFAULT_CAPTURE_ENABLED);
|
||||||
|
this._persistHistory = configSection.get(PERSIST_HISTORY_CONFIG_SECTION, DEFAULT_PERSIST_HISTORY);
|
||||||
|
if (!this._persistHistory) {
|
||||||
|
// If we're no longer persisting the history then clean up our storage file
|
||||||
|
try {
|
||||||
|
await fs.promises.rmdir(this._historyStorageFile);
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore ENOENT errors, those are expected if the storage file doesn't exist (on first run or if results aren't being persisted)
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
// Best effort, we don't want other things to fail if we can't delete the file for some reason
|
||||||
|
console.error(`Error cleaning up query history storage: ${this._historyStorageFile}. ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.writeHistoryFile();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,4 +247,14 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
|
|||||||
this._captureEnabled = enabled;
|
this._captureEnabled = enabled;
|
||||||
return vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION).update(CAPTURE_ENABLED_CONFIG_SECTION, this._captureEnabled, vscode.ConfigurationTarget.Global);
|
return vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION).update(CAPTURE_ENABLED_CONFIG_SECTION, this._captureEnabled, vscode.ConfigurationTarget.Global);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether query history persistence is currently enabled
|
||||||
|
* @param enabled Whether persistence is currently enabled
|
||||||
|
* @returns A promise that resolves when the value is updated and persisted to configuration
|
||||||
|
*/
|
||||||
|
public async setPersistenceEnabled(enabled: boolean): Promise<void> {
|
||||||
|
this._persistHistory = enabled;
|
||||||
|
return vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION).update(PERSIST_HISTORY_CONFIG_SECTION, this._persistHistory, vscode.ConfigurationTarget.Global);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe('QueryHistoryProvider', () => {
|
|||||||
let textDocumentSandbox: sinon.SinonSandbox;
|
let textDocumentSandbox: sinon.SinonSandbox;
|
||||||
const testUri = vscode.Uri.parse('untitled://query1');
|
const testUri = vscode.Uri.parse('untitled://query1');
|
||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(async function (): Promise<void> {
|
||||||
sinon.stub(azdata.queryeditor, 'registerQueryEventListener').callsFake((listener: azdata.queryeditor.QueryEventListener) => {
|
sinon.stub(azdata.queryeditor, 'registerQueryEventListener').callsFake((listener: azdata.queryeditor.QueryEventListener) => {
|
||||||
testListener = listener;
|
testListener = listener;
|
||||||
return { dispose: (): void => { } };
|
return { dispose: (): void => { } };
|
||||||
@@ -28,7 +28,11 @@ describe('QueryHistoryProvider', () => {
|
|||||||
textDocumentSandbox.replaceGetter(vscode.workspace, 'textDocuments', () => [azdataTest.mocks.vscode.createTextDocumentMock(testUri).object]);
|
textDocumentSandbox.replaceGetter(vscode.workspace, 'textDocuments', () => [azdataTest.mocks.vscode.createTextDocumentMock(testUri).object]);
|
||||||
const getConnectionStub = sinon.stub(azdata.connection, 'getConnection');
|
const getConnectionStub = sinon.stub(azdata.connection, 'getConnection');
|
||||||
getConnectionStub.resolves(<any>{});
|
getConnectionStub.resolves(<any>{});
|
||||||
testProvider = new QueryHistoryProvider();
|
// const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration')
|
||||||
|
const contextMock = azdataTest.mocks.vscode.createExtensionContextMock();
|
||||||
|
testProvider = new QueryHistoryProvider(contextMock.object);
|
||||||
|
// Disable persistence during tests
|
||||||
|
await testProvider.setPersistenceEnabled(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function (): void {
|
afterEach(function (): void {
|
||||||
@@ -40,8 +44,8 @@ describe('QueryHistoryProvider', () => {
|
|||||||
should(children).length(0);
|
should(children).length(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Clearing empty list does not throw', function () {
|
it('Clearing empty list does not throw', async function () {
|
||||||
testProvider.clearAll();
|
await testProvider.clearAll();
|
||||||
const children = testProvider.getChildren();
|
const children = testProvider.getChildren();
|
||||||
should(children).length(0);
|
should(children).length(0);
|
||||||
});
|
});
|
||||||
@@ -138,7 +142,7 @@ describe('QueryHistoryProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delete item when no items doesn\'t throw', async function () {
|
it('delete item when no items doesn\'t throw', async function () {
|
||||||
const testItem: QueryHistoryItem = { queryText: 'SELECT 1', connectionProfile: azdataTest.stubs.connectionProfile.createConnectionProfile(), timestamp: new Date(), isSuccess: true };
|
const testItem: QueryHistoryItem = { queryText: 'SELECT 1', connectionProfile: azdataTest.stubs.azdata.createConnectionProfile(), timestamp: new Date().toLocaleString(), isSuccess: true };
|
||||||
await waitForItemRefresh(() => testProvider.deleteItem(testItem));
|
await waitForItemRefresh(() => testProvider.deleteItem(testItem));
|
||||||
const children = testProvider.getChildren();
|
const children = testProvider.getChildren();
|
||||||
should(children).length(0, 'Should have no children after deleting item');
|
should(children).length(0, 'Should have no children after deleting item');
|
||||||
@@ -149,7 +153,7 @@ describe('QueryHistoryProvider', () => {
|
|||||||
let children = testProvider.getChildren();
|
let children = testProvider.getChildren();
|
||||||
should(children).length(1, 'Should have 1 child initially');
|
should(children).length(1, 'Should have 1 child initially');
|
||||||
|
|
||||||
const testItem: QueryHistoryItem = { queryText: 'SELECT 1', connectionProfile: azdataTest.stubs.connectionProfile.createConnectionProfile(), timestamp: new Date(), isSuccess: true };
|
const testItem: QueryHistoryItem = { queryText: 'SELECT 1', connectionProfile: azdataTest.stubs.azdata.createConnectionProfile(), timestamp: new Date().toLocaleString(), isSuccess: true };
|
||||||
await waitForItemRefresh(() => testProvider.deleteItem(testItem));
|
await waitForItemRefresh(() => testProvider.deleteItem(testItem));
|
||||||
children = testProvider.getChildren();
|
children = testProvider.getChildren();
|
||||||
should(children).length(1, 'Should still have 1 child after deleting item');
|
should(children).length(1, 'Should still have 1 child after deleting item');
|
||||||
@@ -213,16 +217,16 @@ describe('QueryHistoryProvider', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fireQueryEventAndWaitForRefresh(type: azdata.queryeditor.QueryEventType, document: azdata.queryeditor.QueryDocument, queryInfo: azdata.queryeditor.QueryInfo, timeoutMs?: number): Promise<void> {
|
async function fireQueryEventAndWaitForRefresh(type: azdata.queryeditor.QueryEventType, document: azdata.queryeditor.QueryDocument, queryInfo: azdata.queryeditor.QueryInfo, timeoutMs?: number): Promise<void> {
|
||||||
await waitForItemRefresh(() => testListener.onQueryEvent(type, document, undefined, queryInfo), timeoutMs);
|
await waitForItemRefresh(async () => testListener.onQueryEvent(type, document, undefined, queryInfo), timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForItemRefresh(func: Function, timeoutMs?: number): Promise<void> {
|
async function waitForItemRefresh(func: () => Promise<void>, timeoutMs?: number): Promise<void> {
|
||||||
const promises: Promise<any>[] = [azdataTest.helpers.eventToPromise(testProvider.onDidChangeTreeData)];
|
const promises: Promise<any>[] = [azdataTest.helpers.eventToPromise(testProvider.onDidChangeTreeData)];
|
||||||
const timeoutPromise = timeoutMs ? new Promise<void>(r => setTimeout(() => r(), timeoutMs)) : undefined;
|
const timeoutPromise = timeoutMs ? new Promise<void>(r => setTimeout(() => r(), timeoutMs)) : undefined;
|
||||||
if (timeoutPromise) {
|
if (timeoutPromise) {
|
||||||
promises.push(timeoutPromise);
|
promises.push(timeoutPromise);
|
||||||
}
|
}
|
||||||
func();
|
await func();
|
||||||
await Promise.race(promises);
|
await Promise.race(promises);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,3 +11,35 @@
|
|||||||
export function removeNewLines(str: string): string {
|
export function removeNewLines(str: string): string {
|
||||||
return str.replace(/\r\n/g, ' ').replace(/\n/g, ' ');
|
return str.replace(/\r\n/g, ' ').replace(/\n/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decorate(decorator: (fn: Function, key: string) => Function): Function {
|
||||||
|
return (_target: any, key: string, descriptor: any): void => {
|
||||||
|
let fnKey: string | null = null;
|
||||||
|
let fn: Function | null = null;
|
||||||
|
|
||||||
|
if (typeof descriptor.value === 'function') {
|
||||||
|
fnKey = 'value';
|
||||||
|
fn = descriptor.value;
|
||||||
|
} else if (typeof descriptor.get === 'function') {
|
||||||
|
fnKey = 'get';
|
||||||
|
fn = descriptor.get;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fn || !fnKey) {
|
||||||
|
throw new Error('not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptor[fnKey] = decorator(fn, key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce(delay: number): Function {
|
||||||
|
return decorate((fn, key) => {
|
||||||
|
const timerKey = `$debounce$${key}`;
|
||||||
|
|
||||||
|
return function (this: any, ...args: any[]): void {
|
||||||
|
clearTimeout(this[timerKey]);
|
||||||
|
this[timerKey] = setTimeout(() => fn.apply(this, args), delay);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -228,10 +228,10 @@
|
|||||||
"@jridgewell/resolve-uri" "^3.0.3"
|
"@jridgewell/resolve-uri" "^3.0.3"
|
||||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||||
|
|
||||||
"@microsoft/azdata-test@^2.0.3":
|
"@microsoft/azdata-test@^3.0.0":
|
||||||
version "2.0.3"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@microsoft/azdata-test/-/azdata-test-2.0.3.tgz#652984efa2f5adc56cdae9029a4d5f33446b54d3"
|
resolved "https://registry.yarnpkg.com/@microsoft/azdata-test/-/azdata-test-3.0.0.tgz#5cc4f416e464e6127dc333afaf983ebe49a66f37"
|
||||||
integrity sha512-BgB6gGjQVXxnZHq7o5TlajZR/mJd/6AqbclrGzoyATvCEt92jRXhPzaY6XA/jMahdUGFSQwXpm45qRhZetwDig==
|
integrity sha512-W0KsXlVF5l0SIcy0HuDqy289o/C1D5AfYxkrlezCeACKsEXyKBsecVojgAY/YN26K5L5AFKXJYaRcqOeq0k76w==
|
||||||
dependencies:
|
dependencies:
|
||||||
http-proxy-agent "^2.1.0"
|
http-proxy-agent "^2.1.0"
|
||||||
https-proxy-agent "^2.2.4"
|
https-proxy-agent "^2.2.4"
|
||||||
@@ -1509,6 +1509,11 @@ update-browserslist-db@^1.0.4:
|
|||||||
escalade "^3.1.1"
|
escalade "^3.1.1"
|
||||||
picocolors "^1.0.0"
|
picocolors "^1.0.0"
|
||||||
|
|
||||||
|
vscode-nls@^4.1.2:
|
||||||
|
version "4.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-4.1.2.tgz#ca8bf8bb82a0987b32801f9fddfdd2fb9fd3c167"
|
||||||
|
integrity sha512-7bOHxPsfyuCqmP+hZXscLhiHwe7CSuFE4hyhbs22xPIhQ4jv99FcR4eBzfYYVLP356HNFpdvz63FFb/xw6T4Iw==
|
||||||
|
|
||||||
which-boxed-primitive@^1.0.2:
|
which-boxed-primitive@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
|
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
|
||||||
|
|||||||
Reference in New Issue
Block a user