Files
vscode-codeql/extensions/ql-vscode/src/databases-ui.ts
2021-10-01 11:07:37 -07:00

753 lines
20 KiB
TypeScript

import * as path from 'path';
import { DisposableObject } from './pure/disposable-object';
import {
Event,
EventEmitter,
ProviderResult,
TreeDataProvider,
TreeItem,
Uri,
window,
env,
} from 'vscode';
import * as fs from 'fs-extra';
import {
DatabaseChangedEvent,
DatabaseItem,
DatabaseManager,
} from './databases';
import {
commandRunner,
commandRunnerWithProgress,
ProgressCallback,
} from './commandRunner';
import {
isLikelyDatabaseRoot,
isLikelyDbLanguageFolder,
showAndLogErrorMessage
} from './helpers';
import { logger } from './logging';
import { clearCacheInDatabase } from './run-queries';
import * as qsClient from './queryserver-client';
import { upgradeDatabaseExplicit } from './upgrades';
import {
importArchiveDatabase,
promptImportInternetDatabase,
promptImportLgtmDatabase,
} from './databaseFetcher';
import { CancellationToken } from 'vscode';
import { asyncFilter } from './pure/helpers-pure';
type ThemableIconPath = { light: string; dark: string } | string;
/**
* Path to icons to display next to currently selected database.
*/
const SELECTED_DATABASE_ICON: ThemableIconPath = {
light: 'media/light/check.svg',
dark: 'media/dark/check.svg',
};
/**
* Path to icon to display next to an invalid database.
*/
const INVALID_DATABASE_ICON: ThemableIconPath = 'media/red-x.svg';
function joinThemableIconPath(
base: string,
iconPath: ThemableIconPath
): ThemableIconPath {
if (typeof iconPath == 'object')
return {
light: path.join(base, iconPath.light),
dark: path.join(base, iconPath.dark),
};
else return path.join(base, iconPath);
}
enum SortOrder {
NameAsc = 'NameAsc',
NameDesc = 'NameDesc',
DateAddedAsc = 'DateAddedAsc',
DateAddedDesc = 'DateAddedDesc',
}
/**
* Tree data provider for the databases view.
*/
class DatabaseTreeDataProvider extends DisposableObject
implements TreeDataProvider<DatabaseItem> {
private _sortOrder = SortOrder.NameAsc;
private readonly _onDidChangeTreeData = this.push(new EventEmitter<DatabaseItem | undefined>());
private currentDatabaseItem: DatabaseItem | undefined;
constructor(
private databaseManager: DatabaseManager,
private readonly extensionPath: string
) {
super();
this.currentDatabaseItem = databaseManager.currentDatabaseItem;
this.push(
this.databaseManager.onDidChangeDatabaseItem(
this.handleDidChangeDatabaseItem
)
);
this.push(
this.databaseManager.onDidChangeCurrentDatabaseItem(
this.handleDidChangeCurrentDatabaseItem
)
);
}
public get onDidChangeTreeData(): Event<DatabaseItem | undefined> {
return this._onDidChangeTreeData.event;
}
private handleDidChangeDatabaseItem = (event: DatabaseChangedEvent): void => {
// Note that events from the database manager are instances of DatabaseChangedEvent
// and events fired by the UI are instances of DatabaseItem
// When event.item is undefined, then the entire tree is refreshed.
// When event.item is a db item, then only that item is refreshed.
this._onDidChangeTreeData.fire(event.item);
};
private handleDidChangeCurrentDatabaseItem = (
event: DatabaseChangedEvent
): void => {
if (this.currentDatabaseItem) {
this._onDidChangeTreeData.fire(this.currentDatabaseItem);
}
this.currentDatabaseItem = event.item;
if (this.currentDatabaseItem) {
this._onDidChangeTreeData.fire(this.currentDatabaseItem);
}
};
public getTreeItem(element: DatabaseItem): TreeItem {
const item = new TreeItem(element.name);
if (element === this.currentDatabaseItem) {
item.iconPath = joinThemableIconPath(
this.extensionPath,
SELECTED_DATABASE_ICON
);
} else if (element.error !== undefined) {
item.iconPath = joinThemableIconPath(
this.extensionPath,
INVALID_DATABASE_ICON
);
}
item.tooltip = element.databaseUri.fsPath;
item.description = element.language;
return item;
}
public getChildren(element?: DatabaseItem): ProviderResult<DatabaseItem[]> {
if (element === undefined) {
return this.databaseManager.databaseItems.slice(0).sort((db1, db2) => {
switch (this.sortOrder) {
case SortOrder.NameAsc:
return db1.name.localeCompare(db2.name, env.language);
case SortOrder.NameDesc:
return db2.name.localeCompare(db1.name, env.language);
case SortOrder.DateAddedAsc:
return (db1.dateAdded || 0) - (db2.dateAdded || 0);
case SortOrder.DateAddedDesc:
return (db2.dateAdded || 0) - (db1.dateAdded || 0);
}
});
} else {
return [];
}
}
public getParent(_element: DatabaseItem): ProviderResult<DatabaseItem> {
return null;
}
public getCurrent(): DatabaseItem | undefined {
return this.currentDatabaseItem;
}
public get sortOrder() {
return this._sortOrder;
}
public set sortOrder(newSortOrder: SortOrder) {
this._sortOrder = newSortOrder;
this._onDidChangeTreeData.fire(undefined);
}
}
/** Gets the first element in the given list, if any, or undefined if the list is empty or undefined. */
function getFirst(list: Uri[] | undefined): Uri | undefined {
if (list === undefined || list.length === 0) {
return undefined;
} else {
return list[0];
}
}
/**
* Displays file selection dialog. Expects the user to choose a
* database directory, which should be the parent directory of a
* directory of the form `db-[language]`, for example, `db-cpp`.
*
* XXX: no validation is done other than checking the directory name
* to make sure it really is a database directory.
*/
async function chooseDatabaseDir(byFolder: boolean): Promise<Uri | undefined> {
const chosen = await window.showOpenDialog({
openLabel: byFolder ? 'Choose Database folder' : 'Choose Database archive',
canSelectFiles: !byFolder,
canSelectFolders: byFolder,
canSelectMany: false,
filters: byFolder ? {} : { Archives: ['zip'] },
});
return getFirst(chosen);
}
export class DatabaseUI extends DisposableObject {
private treeDataProvider: DatabaseTreeDataProvider;
public constructor(
private databaseManager: DatabaseManager,
private readonly queryServer: qsClient.QueryServerClient | undefined,
private readonly storagePath: string,
readonly extensionPath: string
) {
super();
this.treeDataProvider = this.push(
new DatabaseTreeDataProvider(databaseManager, extensionPath)
);
this.push(
window.createTreeView('codeQLDatabases', {
treeDataProvider: this.treeDataProvider,
canSelectMany: true,
})
);
}
init() {
void logger.log('Registering database panel commands.');
this.push(
commandRunnerWithProgress(
'codeQL.setCurrentDatabase',
this.handleSetCurrentDatabase,
{
title: 'Importing database from archive',
}
)
);
this.push(
commandRunnerWithProgress(
'codeQL.upgradeCurrentDatabase',
this.handleUpgradeCurrentDatabase,
{
title: 'Upgrading current database',
cancellable: true,
}
)
);
this.push(
commandRunnerWithProgress(
'codeQL.clearCache',
this.handleClearCache,
{
title: 'Clearing Cache',
})
);
this.push(
commandRunnerWithProgress(
'codeQLDatabases.chooseDatabaseFolder',
this.handleChooseDatabaseFolder,
{
title: 'Adding database from folder',
}
)
);
this.push(
commandRunnerWithProgress(
'codeQLDatabases.chooseDatabaseArchive',
this.handleChooseDatabaseArchive,
{
title: 'Adding database from archive',
}
)
);
this.push(
commandRunnerWithProgress(
'codeQLDatabases.chooseDatabaseInternet',
this.handleChooseDatabaseInternet,
{
title: 'Adding database from URL',
}
)
);
this.push(
commandRunnerWithProgress(
'codeQLDatabases.chooseDatabaseLgtm',
this.handleChooseDatabaseLgtm,
{
title: 'Adding database from LGTM',
})
);
this.push(
commandRunner(
'codeQLDatabases.setCurrentDatabase',
this.handleMakeCurrentDatabase
)
);
this.push(
commandRunner(
'codeQLDatabases.sortByName',
this.handleSortByName
)
);
this.push(
commandRunner(
'codeQLDatabases.sortByDateAdded',
this.handleSortByDateAdded
)
);
this.push(
commandRunnerWithProgress(
'codeQLDatabases.removeDatabase',
this.handleRemoveDatabase,
{
title: 'Removing database',
cancellable: false
}
)
);
this.push(
commandRunnerWithProgress(
'codeQLDatabases.upgradeDatabase',
this.handleUpgradeDatabase,
{
title: 'Upgrading database',
cancellable: true,
}
)
);
this.push(
commandRunner(
'codeQLDatabases.renameDatabase',
this.handleRenameDatabase
)
);
this.push(
commandRunner(
'codeQLDatabases.openDatabaseFolder',
this.handleOpenFolder
)
);
this.push(
commandRunner(
'codeQLDatabases.addDatabaseSource',
this.handleAddSource
)
);
this.push(
commandRunner(
'codeQLDatabases.removeOrphanedDatabases',
this.handleRemoveOrphanedDatabases
)
);
}
private handleMakeCurrentDatabase = async (
databaseItem: DatabaseItem
): Promise<void> => {
await this.databaseManager.setCurrentDatabaseItem(databaseItem);
};
handleChooseDatabaseFolder = async (
progress: ProgressCallback,
token: CancellationToken
): Promise<DatabaseItem | undefined> => {
try {
return await this.chooseAndSetDatabase(true, progress, token);
} catch (e) {
void showAndLogErrorMessage(e.message);
return undefined;
}
};
handleRemoveOrphanedDatabases = async (): Promise<void> => {
void logger.log('Removing orphaned databases from workspace storage.');
let dbDirs = undefined;
if (
!(await fs.pathExists(this.storagePath)) ||
!(await fs.stat(this.storagePath)).isDirectory()
) {
void logger.log('Missing or invalid storage directory. Not trying to remove orphaned databases.');
return;
}
dbDirs =
// read directory
(await fs.readdir(this.storagePath, { withFileTypes: true }))
// remove non-directories
.filter(dirent => dirent.isDirectory())
// get the full path
.map(dirent => path.join(this.storagePath, dirent.name))
// remove databases still in workspace
.filter(dbDir => {
const dbUri = Uri.file(dbDir);
return this.databaseManager.databaseItems.every(item => item.databaseUri.fsPath !== dbUri.fsPath);
});
// remove non-databases
dbDirs = await asyncFilter(dbDirs, isLikelyDatabaseRoot);
if (!dbDirs.length) {
void logger.log('No orphaned databases found.');
return;
}
// delete
const failures = [] as string[];
await Promise.all(
dbDirs.map(async dbDir => {
try {
void logger.log(`Deleting orphaned database '${dbDir}'.`);
await fs.remove(dbDir);
} catch (e) {
failures.push(`${path.basename(dbDir)}`);
}
})
);
if (failures.length) {
const dirname = path.dirname(failures[0]);
void showAndLogErrorMessage(
`Failed to delete unused databases (${failures.join(', ')
}).\nTo delete unused databases, please remove them manually from the storage folder ${dirname}.`
);
}
};
handleChooseDatabaseArchive = async (
progress: ProgressCallback,
token: CancellationToken
): Promise<DatabaseItem | undefined> => {
try {
return await this.chooseAndSetDatabase(false, progress, token);
} catch (e) {
void showAndLogErrorMessage(e.message);
return undefined;
}
};
handleChooseDatabaseInternet = async (
progress: ProgressCallback,
token: CancellationToken
): Promise<
DatabaseItem | undefined
> => {
return await promptImportInternetDatabase(
this.databaseManager,
this.storagePath,
progress,
token
);
};
handleChooseDatabaseLgtm = async (
progress: ProgressCallback,
token: CancellationToken
): Promise<DatabaseItem | undefined> => {
return await promptImportLgtmDatabase(
this.databaseManager,
this.storagePath,
progress,
token
);
};
async tryUpgradeCurrentDatabase(
progress: ProgressCallback,
token: CancellationToken
) {
await this.handleUpgradeCurrentDatabase(progress, token);
}
private handleSortByName = async () => {
if (this.treeDataProvider.sortOrder === SortOrder.NameAsc) {
this.treeDataProvider.sortOrder = SortOrder.NameDesc;
} else {
this.treeDataProvider.sortOrder = SortOrder.NameAsc;
}
};
private handleSortByDateAdded = async () => {
if (this.treeDataProvider.sortOrder === SortOrder.DateAddedAsc) {
this.treeDataProvider.sortOrder = SortOrder.DateAddedDesc;
} else {
this.treeDataProvider.sortOrder = SortOrder.DateAddedAsc;
}
};
private handleUpgradeCurrentDatabase = async (
progress: ProgressCallback,
token: CancellationToken,
): Promise<void> => {
await this.handleUpgradeDatabase(
progress, token,
this.databaseManager.currentDatabaseItem,
[]
);
};
private handleUpgradeDatabase = async (
progress: ProgressCallback,
token: CancellationToken,
databaseItem: DatabaseItem | undefined,
multiSelect: DatabaseItem[] | undefined,
): Promise<void> => {
if (multiSelect?.length) {
await Promise.all(
multiSelect.map((dbItem) => this.handleUpgradeDatabase(progress, token, dbItem, []))
);
}
if (this.queryServer === undefined) {
throw new Error(
'Received request to upgrade database, but there is no running query server.'
);
}
if (databaseItem === undefined) {
throw new Error(
'Received request to upgrade database, but no database was provided.'
);
}
if (databaseItem.contents === undefined) {
throw new Error(
'Received request to upgrade database, but database contents could not be found.'
);
}
if (databaseItem.contents.dbSchemeUri === undefined) {
throw new Error(
'Received request to upgrade database, but database has no schema.'
);
}
// Search for upgrade scripts in any workspace folders available
await upgradeDatabaseExplicit(
this.queryServer,
databaseItem,
progress,
token
);
};
private handleClearCache = async (
progress: ProgressCallback,
token: CancellationToken,
): Promise<void> => {
if (
this.queryServer !== undefined &&
this.databaseManager.currentDatabaseItem !== undefined
) {
await clearCacheInDatabase(
this.queryServer,
this.databaseManager.currentDatabaseItem,
progress,
token
);
}
};
private handleSetCurrentDatabase = async (
progress: ProgressCallback,
token: CancellationToken,
uri: Uri,
): Promise<void> => {
try {
// Assume user has selected an archive if the file has a .zip extension
if (uri.path.endsWith('.zip')) {
await importArchiveDatabase(
uri.toString(true),
this.databaseManager,
this.storagePath,
progress,
token
);
} else {
await this.setCurrentDatabase(progress, token, uri);
}
} catch (e) {
// rethrow and let this be handled by default error handling.
throw new Error(
`Could not set database to ${path.basename(uri.fsPath)}. Reason: ${e.message
}`
);
}
};
private handleRemoveDatabase = async (
progress: ProgressCallback,
token: CancellationToken,
databaseItem: DatabaseItem,
multiSelect: DatabaseItem[] | undefined
): Promise<void> => {
if (multiSelect?.length) {
await Promise.all(multiSelect.map((dbItem) =>
this.databaseManager.removeDatabaseItem(progress, token, dbItem)
));
} else {
await this.databaseManager.removeDatabaseItem(progress, token, databaseItem);
}
};
private handleRenameDatabase = async (
databaseItem: DatabaseItem,
multiSelect: DatabaseItem[] | undefined
): Promise<void> => {
this.assertSingleDatabase(multiSelect);
const newName = await window.showInputBox({
prompt: 'Choose new database name',
value: databaseItem.name,
});
if (newName) {
await this.databaseManager.renameDatabaseItem(databaseItem, newName);
}
};
private handleOpenFolder = async (
databaseItem: DatabaseItem,
multiSelect: DatabaseItem[] | undefined
): Promise<void> => {
if (multiSelect?.length) {
await Promise.all(
multiSelect.map((dbItem) => env.openExternal(dbItem.databaseUri))
);
} else {
await env.openExternal(databaseItem.databaseUri);
}
};
/**
* Adds the source folder of a CodeQL database to the workspace.
* When a database is first added in the "Databases" view, its source folder is added to the workspace.
* If the source folder is removed from the workspace for some reason, we want to be able to re-add it if need be.
*/
private handleAddSource = async (
databaseItem: DatabaseItem,
multiSelect: DatabaseItem[] | undefined
): Promise<void> => {
if (multiSelect?.length) {
for (const dbItem of multiSelect) {
await this.databaseManager.addDatabaseSourceArchiveFolder(dbItem);
}
} else {
await this.databaseManager.addDatabaseSourceArchiveFolder(databaseItem);
}
};
/**
* Return the current database directory. If we don't already have a
* current database, ask the user for one, and return that, or
* undefined if they cancel.
*/
public async getDatabaseItem(
progress: ProgressCallback,
token: CancellationToken
): Promise<DatabaseItem | undefined> {
if (this.databaseManager.currentDatabaseItem === undefined) {
await this.chooseAndSetDatabase(false, progress, token);
}
return this.databaseManager.currentDatabaseItem;
}
private async setCurrentDatabase(
progress: ProgressCallback,
token: CancellationToken,
uri: Uri
): Promise<DatabaseItem | undefined> {
let databaseItem = this.databaseManager.findDatabaseItem(uri);
if (databaseItem === undefined) {
databaseItem = await this.databaseManager.openDatabase(progress, token, uri);
}
await this.databaseManager.setCurrentDatabaseItem(databaseItem);
return databaseItem;
}
/**
* Ask the user for a database directory. Returns the chosen database, or `undefined` if the
* operation was canceled.
*/
private async chooseAndSetDatabase(
byFolder: boolean,
progress: ProgressCallback,
token: CancellationToken,
): Promise<DatabaseItem | undefined> {
const uri = await chooseDatabaseDir(byFolder);
if (!uri) {
return undefined;
}
if (byFolder) {
const fixedUri = await this.fixDbUri(uri);
// we are selecting a database folder
return await this.setCurrentDatabase(progress, token, fixedUri);
} else {
// we are selecting a database archive. Must unzip into a workspace-controlled area
// before importing.
return await importArchiveDatabase(
uri.toString(true),
this.databaseManager,
this.storagePath,
progress,
token
);
}
}
/**
* Perform some heuristics to ensure a proper database location is chosen.
*
* 1. If the selected URI to add is a file, choose the containing directory
* 2. If the selected URI is a directory matching db-*, choose the containing directory
* 3. choose the current directory
*
* @param uri a URI that is a database folder or inside it
*
* @return the actual database folder found by using the heuristics above.
*/
private async fixDbUri(uri: Uri): Promise<Uri> {
let dbPath = uri.fsPath;
if ((await fs.stat(dbPath)).isFile()) {
dbPath = path.dirname(dbPath);
}
if (isLikelyDbLanguageFolder(dbPath)) {
dbPath = path.dirname(dbPath);
}
return Uri.file(dbPath);
}
private assertSingleDatabase(
multiSelect: DatabaseItem[] = [],
message = 'Please select a single database.'
) {
if (multiSelect.length > 1) {
throw new Error(message);
}
}
}