1461 lines
50 KiB
TypeScript
1461 lines
50 KiB
TypeScript
import * as path from 'path';
|
|
import {
|
|
commands,
|
|
Disposable,
|
|
env,
|
|
Event,
|
|
EventEmitter,
|
|
ExtensionContext,
|
|
ProviderResult,
|
|
Range,
|
|
ThemeIcon,
|
|
TreeDataProvider,
|
|
TreeItem,
|
|
TreeView,
|
|
Uri,
|
|
ViewColumn,
|
|
window,
|
|
workspace,
|
|
} from 'vscode';
|
|
import { QueryHistoryConfig } from './config';
|
|
import {
|
|
showAndLogErrorMessage,
|
|
showAndLogInformationMessage,
|
|
showAndLogWarningMessage,
|
|
showBinaryChoiceDialog
|
|
} from './helpers';
|
|
import { logger } from './logging';
|
|
import { URLSearchParams } from 'url';
|
|
import { DisposableObject } from './pure/disposable-object';
|
|
import { commandRunner } from './commandRunner';
|
|
import { ONE_HOUR_IN_MS, TWO_HOURS_IN_MS } from './pure/time';
|
|
import { assertNever, getErrorMessage, getErrorStack } from './pure/helpers-pure';
|
|
import { CompletedLocalQueryInfo, LocalQueryInfo } from './query-results';
|
|
import { getActionsWorkflowRunUrl, getQueryId, getQueryText, QueryHistoryInfo } from './query-history-info';
|
|
import { DatabaseManager } from './databases';
|
|
import { registerQueryHistoryScrubber } from './query-history-scrubber';
|
|
import { QueryStatus, variantAnalysisStatusToQueryStatus } from './query-status';
|
|
import { slurpQueryHistory, splatQueryHistory } from './query-serialization';
|
|
import * as fs from 'fs-extra';
|
|
import { CliVersionConstraint } from './cli';
|
|
import { HistoryItemLabelProvider } from './history-item-label-provider';
|
|
import { Credentials } from './authentication';
|
|
import { cancelRemoteQuery } from './remote-queries/gh-api/gh-actions-api-client';
|
|
import { RemoteQueriesManager } from './remote-queries/remote-queries-manager';
|
|
import { RemoteQueryHistoryItem } from './remote-queries/remote-query-history-item';
|
|
import { ResultsView } from './interface';
|
|
import { WebviewReveal } from './interface-utils';
|
|
import { EvalLogViewer } from './eval-log-viewer';
|
|
import EvalLogTreeBuilder from './eval-log-tree-builder';
|
|
import { EvalLogData, parseViewerData } from './pure/log-summary-parser';
|
|
import { QueryWithResults } from './run-queries-shared';
|
|
import { QueryRunner } from './queryRunner';
|
|
import { VariantAnalysisManager } from './remote-queries/variant-analysis-manager';
|
|
import { VariantAnalysisHistoryItem } from './remote-queries/variant-analysis-history-item';
|
|
import { getTotalResultCount } from './remote-queries/shared/variant-analysis';
|
|
|
|
/**
|
|
* query-history.ts
|
|
* ------------
|
|
* Managing state of previous queries that we've executed.
|
|
*
|
|
* The source of truth of the current state resides inside the
|
|
* `TreeDataProvider` subclass below.
|
|
*/
|
|
|
|
export const SHOW_QUERY_TEXT_MSG = `\
|
|
////////////////////////////////////////////////////////////////////////////////////
|
|
// This is the text of the entire query file when it was executed for this query //
|
|
// run. The text or dependent libraries may have changed since then. //
|
|
// //
|
|
// This buffer is readonly. To re-execute this query, you must open the original //
|
|
// query file. //
|
|
////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
`;
|
|
|
|
const SHOW_QUERY_TEXT_QUICK_EVAL_MSG = `\
|
|
////////////////////////////////////////////////////////////////////////////////////
|
|
// This is the Quick Eval selection of the query file when it was executed for //
|
|
// this query run. The text or dependent libraries may have changed since then. //
|
|
// //
|
|
// This buffer is readonly. To re-execute this query, you must open the original //
|
|
// query file. //
|
|
////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
`;
|
|
|
|
/**
|
|
* Path to icon to display next to a failed query history item.
|
|
*/
|
|
const FAILED_QUERY_HISTORY_ITEM_ICON = 'media/red-x.svg';
|
|
|
|
/**
|
|
* Path to icon to display next to a successful local run.
|
|
*/
|
|
const LOCAL_SUCCESS_QUERY_HISTORY_ITEM_ICON = 'media/drive.svg';
|
|
|
|
/**
|
|
* Path to icon to display next to a successful remote run.
|
|
*/
|
|
const REMOTE_SUCCESS_QUERY_HISTORY_ITEM_ICON = 'media/globe.svg';
|
|
|
|
export enum SortOrder {
|
|
NameAsc = 'NameAsc',
|
|
NameDesc = 'NameDesc',
|
|
DateAsc = 'DateAsc',
|
|
DateDesc = 'DateDesc',
|
|
CountAsc = 'CountAsc',
|
|
CountDesc = 'CountDesc',
|
|
}
|
|
|
|
/**
|
|
* Number of milliseconds two clicks have to arrive apart to be
|
|
* considered a double-click.
|
|
*/
|
|
const DOUBLE_CLICK_TIME = 500;
|
|
|
|
const WORKSPACE_QUERY_HISTORY_FILE = 'workspace-query-history.json';
|
|
|
|
/**
|
|
* Tree data provider for the query history view.
|
|
*/
|
|
export class HistoryTreeDataProvider extends DisposableObject implements TreeDataProvider<QueryHistoryInfo> {
|
|
private _sortOrder = SortOrder.DateAsc;
|
|
|
|
private _onDidChangeTreeData = super.push(new EventEmitter<QueryHistoryInfo | undefined>());
|
|
|
|
readonly onDidChangeTreeData: Event<QueryHistoryInfo | undefined> = this
|
|
._onDidChangeTreeData.event;
|
|
|
|
private _onDidChangeCurrentQueryItem = super.push(new EventEmitter<QueryHistoryInfo | undefined>());
|
|
|
|
public readonly onDidChangeCurrentQueryItem = this._onDidChangeCurrentQueryItem.event;
|
|
|
|
private history: QueryHistoryInfo[] = [];
|
|
|
|
private failedIconPath: string;
|
|
|
|
private localSuccessIconPath: string;
|
|
|
|
private remoteSuccessIconPath: string;
|
|
|
|
private current: QueryHistoryInfo | undefined;
|
|
|
|
constructor(
|
|
extensionPath: string,
|
|
private readonly labelProvider: HistoryItemLabelProvider,
|
|
) {
|
|
super();
|
|
this.failedIconPath = path.join(
|
|
extensionPath,
|
|
FAILED_QUERY_HISTORY_ITEM_ICON
|
|
);
|
|
this.localSuccessIconPath = path.join(
|
|
extensionPath,
|
|
LOCAL_SUCCESS_QUERY_HISTORY_ITEM_ICON
|
|
);
|
|
this.remoteSuccessIconPath = path.join(
|
|
extensionPath,
|
|
REMOTE_SUCCESS_QUERY_HISTORY_ITEM_ICON
|
|
);
|
|
}
|
|
|
|
async getTreeItem(element: QueryHistoryInfo): Promise<TreeItem> {
|
|
const treeItem = new TreeItem(this.labelProvider.getLabel(element));
|
|
|
|
treeItem.command = {
|
|
title: 'Query History Item',
|
|
command: 'codeQLQueryHistory.itemClicked',
|
|
arguments: [element],
|
|
tooltip: element.failureReason || this.labelProvider.getLabel(element)
|
|
};
|
|
|
|
// Populate the icon and the context value. We use the context value to
|
|
// control which commands are visible in the context menu.
|
|
let hasResults;
|
|
switch (element.status) {
|
|
case QueryStatus.InProgress:
|
|
treeItem.iconPath = new ThemeIcon('sync~spin');
|
|
treeItem.contextValue = element.t === 'local' ? 'inProgressResultsItem' : 'inProgressRemoteResultsItem';
|
|
break;
|
|
case QueryStatus.Completed:
|
|
if (element.t === 'local') {
|
|
hasResults = await element.completedQuery?.query.hasInterpretedResults();
|
|
treeItem.iconPath = this.localSuccessIconPath;
|
|
treeItem.contextValue = hasResults
|
|
? 'interpretedResultsItem'
|
|
: 'rawResultsItem';
|
|
} else {
|
|
treeItem.iconPath = this.remoteSuccessIconPath;
|
|
treeItem.contextValue = 'remoteResultsItem';
|
|
}
|
|
break;
|
|
case QueryStatus.Failed:
|
|
treeItem.iconPath = this.failedIconPath;
|
|
treeItem.contextValue = element.t === 'local' ? 'cancelledResultsItem' : 'cancelledRemoteResultsItem';
|
|
break;
|
|
default:
|
|
assertNever(element.status);
|
|
}
|
|
|
|
return treeItem;
|
|
}
|
|
|
|
getChildren(
|
|
element?: QueryHistoryInfo
|
|
): ProviderResult<QueryHistoryInfo[]> {
|
|
return element ? [] : this.history.sort((h1, h2) => {
|
|
|
|
const h1Label = this.labelProvider.getLabel(h1).toLowerCase();
|
|
const h2Label = this.labelProvider.getLabel(h2).toLowerCase();
|
|
|
|
const h1Date = this.getItemDate(h1);
|
|
|
|
const h2Date = this.getItemDate(h2);
|
|
|
|
const resultCount1 = h1.t === 'local'
|
|
? h1.completedQuery?.resultCount ?? -1
|
|
: h1.resultCount ?? -1;
|
|
const resultCount2 = h2.t === 'local'
|
|
? h2.completedQuery?.resultCount ?? -1
|
|
: h2.resultCount ?? -1;
|
|
|
|
switch (this.sortOrder) {
|
|
case SortOrder.NameAsc:
|
|
return h1Label.localeCompare(h2Label, env.language);
|
|
|
|
case SortOrder.NameDesc:
|
|
return h2Label.localeCompare(h1Label, env.language);
|
|
|
|
case SortOrder.DateAsc:
|
|
return h1Date - h2Date;
|
|
|
|
case SortOrder.DateDesc:
|
|
return h2Date - h1Date;
|
|
|
|
case SortOrder.CountAsc:
|
|
// If the result counts are equal, sort by name.
|
|
return resultCount1 - resultCount2 === 0
|
|
? h1Label.localeCompare(h2Label, env.language)
|
|
: resultCount1 - resultCount2;
|
|
|
|
case SortOrder.CountDesc:
|
|
// If the result counts are equal, sort by name.
|
|
return resultCount2 - resultCount1 === 0
|
|
? h2Label.localeCompare(h1Label, env.language)
|
|
: resultCount2 - resultCount1;
|
|
default:
|
|
assertNever(this.sortOrder);
|
|
}
|
|
});
|
|
}
|
|
|
|
getParent(_element: QueryHistoryInfo): ProviderResult<QueryHistoryInfo> {
|
|
return null;
|
|
}
|
|
|
|
getCurrent(): QueryHistoryInfo | undefined {
|
|
return this.current;
|
|
}
|
|
|
|
pushQuery(item: QueryHistoryInfo): void {
|
|
this.history.push(item);
|
|
this.setCurrentItem(item);
|
|
this.refresh();
|
|
}
|
|
|
|
setCurrentItem(item?: QueryHistoryInfo) {
|
|
if (item !== this.current) {
|
|
this.current = item;
|
|
this._onDidChangeCurrentQueryItem.fire(item);
|
|
}
|
|
}
|
|
|
|
remove(item: QueryHistoryInfo) {
|
|
const isCurrent = this.current === item;
|
|
if (isCurrent) {
|
|
this.setCurrentItem();
|
|
}
|
|
const index = this.history.findIndex((i) => i === item);
|
|
if (index >= 0) {
|
|
this.history.splice(index, 1);
|
|
if (isCurrent && this.history.length > 0) {
|
|
// Try to keep a current item, near the deleted item if there
|
|
// are any available.
|
|
this.setCurrentItem(this.history[Math.min(index, this.history.length - 1)]);
|
|
}
|
|
this.refresh();
|
|
}
|
|
}
|
|
|
|
get allHistory(): QueryHistoryInfo[] {
|
|
return this.history;
|
|
}
|
|
|
|
set allHistory(history: QueryHistoryInfo[]) {
|
|
this.history = history;
|
|
this.setCurrentItem(history[0]);
|
|
this.refresh();
|
|
}
|
|
|
|
refresh() {
|
|
this._onDidChangeTreeData.fire(undefined);
|
|
}
|
|
|
|
public get sortOrder() {
|
|
return this._sortOrder;
|
|
}
|
|
|
|
public set sortOrder(newSortOrder: SortOrder) {
|
|
this._sortOrder = newSortOrder;
|
|
this._onDidChangeTreeData.fire(undefined);
|
|
}
|
|
|
|
private getItemDate(item: QueryHistoryInfo) {
|
|
switch (item.t) {
|
|
case 'local':
|
|
return item.initialInfo.start.getTime();
|
|
case 'remote':
|
|
return item.remoteQuery.executionStartTime;
|
|
case 'variant-analysis':
|
|
return item.variantAnalysis.executionStartTime;
|
|
default:
|
|
assertNever(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class QueryHistoryManager extends DisposableObject {
|
|
|
|
treeDataProvider: HistoryTreeDataProvider;
|
|
treeView: TreeView<QueryHistoryInfo>;
|
|
lastItemClick: { time: Date; item: QueryHistoryInfo } | undefined;
|
|
compareWithItem: LocalQueryInfo | undefined;
|
|
queryHistoryScrubber: Disposable | undefined;
|
|
private queryMetadataStorageLocation;
|
|
|
|
private readonly _onDidChangeCurrentQueryItem = super.push(new EventEmitter<QueryHistoryInfo | undefined>());
|
|
readonly onDidChangeCurrentQueryItem = this._onDidChangeCurrentQueryItem.event;
|
|
|
|
private readonly _onDidCompleteQuery = super.push(new EventEmitter<LocalQueryInfo>());
|
|
readonly onDidCompleteQuery = this._onDidCompleteQuery.event;
|
|
|
|
constructor(
|
|
private readonly qs: QueryRunner,
|
|
private readonly dbm: DatabaseManager,
|
|
private readonly localQueriesResultsView: ResultsView,
|
|
private readonly remoteQueriesManager: RemoteQueriesManager,
|
|
private readonly variantAnalysisManager: VariantAnalysisManager,
|
|
private readonly evalLogViewer: EvalLogViewer,
|
|
private readonly queryStorageDir: string,
|
|
private readonly ctx: ExtensionContext,
|
|
private readonly queryHistoryConfigListener: QueryHistoryConfig,
|
|
private readonly labelProvider: HistoryItemLabelProvider,
|
|
private readonly doCompareCallback: (
|
|
from: CompletedLocalQueryInfo,
|
|
to: CompletedLocalQueryInfo
|
|
) => Promise<void>
|
|
) {
|
|
super();
|
|
|
|
// Note that we use workspace storage to hold the metadata for the query history.
|
|
// This is because the query history is specific to each workspace.
|
|
// For situations where `ctx.storageUri` is undefined (i.e., there is no workspace),
|
|
// we default to global storage.
|
|
this.queryMetadataStorageLocation = path.join((ctx.storageUri || ctx.globalStorageUri).fsPath, WORKSPACE_QUERY_HISTORY_FILE);
|
|
|
|
this.treeDataProvider = this.push(new HistoryTreeDataProvider(
|
|
ctx.extensionPath,
|
|
this.labelProvider
|
|
));
|
|
this.treeView = this.push(window.createTreeView('codeQLQueryHistory', {
|
|
treeDataProvider: this.treeDataProvider,
|
|
canSelectMany: true,
|
|
}));
|
|
|
|
// Forward any change of current history item from the tree data.
|
|
this.push(this.treeDataProvider.onDidChangeCurrentQueryItem((item) => {
|
|
this._onDidChangeCurrentQueryItem.fire(item);
|
|
}));
|
|
|
|
// Lazily update the tree view selection due to limitations of TreeView API (see
|
|
// `updateTreeViewSelectionIfVisible` doc for details)
|
|
this.push(
|
|
this.treeView.onDidChangeVisibility(async (_ev) =>
|
|
this.updateTreeViewSelectionIfVisible()
|
|
)
|
|
);
|
|
this.push(
|
|
this.treeView.onDidChangeSelection(async (ev) => {
|
|
if (ev.selection.length === 0) {
|
|
// Don't allow the selection to become empty
|
|
this.updateTreeViewSelectionIfVisible();
|
|
} else {
|
|
this.treeDataProvider.setCurrentItem(ev.selection[0]);
|
|
}
|
|
if (ev.selection.some(item => item.t !== 'local')) {
|
|
// Don't allow comparison of non-local items
|
|
this.updateCompareWith([]);
|
|
} else {
|
|
this.updateCompareWith([...ev.selection] as LocalQueryInfo[]);
|
|
}
|
|
})
|
|
);
|
|
|
|
void logger.log('Registering query history panel commands.');
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.openQuery',
|
|
this.handleOpenQuery.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.removeHistoryItem',
|
|
this.handleRemoveHistoryItem.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.sortByName',
|
|
this.handleSortByName.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.sortByDate',
|
|
this.handleSortByDate.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.sortByCount',
|
|
this.handleSortByCount.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.setLabel',
|
|
this.handleSetLabel.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.compareWith',
|
|
this.handleCompareWith.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.showQueryLog',
|
|
this.handleShowQueryLog.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.openQueryDirectory',
|
|
this.handleOpenQueryDirectory.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.showEvalLog',
|
|
this.handleShowEvalLog.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.showEvalLogSummary',
|
|
this.handleShowEvalLogSummary.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.showEvalLogViewer',
|
|
this.handleShowEvalLogViewer.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.cancel',
|
|
this.handleCancel.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.showQueryText',
|
|
this.handleShowQueryText.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.exportResults',
|
|
this.handleExportResults.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.viewCsvResults',
|
|
this.handleViewCsvResults.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.viewCsvAlerts',
|
|
this.handleViewCsvAlerts.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.viewSarifAlerts',
|
|
this.handleViewSarifAlerts.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.viewDil',
|
|
this.handleViewDil.bind(this)
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.itemClicked',
|
|
async (item: LocalQueryInfo) => {
|
|
return this.handleItemClicked(item, [item]);
|
|
}
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.openOnGithub',
|
|
async (item: LocalQueryInfo) => {
|
|
return this.handleOpenOnGithub(item, [item]);
|
|
}
|
|
)
|
|
);
|
|
this.push(
|
|
commandRunner(
|
|
'codeQLQueryHistory.copyRepoList',
|
|
this.handleCopyRepoList.bind(this)
|
|
)
|
|
);
|
|
|
|
// There are two configuration items that affect the query history:
|
|
// 1. The ttl for query history items.
|
|
// 2. The default label for query history items.
|
|
// When either of these change, must refresh the tree view.
|
|
this.push(
|
|
queryHistoryConfigListener.onDidChangeConfiguration(() => {
|
|
this.treeDataProvider.refresh();
|
|
this.registerQueryHistoryScrubber(queryHistoryConfigListener, this, ctx);
|
|
})
|
|
);
|
|
|
|
// displays query text in a read-only document
|
|
this.push(workspace.registerTextDocumentContentProvider('codeql', {
|
|
provideTextDocumentContent(
|
|
uri: Uri
|
|
): ProviderResult<string> {
|
|
const params = new URLSearchParams(uri.query);
|
|
|
|
return (
|
|
(JSON.parse(params.get('isQuickEval') || '')
|
|
? SHOW_QUERY_TEXT_QUICK_EVAL_MSG
|
|
: SHOW_QUERY_TEXT_MSG) + params.get('queryText')
|
|
);
|
|
},
|
|
}));
|
|
|
|
this.registerQueryHistoryScrubber(queryHistoryConfigListener, this, ctx);
|
|
this.registerToRemoteQueriesEvents();
|
|
this.registerToVariantAnalysisEvents();
|
|
}
|
|
|
|
public completeQuery(info: LocalQueryInfo, results: QueryWithResults): void {
|
|
info.completeThisQuery(results);
|
|
this._onDidCompleteQuery.fire(info);
|
|
}
|
|
|
|
private getCredentials() {
|
|
return Credentials.initialize(this.ctx);
|
|
}
|
|
|
|
/**
|
|
* Register and create the history scrubber.
|
|
*/
|
|
private registerQueryHistoryScrubber(queryHistoryConfigListener: QueryHistoryConfig, qhm: QueryHistoryManager, ctx: ExtensionContext) {
|
|
this.queryHistoryScrubber?.dispose();
|
|
// Every hour check if we need to re-run the query history scrubber.
|
|
this.queryHistoryScrubber = this.push(
|
|
registerQueryHistoryScrubber(
|
|
ONE_HOUR_IN_MS,
|
|
TWO_HOURS_IN_MS,
|
|
queryHistoryConfigListener.ttlInMillis,
|
|
this.queryStorageDir,
|
|
qhm,
|
|
ctx
|
|
)
|
|
);
|
|
}
|
|
|
|
private registerToVariantAnalysisEvents() {
|
|
const variantAnalysisAddedSubscription = this.variantAnalysisManager.onVariantAnalysisAdded(async (variantAnalysis) => {
|
|
this.addQuery({
|
|
t: 'variant-analysis',
|
|
status: QueryStatus.InProgress,
|
|
completed: false,
|
|
variantAnalysis,
|
|
});
|
|
|
|
await this.refreshTreeView();
|
|
});
|
|
|
|
const variantAnalysisStatusUpdateSubscription = this.variantAnalysisManager.onVariantAnalysisStatusUpdated(async (variantAnalysis) => {
|
|
const items = this.treeDataProvider.allHistory.filter(i => i.t === 'variant-analysis' && i.variantAnalysis.id === variantAnalysis.id);
|
|
const status = variantAnalysisStatusToQueryStatus(variantAnalysis.status);
|
|
|
|
if (items.length > 0) {
|
|
items.forEach((item) => {
|
|
const variantAnalysisHistoryItem = item as VariantAnalysisHistoryItem;
|
|
variantAnalysisHistoryItem.status = status;
|
|
variantAnalysisHistoryItem.failureReason = variantAnalysis.failureReason;
|
|
variantAnalysisHistoryItem.resultCount = getTotalResultCount(variantAnalysis.scannedRepos);
|
|
variantAnalysisHistoryItem.variantAnalysis = variantAnalysis;
|
|
if (status === QueryStatus.Completed) {
|
|
variantAnalysisHistoryItem.completed = true;
|
|
}
|
|
});
|
|
await this.refreshTreeView();
|
|
} else {
|
|
void logger.log('Variant analysis status update event received for unknown variant analysis');
|
|
}
|
|
});
|
|
|
|
const variantAnalysisRemovedSubscription = this.variantAnalysisManager.onVariantAnalysisRemoved(async (variantAnalysis) => {
|
|
const items = this.treeDataProvider.allHistory.filter(i => i.t === 'variant-analysis' && i.variantAnalysis.id === variantAnalysis.id);
|
|
await Promise.all(items.map(async (item) => {
|
|
await this.removeVariantAnalysis(item as VariantAnalysisHistoryItem);
|
|
}));
|
|
});
|
|
|
|
this.push(variantAnalysisAddedSubscription);
|
|
this.push(variantAnalysisStatusUpdateSubscription);
|
|
this.push(variantAnalysisRemovedSubscription);
|
|
}
|
|
|
|
private registerToRemoteQueriesEvents() {
|
|
const queryAddedSubscription = this.remoteQueriesManager.onRemoteQueryAdded(async (event) => {
|
|
this.addQuery({
|
|
t: 'remote',
|
|
status: QueryStatus.InProgress,
|
|
completed: false,
|
|
queryId: event.queryId,
|
|
remoteQuery: event.query,
|
|
});
|
|
|
|
await this.refreshTreeView();
|
|
});
|
|
|
|
const queryRemovedSubscription = this.remoteQueriesManager.onRemoteQueryRemoved(async (event) => {
|
|
const item = this.treeDataProvider.allHistory.find(i => i.t === 'remote' && i.queryId === event.queryId);
|
|
if (item) {
|
|
await this.removeRemoteQuery(item as RemoteQueryHistoryItem);
|
|
}
|
|
});
|
|
|
|
const queryStatusUpdateSubscription = this.remoteQueriesManager.onRemoteQueryStatusUpdate(async (event) => {
|
|
const item = this.treeDataProvider.allHistory.find(i => i.t === 'remote' && i.queryId === event.queryId);
|
|
if (item) {
|
|
const remoteQueryHistoryItem = item as RemoteQueryHistoryItem;
|
|
remoteQueryHistoryItem.status = event.status;
|
|
remoteQueryHistoryItem.failureReason = event.failureReason;
|
|
remoteQueryHistoryItem.resultCount = event.resultCount;
|
|
if (event.status === QueryStatus.Completed) {
|
|
remoteQueryHistoryItem.completed = true;
|
|
}
|
|
await this.refreshTreeView();
|
|
} else {
|
|
void logger.log('Variant analysis status update event received for unknown variant analysis');
|
|
}
|
|
});
|
|
|
|
this.push(queryAddedSubscription);
|
|
this.push(queryRemovedSubscription);
|
|
this.push(queryStatusUpdateSubscription);
|
|
}
|
|
|
|
async readQueryHistory(): Promise<void> {
|
|
void logger.log(`Reading cached query history from '${this.queryMetadataStorageLocation}'.`);
|
|
const history = await slurpQueryHistory(this.queryMetadataStorageLocation);
|
|
this.treeDataProvider.allHistory = history;
|
|
await Promise.all(this.treeDataProvider.allHistory.map(async (item) => {
|
|
if (item.t === 'remote') {
|
|
await this.remoteQueriesManager.rehydrateRemoteQuery(item.queryId, item.remoteQuery, item.status);
|
|
}
|
|
if (item.t === 'variant-analysis') {
|
|
await this.variantAnalysisManager.rehydrateVariantAnalysis(item.variantAnalysis);
|
|
}
|
|
}));
|
|
}
|
|
|
|
async writeQueryHistory(): Promise<void> {
|
|
await splatQueryHistory(this.treeDataProvider.allHistory, this.queryMetadataStorageLocation);
|
|
}
|
|
|
|
async handleOpenQuery(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
): Promise<void> {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem) {
|
|
return;
|
|
}
|
|
|
|
let queryPath: string;
|
|
switch (finalSingleItem.t) {
|
|
case 'local':
|
|
queryPath = finalSingleItem.initialInfo.queryPath;
|
|
break;
|
|
case 'remote':
|
|
queryPath = finalSingleItem.remoteQuery.queryFilePath;
|
|
break;
|
|
case 'variant-analysis':
|
|
queryPath = finalSingleItem.variantAnalysis.query.filePath;
|
|
break;
|
|
default:
|
|
assertNever(finalSingleItem);
|
|
}
|
|
const textDocument = await workspace.openTextDocument(
|
|
Uri.file(queryPath)
|
|
);
|
|
const editor = await window.showTextDocument(
|
|
textDocument,
|
|
ViewColumn.One
|
|
);
|
|
|
|
if (finalSingleItem.t === 'local') {
|
|
const queryText = finalSingleItem.initialInfo.queryText;
|
|
if (queryText !== undefined && finalSingleItem.initialInfo.isQuickQuery) {
|
|
await editor.edit((edit) =>
|
|
edit.replace(
|
|
textDocument.validateRange(
|
|
new Range(0, 0, textDocument.lineCount, 0)
|
|
),
|
|
queryText
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
getCurrentQueryHistoryItem(): QueryHistoryInfo | undefined {
|
|
return this.treeDataProvider.getCurrent();
|
|
}
|
|
|
|
getRemoteQueryById(queryId: string): RemoteQueryHistoryItem | undefined {
|
|
return this.treeDataProvider.allHistory.find(i => i.t === 'remote' && i.queryId === queryId) as RemoteQueryHistoryItem;
|
|
}
|
|
|
|
async removeDeletedQueries() {
|
|
await Promise.all(this.treeDataProvider.allHistory.map(async (item) => {
|
|
if (item.t == 'local' && item.completedQuery && !(await fs.pathExists(item.completedQuery?.query.querySaveDir))) {
|
|
this.treeDataProvider.remove(item);
|
|
item.completedQuery?.dispose();
|
|
}
|
|
}));
|
|
}
|
|
|
|
async handleRemoveHistoryItem(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[] = []
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
const toDelete = (finalMultiSelect || [finalSingleItem]);
|
|
await Promise.all(toDelete.map(async (item) => {
|
|
if (item.t === 'local') {
|
|
// Removing in progress local queries is not supported. They must be cancelled first.
|
|
if (item.status !== QueryStatus.InProgress) {
|
|
this.treeDataProvider.remove(item);
|
|
item.completedQuery?.dispose();
|
|
|
|
// User has explicitly asked for this query to be removed.
|
|
// We need to delete it from disk as well.
|
|
await item.completedQuery?.query.deleteQuery();
|
|
}
|
|
} else if (item.t === 'remote') {
|
|
await this.removeRemoteQuery(item);
|
|
} else if (item.t === 'variant-analysis') {
|
|
await this.removeVariantAnalysis(item);
|
|
} else {
|
|
assertNever(item);
|
|
}
|
|
}));
|
|
|
|
await this.writeQueryHistory();
|
|
const current = this.treeDataProvider.getCurrent();
|
|
if (current !== undefined) {
|
|
await this.treeView.reveal(current, { select: true });
|
|
await this.openQueryResults(current);
|
|
}
|
|
}
|
|
|
|
private async removeRemoteQuery(item: RemoteQueryHistoryItem): Promise<void> {
|
|
// Remote queries can be removed locally, but not remotely.
|
|
// The user must cancel the query on GitHub Actions explicitly.
|
|
this.treeDataProvider.remove(item);
|
|
void logger.log(`Deleted ${this.labelProvider.getLabel(item)}.`);
|
|
if (item.status === QueryStatus.InProgress) {
|
|
void logger.log('The variant analysis is still running on GitHub Actions. To cancel there, you must go to the workflow run in your browser.');
|
|
}
|
|
|
|
await this.remoteQueriesManager.removeRemoteQuery(item.queryId);
|
|
}
|
|
|
|
private async removeVariantAnalysis(item: VariantAnalysisHistoryItem): Promise<void> {
|
|
// We can remove a Variant Analysis locally, but not remotely.
|
|
// The user must cancel the query on GitHub Actions explicitly.
|
|
this.treeDataProvider.remove(item);
|
|
void logger.log(`Deleted ${this.labelProvider.getLabel(item)}.`);
|
|
if (item.status === QueryStatus.InProgress) {
|
|
void logger.log('The variant analysis is still running on GitHub Actions. To cancel there, you must go to the workflow run in your browser.');
|
|
}
|
|
|
|
await this.variantAnalysisManager.removeVariantAnalysis(item.variantAnalysis);
|
|
}
|
|
|
|
async handleSortByName() {
|
|
if (this.treeDataProvider.sortOrder === SortOrder.NameAsc) {
|
|
this.treeDataProvider.sortOrder = SortOrder.NameDesc;
|
|
} else {
|
|
this.treeDataProvider.sortOrder = SortOrder.NameAsc;
|
|
}
|
|
}
|
|
|
|
async handleSortByDate() {
|
|
if (this.treeDataProvider.sortOrder === SortOrder.DateAsc) {
|
|
this.treeDataProvider.sortOrder = SortOrder.DateDesc;
|
|
} else {
|
|
this.treeDataProvider.sortOrder = SortOrder.DateAsc;
|
|
}
|
|
}
|
|
|
|
async handleSortByCount() {
|
|
if (this.treeDataProvider.sortOrder === SortOrder.CountAsc) {
|
|
this.treeDataProvider.sortOrder = SortOrder.CountDesc;
|
|
} else {
|
|
this.treeDataProvider.sortOrder = SortOrder.CountAsc;
|
|
}
|
|
}
|
|
|
|
async handleSetLabel(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
): Promise<void> {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
if (!this.assertSingleQuery(finalMultiSelect)) {
|
|
return;
|
|
}
|
|
|
|
const response = await window.showInputBox({
|
|
placeHolder: `(use default: ${this.queryHistoryConfigListener.format})`,
|
|
value: finalSingleItem.userSpecifiedLabel ?? '',
|
|
title: 'Set query label',
|
|
prompt: 'Set the query history item label. See the description of the codeQL.queryHistory.format setting for more information.',
|
|
});
|
|
// undefined response means the user cancelled the dialog; don't change anything
|
|
if (response !== undefined) {
|
|
// Interpret empty string response as 'go back to using default'
|
|
finalSingleItem.userSpecifiedLabel = response === '' ? undefined : response;
|
|
await this.refreshTreeView();
|
|
}
|
|
}
|
|
|
|
async handleCompareWith(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
try {
|
|
// local queries only
|
|
if (finalSingleItem?.t !== 'local') {
|
|
throw new Error('Please select a local query.');
|
|
}
|
|
|
|
if (!finalSingleItem.completedQuery?.successful) {
|
|
throw new Error('Please select a query that has completed successfully.');
|
|
}
|
|
|
|
const from = this.compareWithItem || singleItem;
|
|
const to = await this.findOtherQueryToCompare(from, finalMultiSelect);
|
|
|
|
if (from.completed && to?.completed) {
|
|
await this.doCompareCallback(from as CompletedLocalQueryInfo, to as CompletedLocalQueryInfo);
|
|
}
|
|
} catch (e) {
|
|
void showAndLogErrorMessage(getErrorMessage(e));
|
|
}
|
|
}
|
|
|
|
async handleItemClicked(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[] = []
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem) {
|
|
return;
|
|
}
|
|
|
|
this.treeDataProvider.setCurrentItem(finalSingleItem);
|
|
|
|
const now = new Date();
|
|
const prevItemClick = this.lastItemClick;
|
|
this.lastItemClick = { time: now, item: finalSingleItem };
|
|
|
|
if (
|
|
prevItemClick !== undefined &&
|
|
now.valueOf() - prevItemClick.time.valueOf() < DOUBLE_CLICK_TIME &&
|
|
finalSingleItem == prevItemClick.item
|
|
) {
|
|
// show original query file on double click
|
|
await this.handleOpenQuery(finalSingleItem, [finalSingleItem]);
|
|
} else {
|
|
// show results on single click (if results view is available)
|
|
if (finalSingleItem.t === 'variant-analysis' || finalSingleItem.status === QueryStatus.Completed) {
|
|
await this.openQueryResults(finalSingleItem);
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleShowQueryLog(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
) {
|
|
// Local queries only
|
|
if (!this.assertSingleQuery(multiSelect) || singleItem?.t !== 'local') {
|
|
return;
|
|
}
|
|
|
|
if (!singleItem.completedQuery) {
|
|
return;
|
|
}
|
|
|
|
if (singleItem.completedQuery.logFileLocation) {
|
|
await this.tryOpenExternalFile(singleItem.completedQuery.logFileLocation);
|
|
} else {
|
|
void showAndLogWarningMessage('No log file available');
|
|
}
|
|
}
|
|
|
|
async getQueryHistoryItemDirectory(queryHistoryItem: QueryHistoryInfo): Promise<string> {
|
|
if (queryHistoryItem.t === 'local') {
|
|
if (queryHistoryItem.completedQuery) {
|
|
return queryHistoryItem.completedQuery.query.querySaveDir;
|
|
}
|
|
} else if (queryHistoryItem.t === 'remote') {
|
|
return path.join(this.queryStorageDir, queryHistoryItem.queryId);
|
|
} else if (queryHistoryItem.t === 'variant-analysis') {
|
|
return this.variantAnalysisManager.getVariantAnalysisStorageLocation(queryHistoryItem.variantAnalysis.id);
|
|
}
|
|
|
|
throw new Error('Unable to get query directory');
|
|
}
|
|
|
|
async handleOpenQueryDirectory(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem) {
|
|
return;
|
|
}
|
|
|
|
let externalFilePath: string | undefined;
|
|
if (finalSingleItem.t === 'local') {
|
|
if (finalSingleItem.completedQuery) {
|
|
externalFilePath = path.join(finalSingleItem.completedQuery.query.querySaveDir, 'timestamp');
|
|
}
|
|
} else if (finalSingleItem.t === 'remote') {
|
|
externalFilePath = path.join(this.queryStorageDir, finalSingleItem.queryId, 'timestamp');
|
|
} else if (finalSingleItem.t === 'variant-analysis') {
|
|
externalFilePath = path.join(this.variantAnalysisManager.getVariantAnalysisStorageLocation(finalSingleItem.variantAnalysis.id), 'timestamp');
|
|
}
|
|
|
|
if (externalFilePath) {
|
|
if (!(await fs.pathExists(externalFilePath))) {
|
|
// timestamp file is missing (manually deleted?) try selecting the parent folder.
|
|
// It's less nice, but at least it will work.
|
|
externalFilePath = path.dirname(externalFilePath);
|
|
if (!(await fs.pathExists(externalFilePath))) {
|
|
throw new Error(`Query directory does not exist: ${externalFilePath}`);
|
|
}
|
|
}
|
|
try {
|
|
await commands.executeCommand('revealFileInOS', Uri.file(externalFilePath));
|
|
} catch (e) {
|
|
throw new Error(`Failed to open ${externalFilePath}: ${getErrorMessage(e)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private warnNoEvalLogs() {
|
|
void showAndLogWarningMessage(`Evaluator log, summary, and viewer are not available for this run. Perhaps it failed before evaluation, or you are running with a version of CodeQL before ' + ${CliVersionConstraint.CLI_VERSION_WITH_PER_QUERY_EVAL_LOG}?`);
|
|
}
|
|
|
|
private warnInProgressEvalLogSummary() {
|
|
void showAndLogWarningMessage('The evaluator log summary is still being generated for this run. Please try again later. The summary generation process is tracked in the "CodeQL Extension Log" view.');
|
|
}
|
|
|
|
private warnInProgressEvalLogViewer() {
|
|
void showAndLogWarningMessage('The viewer\'s data is still being generated for this run. Please try again or re-run the query.');
|
|
}
|
|
|
|
async handleShowEvalLog(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
// Only applicable to an individual local query
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local') {
|
|
return;
|
|
}
|
|
|
|
if (finalSingleItem.evalLogLocation) {
|
|
await this.tryOpenExternalFile(finalSingleItem.evalLogLocation);
|
|
} else {
|
|
this.warnNoEvalLogs();
|
|
}
|
|
}
|
|
|
|
async handleShowEvalLogSummary(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
// Only applicable to an individual local query
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local') {
|
|
return;
|
|
}
|
|
|
|
if (finalSingleItem.evalLogSummaryLocation) {
|
|
await this.tryOpenExternalFile(finalSingleItem.evalLogSummaryLocation);
|
|
return;
|
|
}
|
|
|
|
// Summary log file doesn't exist.
|
|
if (finalSingleItem.evalLogLocation && await fs.pathExists(finalSingleItem.evalLogLocation)) {
|
|
// If raw log does exist, then the summary log is still being generated.
|
|
this.warnInProgressEvalLogSummary();
|
|
} else {
|
|
this.warnNoEvalLogs();
|
|
}
|
|
}
|
|
|
|
async handleShowEvalLogViewer(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[],
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
// Only applicable to an individual local query
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local') {
|
|
return;
|
|
}
|
|
|
|
// If the JSON summary file location wasn't saved, display error
|
|
if (finalSingleItem.jsonEvalLogSummaryLocation == undefined) {
|
|
this.warnInProgressEvalLogViewer();
|
|
return;
|
|
}
|
|
|
|
// TODO(angelapwen): Stream the file in.
|
|
try {
|
|
const evalLogData: EvalLogData[] = await parseViewerData(finalSingleItem.jsonEvalLogSummaryLocation);
|
|
const evalLogTreeBuilder = new EvalLogTreeBuilder(finalSingleItem.getQueryName(), evalLogData);
|
|
this.evalLogViewer.updateRoots(await evalLogTreeBuilder.getRoots());
|
|
} catch (e) {
|
|
throw new Error(`Could not read evaluator log summary JSON file to generate viewer data at ${finalSingleItem.jsonEvalLogSummaryLocation}.`);
|
|
}
|
|
}
|
|
|
|
async handleCancel(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
const selected = finalMultiSelect || [finalSingleItem];
|
|
const results = selected.map(async item => {
|
|
if (item.status === QueryStatus.InProgress) {
|
|
if (item.t === 'local') {
|
|
item.cancel();
|
|
} else if (item.t === 'remote') {
|
|
void showAndLogInformationMessage('Cancelling variant analysis. This may take a while.');
|
|
const credentials = await this.getCredentials();
|
|
await cancelRemoteQuery(credentials, item.remoteQuery);
|
|
}
|
|
}
|
|
});
|
|
|
|
await Promise.all(results);
|
|
}
|
|
|
|
async handleShowQueryText(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[] = []
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem) {
|
|
return;
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
isQuickEval: String(!!(finalSingleItem.t === 'local' && finalSingleItem.initialInfo.quickEvalPosition)),
|
|
queryText: encodeURIComponent(getQueryText(finalSingleItem)),
|
|
});
|
|
|
|
const queryId = getQueryId(finalSingleItem);
|
|
|
|
const uri = Uri.parse(
|
|
`codeql:${queryId}.ql?${params.toString()}`, true
|
|
);
|
|
const doc = await workspace.openTextDocument(uri);
|
|
await window.showTextDocument(doc, { preview: false });
|
|
}
|
|
|
|
async handleViewSarifAlerts(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
// Local queries only
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local' || !finalSingleItem.completedQuery) {
|
|
return;
|
|
}
|
|
|
|
const query = finalSingleItem.completedQuery.query;
|
|
const hasInterpretedResults = query.canHaveInterpretedResults();
|
|
if (hasInterpretedResults) {
|
|
await this.tryOpenExternalFile(
|
|
query.resultsPaths.interpretedResultsPath
|
|
);
|
|
} else {
|
|
const label = this.labelProvider.getLabel(finalSingleItem);
|
|
void showAndLogInformationMessage(
|
|
`Query ${label} has no interpreted results.`
|
|
);
|
|
}
|
|
}
|
|
|
|
async handleViewCsvResults(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
// Local queries only
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local' || !finalSingleItem.completedQuery) {
|
|
return;
|
|
}
|
|
const query = finalSingleItem.completedQuery.query;
|
|
if (await query.hasCsv()) {
|
|
void this.tryOpenExternalFile(query.csvPath);
|
|
return;
|
|
}
|
|
if (await query.exportCsvResults(this.qs.cliServer, query.csvPath)) {
|
|
void this.tryOpenExternalFile(
|
|
query.csvPath
|
|
);
|
|
}
|
|
}
|
|
|
|
async handleViewCsvAlerts(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
// Local queries only
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local' || !finalSingleItem.completedQuery) {
|
|
return;
|
|
}
|
|
|
|
await this.tryOpenExternalFile(
|
|
await finalSingleItem.completedQuery.query.ensureCsvAlerts(this.qs.cliServer, this.dbm)
|
|
);
|
|
}
|
|
|
|
async handleViewDil(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[],
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
// Local queries only
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local' || !finalSingleItem.completedQuery) {
|
|
return;
|
|
}
|
|
|
|
await this.tryOpenExternalFile(
|
|
await finalSingleItem.completedQuery.query.ensureDilPath(this.qs.cliServer)
|
|
);
|
|
}
|
|
|
|
async handleOpenOnGithub(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[],
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem) {
|
|
return;
|
|
}
|
|
|
|
if (finalSingleItem.t === 'local') {
|
|
return;
|
|
}
|
|
|
|
const actionsWorkflowRunUrl = getActionsWorkflowRunUrl(finalSingleItem);
|
|
|
|
await commands.executeCommand('vscode.open', Uri.parse(actionsWorkflowRunUrl));
|
|
}
|
|
|
|
async handleCopyRepoList(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[],
|
|
) {
|
|
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
|
|
|
|
// Remote queries only
|
|
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'remote') {
|
|
return;
|
|
}
|
|
|
|
await commands.executeCommand('codeQL.copyRepoList', finalSingleItem.queryId);
|
|
}
|
|
|
|
async handleExportResults(): Promise<void> {
|
|
await commands.executeCommand('codeQL.exportVariantAnalysisResults');
|
|
}
|
|
|
|
addQuery(item: QueryHistoryInfo) {
|
|
this.treeDataProvider.pushQuery(item);
|
|
this.updateTreeViewSelectionIfVisible();
|
|
}
|
|
|
|
/**
|
|
* Update the tree view selection if the tree view is visible.
|
|
*
|
|
* If the tree view is not visible, we must wait until it becomes visible before updating the
|
|
* selection. This is because the only mechanism for updating the selection of the tree view
|
|
* has the side-effect of revealing the tree view. This changes the active sidebar to CodeQL,
|
|
* interrupting user workflows such as writing a commit message on the source control sidebar.
|
|
*/
|
|
private updateTreeViewSelectionIfVisible() {
|
|
if (this.treeView.visible) {
|
|
const current = this.treeDataProvider.getCurrent();
|
|
if (current != undefined) {
|
|
// We must fire the onDidChangeTreeData event to ensure the current element can be selected
|
|
// using `reveal` if the tree view was not visible when the current element was added.
|
|
this.treeDataProvider.refresh();
|
|
void this.treeView.reveal(current, { select: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
private async tryOpenExternalFile(fileLocation: string) {
|
|
const uri = Uri.file(fileLocation);
|
|
try {
|
|
await window.showTextDocument(uri, { preview: false });
|
|
} catch (e) {
|
|
const msg = getErrorMessage(e);
|
|
if (
|
|
msg.includes(
|
|
'Files above 50MB cannot be synchronized with extensions'
|
|
) ||
|
|
msg.includes('too large to open')
|
|
) {
|
|
const res = await showBinaryChoiceDialog(
|
|
`VS Code does not allow extensions to open files >50MB. This file
|
|
exceeds that limit. Do you want to open it outside of VS Code?
|
|
|
|
You can also try manually opening it inside VS Code by selecting
|
|
the file in the file explorer and dragging it into the workspace.`
|
|
);
|
|
if (res) {
|
|
try {
|
|
await commands.executeCommand('revealFileInOS', uri);
|
|
} catch (e) {
|
|
void showAndLogErrorMessage(getErrorMessage(e));
|
|
}
|
|
}
|
|
} else {
|
|
void showAndLogErrorMessage(`Could not open file ${fileLocation}`);
|
|
void logger.log(getErrorMessage(e));
|
|
void logger.log(getErrorStack(e));
|
|
}
|
|
}
|
|
}
|
|
|
|
private async findOtherQueryToCompare(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
): Promise<CompletedLocalQueryInfo | undefined> {
|
|
|
|
// Remote queries cannot be compared
|
|
if (singleItem.t !== 'local' || multiSelect.some(s => s.t !== 'local') || !singleItem.completedQuery) {
|
|
return undefined;
|
|
}
|
|
const dbName = singleItem.initialInfo.databaseInfo.name;
|
|
|
|
// if exactly 2 queries are selected, use those
|
|
if (multiSelect?.length === 2) {
|
|
// return the query that is not the first selected one
|
|
const otherQuery =
|
|
(singleItem === multiSelect[0] ? multiSelect[1] : multiSelect[0]) as LocalQueryInfo;
|
|
if (!otherQuery.completedQuery) {
|
|
throw new Error('Please select a completed query.');
|
|
}
|
|
if (!otherQuery.completedQuery.successful) {
|
|
throw new Error('Please select a successful query.');
|
|
}
|
|
if (otherQuery.initialInfo.databaseInfo.name !== dbName) {
|
|
throw new Error('Query databases must be the same.');
|
|
}
|
|
return otherQuery as CompletedLocalQueryInfo;
|
|
}
|
|
|
|
if (multiSelect?.length > 2) {
|
|
throw new Error('Please select no more than 2 queries.');
|
|
}
|
|
|
|
// otherwise, let the user choose
|
|
const comparableQueryLabels = this.treeDataProvider.allHistory
|
|
.filter(
|
|
(otherQuery) =>
|
|
otherQuery !== singleItem &&
|
|
otherQuery.t === 'local' &&
|
|
otherQuery.completedQuery &&
|
|
otherQuery.completedQuery.successful &&
|
|
otherQuery.initialInfo.databaseInfo.name === dbName
|
|
)
|
|
.map((item) => ({
|
|
label: this.labelProvider.getLabel(item),
|
|
description: (item as CompletedLocalQueryInfo).initialInfo.databaseInfo.name,
|
|
detail: (item as CompletedLocalQueryInfo).completedQuery.statusString,
|
|
query: item as CompletedLocalQueryInfo,
|
|
}));
|
|
if (comparableQueryLabels.length < 1) {
|
|
throw new Error('No other queries available to compare with.');
|
|
}
|
|
const choice = await window.showQuickPick(comparableQueryLabels);
|
|
return choice?.query;
|
|
}
|
|
|
|
private assertSingleQuery(multiSelect: QueryHistoryInfo[] = [], message = 'Please select a single query.') {
|
|
if (multiSelect.length > 1) {
|
|
void showAndLogErrorMessage(
|
|
message
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Updates the compare with source query. This ensures that all compare command invocations
|
|
* when exactly 2 queries are selected always have the proper _from_ query. Always use
|
|
* compareWithItem as the _from_ query.
|
|
*
|
|
* The heuristic is this:
|
|
*
|
|
* 1. If selection is empty or has length > 2 delete compareWithItem.
|
|
* 2. If selection is length 1, then set that item to compareWithItem.
|
|
* 3. If selection is length 2, then make sure compareWithItem is one of the selected items
|
|
* if not, then delete compareWithItem. If it is then, do nothing.
|
|
*
|
|
* This ensures that compareWithItem is always the first item selected if there are only
|
|
* two selected items.
|
|
*
|
|
* @param newSelection the new selection after the most recent selection change
|
|
*/
|
|
private updateCompareWith(newSelection: LocalQueryInfo[]) {
|
|
if (newSelection.length === 1) {
|
|
this.compareWithItem = newSelection[0];
|
|
} else if (
|
|
newSelection.length !== 2 ||
|
|
!this.compareWithItem ||
|
|
!newSelection.includes(this.compareWithItem)
|
|
) {
|
|
this.compareWithItem = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If no items are selected, attempt to grab the selection from the treeview.
|
|
* However, often the treeview itself does not have any selection. In this case,
|
|
* grab the selection from the `treeDataProvider` current item.
|
|
*
|
|
* We need to use this method because when clicking on commands from the view title
|
|
* bar, the selections are not passed in.
|
|
*
|
|
* @param singleItem the single item selected, or undefined if no item is selected
|
|
* @param multiSelect a multi-select or undefined if no items are selected
|
|
*/
|
|
private determineSelection(
|
|
singleItem: QueryHistoryInfo,
|
|
multiSelect: QueryHistoryInfo[]
|
|
): {
|
|
finalSingleItem: QueryHistoryInfo;
|
|
finalMultiSelect: QueryHistoryInfo[]
|
|
} {
|
|
if (!singleItem && !multiSelect?.[0]) {
|
|
const selection = this.treeView.selection;
|
|
const current = this.treeDataProvider.getCurrent();
|
|
if (selection?.length) {
|
|
return {
|
|
finalSingleItem: selection[0],
|
|
finalMultiSelect: [...selection]
|
|
};
|
|
} else if (current) {
|
|
return {
|
|
finalSingleItem: current,
|
|
finalMultiSelect: [current]
|
|
};
|
|
}
|
|
}
|
|
|
|
// ensure we only return undefined if we have neither a single or multi-selecion
|
|
if (singleItem && !multiSelect?.[0]) {
|
|
multiSelect = [singleItem];
|
|
} else if (!singleItem && multiSelect?.[0]) {
|
|
singleItem = multiSelect[0];
|
|
}
|
|
return {
|
|
finalSingleItem: singleItem,
|
|
finalMultiSelect: multiSelect
|
|
};
|
|
}
|
|
|
|
async refreshTreeView(): Promise<void> {
|
|
this.treeDataProvider.refresh();
|
|
await this.writeQueryHistory();
|
|
}
|
|
|
|
private async openQueryResults(item: QueryHistoryInfo) {
|
|
if (item.t === 'local') {
|
|
await this.localQueriesResultsView.showResults(item as CompletedLocalQueryInfo, WebviewReveal.Forced, false);
|
|
} else if (item.t === 'remote') {
|
|
await this.remoteQueriesManager.openRemoteQueryResults(item.queryId);
|
|
} else if (item.t === 'variant-analysis') {
|
|
await this.variantAnalysisManager.showView(item.variantAnalysis.id);
|
|
}
|
|
}
|
|
}
|