1121 lines
34 KiB
TypeScript
1121 lines
34 KiB
TypeScript
import { join, basename, dirname as path_dirname } from "path";
|
|
import { DisposableObject } from "../common/disposable-object";
|
|
import type {
|
|
Event,
|
|
ProviderResult,
|
|
TreeDataProvider,
|
|
CancellationToken,
|
|
QuickPickItem,
|
|
} from "vscode";
|
|
import {
|
|
EventEmitter,
|
|
TreeItem,
|
|
Uri,
|
|
window,
|
|
env,
|
|
ThemeIcon,
|
|
ThemeColor,
|
|
workspace,
|
|
FileType,
|
|
} from "vscode";
|
|
import { pathExists, stat, readdir, remove } from "fs-extra";
|
|
|
|
import type {
|
|
DatabaseChangedEvent,
|
|
DatabaseItem,
|
|
DatabaseManager,
|
|
} from "./local-databases";
|
|
import type { ProgressCallback } from "../common/vscode/progress";
|
|
import {
|
|
UserCancellationException,
|
|
withProgress,
|
|
} from "../common/vscode/progress";
|
|
import {
|
|
isLikelyDatabaseRoot,
|
|
isLikelyDbLanguageFolder,
|
|
} from "./local-databases/db-contents-heuristics";
|
|
import {
|
|
showAndLogExceptionWithTelemetry,
|
|
showAndLogErrorMessage,
|
|
showAndLogInformationMessage,
|
|
} from "../common/logging";
|
|
import type { DatabaseFetcher } from "./database-fetcher";
|
|
import { asError, asyncFilter, getErrorMessage } from "../common/helpers-pure";
|
|
import type { QueryRunner } from "../query-server";
|
|
import type { App } from "../common/app";
|
|
import { redactableError } from "../common/errors";
|
|
import type { LocalDatabasesCommands } from "../common/commands";
|
|
import {
|
|
createMultiSelectionCommand,
|
|
createSingleSelectionCommand,
|
|
} from "../common/vscode/selection-commands";
|
|
import {
|
|
getLanguageDisplayName,
|
|
tryGetQueryLanguage,
|
|
} from "../common/query-language";
|
|
import type { LanguageContextStore } from "../language-context-store";
|
|
|
|
enum SortOrder {
|
|
NameAsc = "NameAsc",
|
|
NameDesc = "NameDesc",
|
|
LanguageAsc = "LanguageAsc",
|
|
LanguageDesc = "LanguageDesc",
|
|
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 languageContext: LanguageContextStore,
|
|
) {
|
|
super();
|
|
|
|
this.currentDatabaseItem = databaseManager.currentDatabaseItem;
|
|
|
|
this.push(
|
|
this.databaseManager.onDidChangeDatabaseItem(
|
|
this.handleDidChangeDatabaseItem.bind(this),
|
|
),
|
|
);
|
|
this.push(
|
|
this.databaseManager.onDidChangeCurrentDatabaseItem(
|
|
this.handleDidChangeCurrentDatabaseItem.bind(this),
|
|
),
|
|
);
|
|
this.push(
|
|
this.languageContext.onLanguageContextChanged(async () => {
|
|
this._onDidChangeTreeData.fire(undefined);
|
|
}),
|
|
);
|
|
}
|
|
|
|
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 a full refresh has occurred, then all items are refreshed by passing undefined.
|
|
this._onDidChangeTreeData.fire(event.fullRefresh ? undefined : 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 = new ThemeIcon("check");
|
|
|
|
item.contextValue = "currentDatabase";
|
|
} else if (element.error !== undefined) {
|
|
item.iconPath = new ThemeIcon("error", new ThemeColor("errorForeground"));
|
|
}
|
|
item.tooltip = element.databaseUri.fsPath;
|
|
item.description =
|
|
element.language + (element.origin?.type === "testproj" ? " (test)" : "");
|
|
return item;
|
|
}
|
|
|
|
public getChildren(element?: DatabaseItem): ProviderResult<DatabaseItem[]> {
|
|
if (element === undefined) {
|
|
// Filter items by language
|
|
const displayItems = this.databaseManager.databaseItems.filter((item) => {
|
|
return this.languageContext.shouldInclude(
|
|
tryGetQueryLanguage(item.language),
|
|
);
|
|
});
|
|
|
|
// Sort items
|
|
return displayItems.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.LanguageAsc:
|
|
return (
|
|
db1.language.localeCompare(db2.language, env.language) ||
|
|
// If the languages are the same, sort by name
|
|
db1.name.localeCompare(db2.name, env.language)
|
|
);
|
|
case SortOrder.LanguageDesc:
|
|
return (
|
|
db2.language.localeCompare(db1.language, env.language) ||
|
|
// If the languages are the same, sort by name
|
|
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 interface DatabaseSelectionQuickPickItem extends QuickPickItem {
|
|
databaseKind: "new" | "existing";
|
|
}
|
|
|
|
export interface DatabaseQuickPickItem extends QuickPickItem {
|
|
databaseItem: DatabaseItem;
|
|
}
|
|
|
|
export interface DatabaseImportQuickPickItems extends QuickPickItem {
|
|
importType: "URL" | "github" | "archive" | "folder";
|
|
}
|
|
|
|
export class DatabaseUI extends DisposableObject {
|
|
private treeDataProvider: DatabaseTreeDataProvider;
|
|
|
|
public constructor(
|
|
private app: App,
|
|
private databaseManager: DatabaseManager,
|
|
private readonly databaseFetcher: DatabaseFetcher,
|
|
languageContext: LanguageContextStore,
|
|
private readonly queryServer: QueryRunner,
|
|
private readonly storagePath: string,
|
|
readonly extensionPath: string,
|
|
) {
|
|
super();
|
|
|
|
this.treeDataProvider = this.push(
|
|
new DatabaseTreeDataProvider(databaseManager, languageContext),
|
|
);
|
|
this.push(
|
|
window.createTreeView("codeQLDatabases", {
|
|
treeDataProvider: this.treeDataProvider,
|
|
canSelectMany: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
public getCommands(): LocalDatabasesCommands {
|
|
return {
|
|
"codeQL.getCurrentDatabase": this.handleGetCurrentDatabase.bind(this),
|
|
"codeQL.chooseDatabaseFolder":
|
|
this.handleChooseDatabaseFolderFromPalette.bind(this),
|
|
"codeQL.chooseDatabaseFoldersParent":
|
|
this.handleChooseDatabaseFoldersParentFromPalette.bind(this),
|
|
"codeQL.chooseDatabaseArchive":
|
|
this.handleChooseDatabaseArchiveFromPalette.bind(this),
|
|
"codeQL.chooseDatabaseInternet":
|
|
this.handleChooseDatabaseInternet.bind(this),
|
|
"codeQL.chooseDatabaseGithub": this.handleChooseDatabaseGithub.bind(this),
|
|
"codeQL.setCurrentDatabase": this.handleSetCurrentDatabase.bind(this),
|
|
"codeQL.importTestDatabase": this.handleImportTestDatabase.bind(this),
|
|
"codeQL.setDefaultTourDatabase":
|
|
this.handleSetDefaultTourDatabase.bind(this),
|
|
"codeQL.upgradeCurrentDatabase":
|
|
this.handleUpgradeCurrentDatabase.bind(this),
|
|
"codeQL.clearCache": this.handleClearCache.bind(this),
|
|
"codeQL.trimCache": this.handleTrimCache.bind(this),
|
|
"codeQLDatabases.chooseDatabaseFolder":
|
|
this.handleChooseDatabaseFolder.bind(this),
|
|
"codeQLDatabases.chooseDatabaseArchive":
|
|
this.handleChooseDatabaseArchive.bind(this),
|
|
"codeQLDatabases.chooseDatabaseInternet":
|
|
this.handleChooseDatabaseInternet.bind(this),
|
|
"codeQLDatabases.chooseDatabaseGithub":
|
|
this.handleChooseDatabaseGithub.bind(this),
|
|
"codeQLDatabases.setCurrentDatabase":
|
|
this.handleMakeCurrentDatabase.bind(this),
|
|
"codeQLDatabases.sortByName": this.handleSortByName.bind(this),
|
|
"codeQLDatabases.sortByLanguage": this.handleSortByLanguage.bind(this),
|
|
"codeQLDatabases.sortByDateAdded": this.handleSortByDateAdded.bind(this),
|
|
"codeQLDatabases.removeDatabase": createMultiSelectionCommand(
|
|
this.handleRemoveDatabase.bind(this),
|
|
),
|
|
"codeQLDatabases.upgradeDatabase": createMultiSelectionCommand(
|
|
this.handleUpgradeDatabase.bind(this),
|
|
),
|
|
"codeQLDatabases.renameDatabase": createSingleSelectionCommand(
|
|
this.app.logger,
|
|
this.handleRenameDatabase.bind(this),
|
|
"database",
|
|
),
|
|
"codeQLDatabases.openDatabaseFolder": createMultiSelectionCommand(
|
|
this.handleOpenFolder.bind(this),
|
|
),
|
|
"codeQLDatabases.addDatabaseSource": createMultiSelectionCommand(
|
|
this.handleAddSource.bind(this),
|
|
),
|
|
"codeQLDatabases.removeOrphanedDatabases":
|
|
this.handleRemoveOrphanedDatabases.bind(this),
|
|
};
|
|
}
|
|
|
|
private async handleMakeCurrentDatabase(
|
|
databaseItem: DatabaseItem,
|
|
): Promise<void> {
|
|
await this.databaseManager.setCurrentDatabaseItem(databaseItem);
|
|
}
|
|
|
|
private async chooseDatabaseFolder(
|
|
progress: ProgressCallback,
|
|
): Promise<void> {
|
|
try {
|
|
await this.chooseAndSetDatabase(true, progress);
|
|
} catch (e) {
|
|
void showAndLogExceptionWithTelemetry(
|
|
this.app.logger,
|
|
this.app.telemetry,
|
|
redactableError(
|
|
asError(e),
|
|
)`Failed to choose and set database: ${getErrorMessage(e)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChooseDatabaseFolder(): Promise<void> {
|
|
return withProgress(
|
|
async (progress) => {
|
|
await this.chooseDatabaseFolder(progress);
|
|
},
|
|
{
|
|
title: "Adding database from folder",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleChooseDatabaseFolderFromPalette(): Promise<void> {
|
|
return withProgress(
|
|
async (progress) => {
|
|
await this.chooseDatabaseFolder(progress);
|
|
},
|
|
{
|
|
title: "Choose a Database from a Folder",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleChooseDatabaseFoldersParentFromPalette(): Promise<void> {
|
|
return withProgress(
|
|
async (progress) => {
|
|
await this.chooseDatabasesParentFolder(progress);
|
|
},
|
|
{
|
|
title: "Importing all databases contained in parent folder",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleSetDefaultTourDatabase(): Promise<void> {
|
|
return withProgress(
|
|
async () => {
|
|
try {
|
|
if (!workspace.workspaceFolders?.length) {
|
|
throw new Error("No workspace folder is open.");
|
|
} else {
|
|
// This specifically refers to the database folder in
|
|
// https://github.com/github/codespaces-codeql
|
|
const uri = Uri.parse(
|
|
`${workspace.workspaceFolders[0].uri}/.tours/codeql-tutorial-database`,
|
|
);
|
|
|
|
const databaseItem = this.databaseManager.findDatabaseItem(uri);
|
|
if (databaseItem === undefined) {
|
|
const makeSelected = true;
|
|
const nameOverride = "CodeQL Tutorial Database";
|
|
|
|
await this.databaseManager.openDatabase(
|
|
uri,
|
|
{
|
|
type: "folder",
|
|
},
|
|
makeSelected,
|
|
nameOverride,
|
|
{
|
|
isTutorialDatabase: true,
|
|
},
|
|
);
|
|
}
|
|
await this.handleTourDependencies();
|
|
}
|
|
} catch (e) {
|
|
// rethrow and let this be handled by default error handling.
|
|
throw new Error(
|
|
`Could not set the database for the Code Tour. Please make sure you are using the default workspace in your codespace: ${getErrorMessage(
|
|
e,
|
|
)}`,
|
|
);
|
|
}
|
|
},
|
|
{
|
|
title: "Set Default Database for Codespace CodeQL Tour",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleTourDependencies(): Promise<void> {
|
|
if (!workspace.workspaceFolders?.length) {
|
|
throw new Error("No workspace folder is open.");
|
|
} else {
|
|
const tutorialQueriesPath = join(
|
|
workspace.workspaceFolders[0].uri.fsPath,
|
|
"tutorial-queries",
|
|
);
|
|
const cli = this.queryServer.cliServer;
|
|
await cli.packInstall(tutorialQueriesPath);
|
|
}
|
|
}
|
|
|
|
// Public because it's used in tests
|
|
public async handleRemoveOrphanedDatabases(): Promise<void> {
|
|
void this.app.logger.log(
|
|
"Removing orphaned databases from workspace storage.",
|
|
);
|
|
let dbDirs = undefined;
|
|
|
|
if (
|
|
!(await pathExists(this.storagePath)) ||
|
|
!(await stat(this.storagePath)).isDirectory()
|
|
) {
|
|
void this.app.logger.log(
|
|
"Missing or invalid storage directory. Not trying to remove orphaned databases.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
dbDirs =
|
|
// read directory
|
|
(await readdir(this.storagePath, { withFileTypes: true }))
|
|
// remove non-directories
|
|
.filter((dirent) => dirent.isDirectory())
|
|
// get the full path
|
|
.map((dirent) => 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 this.app.logger.log("No orphaned databases found.");
|
|
return;
|
|
}
|
|
|
|
// delete
|
|
const failures = [] as string[];
|
|
await Promise.all(
|
|
dbDirs.map(async (dbDir) => {
|
|
try {
|
|
void this.app.logger.log(`Deleting orphaned database '${dbDir}'.`);
|
|
await remove(dbDir);
|
|
} catch (e) {
|
|
void showAndLogExceptionWithTelemetry(
|
|
this.app.logger,
|
|
this.app.telemetry,
|
|
redactableError(
|
|
asError(e),
|
|
)`Failed to delete orphaned database: ${getErrorMessage(e)}`,
|
|
);
|
|
failures.push(`${basename(dbDir)}`);
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (failures.length) {
|
|
const dirname = path_dirname(failures[0]);
|
|
void showAndLogErrorMessage(
|
|
this.app.logger,
|
|
`Failed to delete unused databases (${failures.join(
|
|
", ",
|
|
)}).\nTo delete unused databases, please remove them manually from the storage folder ${dirname}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async chooseDatabaseArchive(
|
|
progress: ProgressCallback,
|
|
): Promise<void> {
|
|
try {
|
|
await this.chooseAndSetDatabase(false, progress);
|
|
} catch (e: unknown) {
|
|
void showAndLogExceptionWithTelemetry(
|
|
this.app.logger,
|
|
this.app.telemetry,
|
|
redactableError(
|
|
asError(e),
|
|
)`Failed to choose and set database: ${getErrorMessage(e)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChooseDatabaseArchive(): Promise<void> {
|
|
return withProgress(
|
|
async (progress) => {
|
|
await this.chooseDatabaseArchive(progress);
|
|
},
|
|
{
|
|
title: "Adding database from archive",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleChooseDatabaseArchiveFromPalette(): Promise<void> {
|
|
return withProgress(
|
|
async (progress) => {
|
|
await this.chooseDatabaseArchive(progress);
|
|
},
|
|
{
|
|
title: "Choose a Database from an Archive",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleChooseDatabaseInternet(): Promise<void> {
|
|
return withProgress(
|
|
async (progress) => {
|
|
await this.databaseFetcher.promptImportInternetDatabase(progress);
|
|
},
|
|
{
|
|
title: "Adding database from URL",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleChooseDatabaseGithub(): Promise<void> {
|
|
return withProgress(
|
|
async (progress) => {
|
|
await this.databaseFetcher.promptImportGithubDatabase(progress);
|
|
},
|
|
{
|
|
title: "Adding database from GitHub",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleSortByName() {
|
|
if (this.treeDataProvider.sortOrder === SortOrder.NameAsc) {
|
|
this.treeDataProvider.sortOrder = SortOrder.NameDesc;
|
|
} else {
|
|
this.treeDataProvider.sortOrder = SortOrder.NameAsc;
|
|
}
|
|
}
|
|
|
|
private async handleSortByLanguage() {
|
|
if (this.treeDataProvider.sortOrder === SortOrder.LanguageAsc) {
|
|
this.treeDataProvider.sortOrder = SortOrder.LanguageDesc;
|
|
} else {
|
|
this.treeDataProvider.sortOrder = SortOrder.LanguageAsc;
|
|
}
|
|
}
|
|
|
|
private async handleSortByDateAdded() {
|
|
if (this.treeDataProvider.sortOrder === SortOrder.DateAddedAsc) {
|
|
this.treeDataProvider.sortOrder = SortOrder.DateAddedDesc;
|
|
} else {
|
|
this.treeDataProvider.sortOrder = SortOrder.DateAddedAsc;
|
|
}
|
|
}
|
|
|
|
private async handleUpgradeCurrentDatabase(): Promise<void> {
|
|
return withProgress(
|
|
async (progress, token) => {
|
|
if (this.databaseManager.currentDatabaseItem !== undefined) {
|
|
await this.handleUpgradeDatabasesInternal(progress, token, [
|
|
this.databaseManager.currentDatabaseItem,
|
|
]);
|
|
}
|
|
},
|
|
{
|
|
title: "Upgrading current database",
|
|
cancellable: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleUpgradeDatabase(
|
|
databaseItems: DatabaseItem[],
|
|
): Promise<void> {
|
|
return withProgress(
|
|
async (progress, token) => {
|
|
return await this.handleUpgradeDatabasesInternal(
|
|
progress,
|
|
token,
|
|
databaseItems,
|
|
);
|
|
},
|
|
{
|
|
title: "Upgrading database",
|
|
cancellable: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleUpgradeDatabasesInternal(
|
|
progress: ProgressCallback,
|
|
token: CancellationToken,
|
|
databaseItems: DatabaseItem[],
|
|
): Promise<void> {
|
|
await Promise.all(
|
|
databaseItems.map(async (databaseItem) => {
|
|
if (this.queryServer === undefined) {
|
|
throw new Error(
|
|
"Received request to upgrade database, but there is no running query server.",
|
|
);
|
|
}
|
|
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 this.queryServer.upgradeDatabaseExplicit(
|
|
databaseItem,
|
|
progress,
|
|
token,
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
private async handleClearCache(): Promise<void> {
|
|
return withProgress(
|
|
async () => {
|
|
if (
|
|
this.queryServer !== undefined &&
|
|
this.databaseManager.currentDatabaseItem !== undefined
|
|
) {
|
|
await this.queryServer.clearCacheInDatabase(
|
|
this.databaseManager.currentDatabaseItem,
|
|
);
|
|
}
|
|
},
|
|
{
|
|
title: "Clearing cache",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleTrimCache(): Promise<void> {
|
|
return withProgress(
|
|
async () => {
|
|
if (
|
|
this.queryServer !== undefined &&
|
|
this.databaseManager.currentDatabaseItem !== undefined
|
|
) {
|
|
await this.queryServer.trimCacheInDatabase(
|
|
this.databaseManager.currentDatabaseItem,
|
|
);
|
|
}
|
|
},
|
|
{
|
|
title: "Trimming cache",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleGetCurrentDatabase(): Promise<string | undefined> {
|
|
const dbItem = await this.getDatabaseItemInternal(undefined);
|
|
return dbItem?.databaseUri.fsPath;
|
|
}
|
|
|
|
private async handleSetCurrentDatabase(uri: Uri): Promise<void> {
|
|
return withProgress(
|
|
async (progress) => {
|
|
try {
|
|
// Assume user has selected an archive if the file has a .zip extension
|
|
if (uri.path.endsWith(".zip")) {
|
|
await this.databaseFetcher.importLocalDatabase(
|
|
uri.toString(true),
|
|
progress,
|
|
);
|
|
} else {
|
|
await this.databaseManager.openDatabase(uri, {
|
|
type: "folder",
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// rethrow and let this be handled by default error handling.
|
|
throw new Error(
|
|
`Could not set database to ${basename(
|
|
uri.fsPath,
|
|
)}. Reason: ${getErrorMessage(e)}`,
|
|
);
|
|
}
|
|
},
|
|
{
|
|
title: "Importing database from archive",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleImportTestDatabase(uri: Uri): Promise<void> {
|
|
return withProgress(
|
|
async (progress) => {
|
|
try {
|
|
if (!uri.path.endsWith(".testproj")) {
|
|
throw new Error(
|
|
"Please select a valid test database to import. Test databases end with `.testproj`.",
|
|
);
|
|
}
|
|
|
|
// Check if the database is already in the workspace. If
|
|
// so, delete it first before importing the new one.
|
|
const existingItem = this.databaseManager.findTestDatabase(uri);
|
|
const baseName = basename(uri.fsPath);
|
|
if (existingItem !== undefined) {
|
|
progress({
|
|
maxStep: 9,
|
|
step: 1,
|
|
message: `Removing existing test database ${baseName}`,
|
|
});
|
|
await this.databaseManager.removeDatabaseItem(existingItem);
|
|
}
|
|
|
|
await this.databaseFetcher.importLocalDatabase(
|
|
uri.toString(true),
|
|
progress,
|
|
);
|
|
|
|
if (existingItem !== undefined) {
|
|
progress({
|
|
maxStep: 9,
|
|
step: 9,
|
|
message: `Successfully re-imported ${baseName}`,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// rethrow and let this be handled by default error handling.
|
|
throw new Error(
|
|
`Could not set database to ${basename(
|
|
uri.fsPath,
|
|
)}. Reason: ${getErrorMessage(e)}`,
|
|
);
|
|
}
|
|
},
|
|
{
|
|
title: "(Re-)importing test database from directory",
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleRemoveDatabase(
|
|
databaseItems: DatabaseItem[],
|
|
): Promise<void> {
|
|
return withProgress(
|
|
async () => {
|
|
await Promise.all(
|
|
databaseItems.map((dbItem) =>
|
|
this.databaseManager.removeDatabaseItem(dbItem),
|
|
),
|
|
);
|
|
},
|
|
{
|
|
title: "Removing database",
|
|
cancellable: false,
|
|
},
|
|
);
|
|
}
|
|
|
|
private async handleRenameDatabase(
|
|
databaseItem: DatabaseItem,
|
|
): Promise<void> {
|
|
const newName = await window.showInputBox({
|
|
prompt: "Choose new database name",
|
|
value: databaseItem.name,
|
|
});
|
|
|
|
if (newName) {
|
|
await this.databaseManager.renameDatabaseItem(databaseItem, newName);
|
|
}
|
|
}
|
|
|
|
private async handleOpenFolder(databaseItems: DatabaseItem[]): Promise<void> {
|
|
await Promise.all(
|
|
databaseItems.map((dbItem) => env.openExternal(dbItem.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 async handleAddSource(databaseItems: DatabaseItem[]): Promise<void> {
|
|
for (const dbItem of databaseItems) {
|
|
await this.databaseManager.addDatabaseSourceArchiveFolder(dbItem);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
): Promise<DatabaseItem | undefined> {
|
|
return await this.getDatabaseItemInternal(progress);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Unlike `getDatabaseItem()`, this function does not require the caller to pass in a progress
|
|
* context. If `progress` is `undefined`, then this command will create a new progress
|
|
* notification if it tries to perform any long-running operations.
|
|
*/
|
|
private async getDatabaseItemInternal(
|
|
progress: ProgressCallback | undefined,
|
|
): Promise<DatabaseItem | undefined> {
|
|
if (this.databaseManager.currentDatabaseItem === undefined) {
|
|
progress?.({
|
|
maxStep: 2,
|
|
step: 1,
|
|
message: "Choosing database",
|
|
});
|
|
await this.promptForDatabase();
|
|
}
|
|
return this.databaseManager.currentDatabaseItem;
|
|
}
|
|
|
|
private async promptForDatabase(): Promise<void> {
|
|
// If there aren't any existing databases,
|
|
// don't bother asking the user if they want to pick one.
|
|
if (this.databaseManager.databaseItems.length === 0) {
|
|
return this.importNewDatabase();
|
|
}
|
|
|
|
const quickPickItems: DatabaseSelectionQuickPickItem[] = [
|
|
{
|
|
label: "$(database) Existing database",
|
|
detail: "Select an existing database from your workspace",
|
|
alwaysShow: true,
|
|
databaseKind: "existing",
|
|
},
|
|
{
|
|
label: "$(arrow-down) New database",
|
|
detail:
|
|
"Import a new database from GitHub, a URL, or your local machine...",
|
|
alwaysShow: true,
|
|
databaseKind: "new",
|
|
},
|
|
];
|
|
const selectedOption =
|
|
await window.showQuickPick<DatabaseSelectionQuickPickItem>(
|
|
quickPickItems,
|
|
{
|
|
placeHolder: "Select an option",
|
|
ignoreFocusOut: true,
|
|
},
|
|
);
|
|
|
|
if (!selectedOption) {
|
|
throw new UserCancellationException("No database selected", true);
|
|
}
|
|
|
|
if (selectedOption.databaseKind === "existing") {
|
|
await this.selectExistingDatabase();
|
|
} else if (selectedOption.databaseKind === "new") {
|
|
await this.importNewDatabase();
|
|
}
|
|
}
|
|
|
|
private async selectExistingDatabase() {
|
|
const dbItems: DatabaseQuickPickItem[] =
|
|
this.databaseManager.databaseItems.map((dbItem) => ({
|
|
label: dbItem.name,
|
|
description: getLanguageDisplayName(dbItem.language),
|
|
databaseItem: dbItem,
|
|
}));
|
|
|
|
const selectedDatabase = await window.showQuickPick(dbItems, {
|
|
placeHolder: "Select an existing database from your workspace...",
|
|
ignoreFocusOut: true,
|
|
});
|
|
|
|
if (!selectedDatabase) {
|
|
throw new UserCancellationException("No database selected", true);
|
|
}
|
|
|
|
await this.databaseManager.setCurrentDatabaseItem(
|
|
selectedDatabase.databaseItem,
|
|
);
|
|
}
|
|
|
|
private async importNewDatabase() {
|
|
const importOptions: DatabaseImportQuickPickItems[] = [
|
|
{
|
|
label: "$(github) GitHub",
|
|
detail: "Import a database from a GitHub repository",
|
|
alwaysShow: true,
|
|
importType: "github",
|
|
},
|
|
{
|
|
label: "$(link) URL",
|
|
detail: "Import a database archive or folder from a remote URL",
|
|
alwaysShow: true,
|
|
importType: "URL",
|
|
},
|
|
{
|
|
label: "$(file-zip) Archive",
|
|
detail: "Import a database from a local ZIP archive",
|
|
alwaysShow: true,
|
|
importType: "archive",
|
|
},
|
|
{
|
|
label: "$(folder) Folder",
|
|
detail: "Import a database from a local folder",
|
|
alwaysShow: true,
|
|
importType: "folder",
|
|
},
|
|
];
|
|
const selectedImportOption =
|
|
await window.showQuickPick<DatabaseImportQuickPickItems>(importOptions, {
|
|
placeHolder:
|
|
"Import a new database from GitHub, a URL, or your local machine...",
|
|
ignoreFocusOut: true,
|
|
});
|
|
if (!selectedImportOption) {
|
|
throw new UserCancellationException("No database selected", true);
|
|
}
|
|
if (selectedImportOption.importType === "github") {
|
|
await this.handleChooseDatabaseGithub();
|
|
} else if (selectedImportOption.importType === "URL") {
|
|
await this.handleChooseDatabaseInternet();
|
|
} else if (selectedImportOption.importType === "archive") {
|
|
await this.handleChooseDatabaseArchive();
|
|
} else if (selectedImportOption.importType === "folder") {
|
|
await this.handleChooseDatabaseFolder();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import database from uri. Returns the imported database, or `undefined` if the
|
|
* operation was unsuccessful or canceled.
|
|
*/
|
|
private async importDatabase(
|
|
uri: Uri,
|
|
byFolder: boolean,
|
|
progress: ProgressCallback,
|
|
): Promise<DatabaseItem | undefined> {
|
|
if (byFolder && !uri.fsPath.endsWith(".testproj")) {
|
|
const fixedUri = await this.fixDbUri(uri);
|
|
// we are selecting a database folder
|
|
return await this.databaseManager.openDatabase(fixedUri, {
|
|
type: "folder",
|
|
});
|
|
} else {
|
|
// we are selecting a database archive or a .testproj.
|
|
// Unzip archives (if an archive) and copy into a workspace-controlled area
|
|
// before importing.
|
|
return await this.databaseFetcher.importLocalDatabase(
|
|
uri.toString(true),
|
|
progress,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
): Promise<DatabaseItem | undefined> {
|
|
const uri = await chooseDatabaseDir(byFolder);
|
|
if (!uri) {
|
|
return undefined;
|
|
}
|
|
|
|
return await this.importDatabase(uri, byFolder, progress);
|
|
}
|
|
|
|
/**
|
|
* Ask the user for a parent directory that contains all databases.
|
|
* Returns all valid databases, or `undefined` if the operation was canceled.
|
|
*/
|
|
private async chooseDatabasesParentFolder(
|
|
progress: ProgressCallback,
|
|
): Promise<DatabaseItem[] | undefined> {
|
|
const uri = await chooseDatabaseDir(true);
|
|
if (!uri) {
|
|
return undefined;
|
|
}
|
|
|
|
const databases: DatabaseItem[] = [];
|
|
const failures: string[] = [];
|
|
const entries = await workspace.fs.readDirectory(uri);
|
|
const validFileTypes = [FileType.File, FileType.Directory];
|
|
|
|
for (const [index, entry] of entries.entries()) {
|
|
progress({
|
|
step: index + 1,
|
|
maxStep: entries.length,
|
|
message: `Importing '${entry[0]}'`,
|
|
});
|
|
|
|
const subProgress: ProgressCallback = (p) => {
|
|
progress({
|
|
step: index + 1,
|
|
maxStep: entries.length,
|
|
message: `Importing '${entry[0]}': (${p.step}/${p.maxStep}) ${p.message}`,
|
|
});
|
|
};
|
|
|
|
if (!validFileTypes.includes(entry[1])) {
|
|
void this.app.logger.log(
|
|
`Skipping import for '${entry}', invalid file type: ${entry[1]}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const databaseUri = Uri.joinPath(uri, entry[0]);
|
|
void this.app.logger.log(`Importing from ${databaseUri}`);
|
|
|
|
const database = await this.importDatabase(
|
|
databaseUri,
|
|
entry[1] === FileType.Directory,
|
|
subProgress,
|
|
);
|
|
if (database) {
|
|
databases.push(database);
|
|
} else {
|
|
failures.push(entry[0]);
|
|
}
|
|
} catch (e) {
|
|
failures.push(`${entry[0]}: ${getErrorMessage(e)}`.trim());
|
|
}
|
|
}
|
|
|
|
if (failures.length) {
|
|
void showAndLogErrorMessage(
|
|
this.app.logger,
|
|
`Failed to import ${failures.length} database(s), successfully imported ${databases.length} database(s).`,
|
|
{
|
|
fullMessage: `Failed to import ${failures.length} database(s), successfully imported ${databases.length} database(s).\nFailed databases:\n - ${failures.join("\n - ")}`,
|
|
},
|
|
);
|
|
} else if (databases.length === 0) {
|
|
void showAndLogErrorMessage(
|
|
this.app.logger,
|
|
`No database folder to import.`,
|
|
);
|
|
return undefined;
|
|
} else {
|
|
void showAndLogInformationMessage(
|
|
this.app.logger,
|
|
`Successfully imported ${databases.length} database(s).`,
|
|
);
|
|
}
|
|
|
|
return databases;
|
|
}
|
|
|
|
/**
|
|
* 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 appears to be a db language folder, 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 stat(dbPath)).isFile()) {
|
|
dbPath = path_dirname(dbPath);
|
|
}
|
|
|
|
if (await isLikelyDbLanguageFolder(dbPath)) {
|
|
dbPath = path_dirname(dbPath);
|
|
}
|
|
return Uri.file(dbPath);
|
|
}
|
|
}
|