Create new queries in selected folder of queries panel

This will change the behavior of the "Create new query" command to
create the new query in the same folder as the first selected item in
the queries panel. If no items are selected, the behavior is the same
as before.

I've used events to communicate the selection from the queries panel to
the local queries module. This is some more code and some extra
complexity, but it ensures that we don't have a dependency from the
local queries module to the queries panel module. This makes testing
easier.
This commit is contained in:
Koen Vlaswinkel
2023-10-24 13:30:58 +02:00
parent fb33879a95
commit 68ab2fda2d
6 changed files with 229 additions and 16 deletions

View File

@@ -795,7 +795,11 @@ async function activateWithInstalledDistribution(
);
ctx.subscriptions.push(databaseUI);
QueriesModule.initialize(app, languageContext, cliServer);
const queriesModule = QueriesModule.initialize(
app,
languageContext,
cliServer,
);
void extLogger.log("Initializing evaluator log viewer.");
const evalLogViewer = new EvalLogViewer();
@@ -934,6 +938,10 @@ async function activateWithInstalledDistribution(
);
ctx.subscriptions.push(localQueries);
queriesModule.onDidChangeSelection((event) =>
localQueries.setSelectedQueryTreeViewItems(event.selection),
);
void extLogger.log("Initializing debugger factory.");
ctx.subscriptions.push(
new QLDebugAdapterDescriptorFactory(queryStorageDir, qs, localQueries),

View File

@@ -63,6 +63,8 @@ export enum QuickEvalType {
}
export class LocalQueries extends DisposableObject {
private selectedQueryTreeViewItems: readonly QueryTreeViewItem[] = [];
public constructor(
private readonly app: App,
private readonly queryRunner: QueryRunner,
@@ -77,6 +79,12 @@ export class LocalQueries extends DisposableObject {
super();
}
public setSelectedQueryTreeViewItems(
selection: readonly QueryTreeViewItem[],
) {
this.selectedQueryTreeViewItems = selection;
}
public getCommands(): LocalQueryCommands {
return {
"codeQL.runQuery": this.runQuery.bind(this),
@@ -333,6 +341,7 @@ export class LocalQueries extends DisposableObject {
this.app.logger,
this.databaseManager,
contextStoragePath,
this.selectedQueryTreeViewItems,
language,
);
await skeletonQueryWizard.execute();

View File

@@ -1,5 +1,5 @@
import { join } from "path";
import { Uri, workspace, window as Window } from "vscode";
import { dirname, join } from "path";
import { Uri, window as Window, workspace } from "vscode";
import { CodeQLCliServer } from "../codeql-cli/cli";
import { BaseLogger } from "../common/logging";
import { Credentials } from "../common/authentication";
@@ -24,8 +24,9 @@ import {
isCodespacesTemplate,
setQlPackLocation,
} from "../config";
import { existsSync } from "fs-extra";
import { lstat, pathExists } from "fs-extra";
import { askForLanguage } from "../codeql-cli/query-language";
import { QueryTreeViewItem } from "../queries-panel/query-tree-view-item";
type QueryLanguagesToDatabaseMap = Record<string, string>;
@@ -51,6 +52,7 @@ export class SkeletonQueryWizard {
private readonly logger: BaseLogger,
private readonly databaseManager: DatabaseManager,
private readonly databaseStoragePath: string | undefined,
private readonly selectedItems: readonly QueryTreeViewItem[],
private language: QueryLanguage | undefined = undefined,
) {}
@@ -71,7 +73,7 @@ export class SkeletonQueryWizard {
this.qlPackStoragePath = await this.determineStoragePath();
const skeletonPackAlreadyExists =
existsSync(join(this.qlPackStoragePath, this.folderName)) ||
(await pathExists(join(this.qlPackStoragePath, this.folderName))) ||
isFolderAlreadyInWorkspace(this.folderName);
if (skeletonPackAlreadyExists) {
@@ -109,7 +111,29 @@ export class SkeletonQueryWizard {
});
}
public async determineStoragePath() {
public async determineStoragePath(): Promise<string> {
if (this.selectedItems.length === 0) {
return this.determineRootStoragePath();
}
// Just like VS Code's "New File" command, if the user has selected multiple files/folders in the queries panel,
// we will create the new file in the same folder as the first selected item.
// See https://github.com/microsoft/vscode/blob/a8b7239d0311d4915b57c837972baf4b01394491/src/vs/workbench/contrib/files/browser/fileActions.ts#L893-L900
const selectedItem = this.selectedItems[0];
const path = selectedItem.path;
// We use stat to protect against outdated query tree items
const fileStat = await lstat(path);
if (fileStat.isDirectory()) {
return path;
}
return dirname(path);
}
public async determineRootStoragePath() {
const firstStorageFolder = getFirstWorkspaceFolder();
if (isCodespacesTemplate()) {
@@ -118,7 +142,7 @@ export class SkeletonQueryWizard {
let storageFolder = getQlPackLocation();
if (storageFolder === undefined || !existsSync(storageFolder)) {
if (storageFolder === undefined || !(await pathExists(storageFolder))) {
storageFolder = await Window.showInputBox({
title:
"Please choose a folder in which to create your new query pack. You can change this in the extension settings.",
@@ -131,7 +155,7 @@ export class SkeletonQueryWizard {
throw new UserCancellationException("No storage folder entered.");
}
if (!existsSync(storageFolder)) {
if (!(await pathExists(storageFolder))) {
throw new UserCancellationException(
"Invalid folder. Must be a folder that already exists.",
);

View File

@@ -7,9 +7,18 @@ import { QueriesPanel } from "./queries-panel";
import { QueryDiscovery } from "./query-discovery";
import { QueryPackDiscovery } from "./query-pack-discovery";
import { LanguageContextStore } from "../language-context-store";
import { TreeViewSelectionChangeEvent } from "vscode";
import { QueryTreeViewItem } from "./query-tree-view-item";
export class QueriesModule extends DisposableObject {
private queriesPanel: QueriesPanel | undefined;
private readonly onDidChangeSelectionEmitter = this.push(
this.app.createEventEmitter<
TreeViewSelectionChangeEvent<QueryTreeViewItem>
>(),
);
public readonly onDidChangeSelection = this.onDidChangeSelectionEmitter.event;
private constructor(readonly app: App) {
super();
@@ -52,6 +61,9 @@ export class QueriesModule extends DisposableObject {
void queryDiscovery.initialRefresh();
this.queriesPanel = new QueriesPanel(queryDiscovery, app);
this.queriesPanel.onDidChangeSelection((event) =>
this.onDidChangeSelectionEmitter.fire(event),
);
this.push(this.queriesPanel);
}
}

View File

@@ -1,7 +1,13 @@
import { DisposableObject } from "../common/disposable-object";
import { QueryTreeDataProvider } from "./query-tree-data-provider";
import { QueryDiscovery } from "./query-discovery";
import { TextEditor, TreeView, window } from "vscode";
import {
Event,
TextEditor,
TreeView,
TreeViewSelectionChangeEvent,
window,
} from "vscode";
import { App } from "../common/app";
import { QueryTreeViewItem } from "./query-tree-view-item";
@@ -16,6 +22,7 @@ export class QueriesPanel extends DisposableObject {
super();
this.dataProvider = new QueryTreeDataProvider(queryDiscovery, app);
this.push(this.dataProvider);
this.treeView = window.createTreeView("codeQLQueries", {
treeDataProvider: this.dataProvider,
@@ -25,6 +32,12 @@ export class QueriesPanel extends DisposableObject {
this.subscribeToTreeSelectionEvents();
}
public get onDidChangeSelection(): Event<
TreeViewSelectionChangeEvent<QueryTreeViewItem>
> {
return this.treeView.onDidChangeSelection;
}
private subscribeToTreeSelectionEvents(): void {
// Keep track of whether the user has changed their text editor while
// the tree view was not visible. If so, we will focus the text editor

View File

@@ -9,8 +9,14 @@ import { TextDocument, window, workspace, WorkspaceFolder } from "vscode";
import { extLogger } from "../../../../src/common/logging/vscode";
import { QlPackGenerator } from "../../../../src/local-queries/qlpack-generator";
import * as workspaceFolders from "../../../../src/common/vscode/workspace-folders";
import { createFileSync, ensureDirSync, removeSync } from "fs-extra";
import { join } from "path";
import {
createFileSync,
ensureDir,
ensureDirSync,
ensureFile,
removeSync,
} from "fs-extra";
import { dirname, join } from "path";
import { testCredentialsWithStub } from "../../../factories/authentication";
import {
DatabaseItem,
@@ -22,6 +28,11 @@ import { createMockDB } from "../../../factories/databases/databases";
import { asError } from "../../../../src/common/helpers-pure";
import { Setting } from "../../../../src/config";
import { QueryLanguage } from "../../../../src/common/query-language";
import {
createQueryTreeFileItem,
createQueryTreeFolderItem,
QueryTreeViewItem,
} from "../../../../src/queries-panel/query-tree-view-item";
describe("SkeletonQueryWizard", () => {
let mockCli: CodeQLCliServer;
@@ -49,6 +60,7 @@ describe("SkeletonQueryWizard", () => {
const credentials = testCredentialsWithStub();
const chosenLanguage = "ruby";
const selectedItems: QueryTreeViewItem[] = [];
jest.spyOn(extLogger, "log").mockResolvedValue(undefined);
@@ -117,6 +129,7 @@ describe("SkeletonQueryWizard", () => {
extLogger,
mockDatabaseManager,
storagePath,
selectedItems,
);
askForGitHubRepoSpy = jest
@@ -144,6 +157,7 @@ describe("SkeletonQueryWizard", () => {
extLogger,
mockDatabaseManager,
storagePath,
selectedItems,
QueryLanguage.Swift,
);
});
@@ -272,6 +286,7 @@ describe("SkeletonQueryWizard", () => {
extLogger,
mockDatabaseManagerWithItems,
storagePath,
selectedItems,
);
});
@@ -407,7 +422,7 @@ describe("SkeletonQueryWizard", () => {
});
describe("determineStoragePath", () => {
it("should prompt the user to provide a storage path", async () => {
it("should prompt the user to provide a storage path when no items are selected", async () => {
const chosenPath = await wizard.determineStoragePath();
expect(showInputBoxSpy).toHaveBeenCalledWith(
@@ -416,10 +431,142 @@ describe("SkeletonQueryWizard", () => {
expect(chosenPath).toEqual(storagePath);
});
describe("with folders and files", () => {
let queriesDir: tmp.DirResult;
beforeEach(async () => {
queriesDir = tmp.dirSync({
prefix: "queries_",
unsafeCleanup: true,
});
await ensureDir(join(queriesDir.name, "folder"));
await ensureFile(join(queriesDir.name, "queries-java", "example.ql"));
});
describe("with selected folder", () => {
let selectedItems: QueryTreeViewItem[];
beforeEach(async () => {
selectedItems = [
createQueryTreeFolderItem(
"folder",
join(queriesDir.name, "folder"),
[
createQueryTreeFileItem(
"example.ql",
join(queriesDir.name, "folder", "example.ql"),
"java",
),
],
),
];
wizard = new SkeletonQueryWizard(
mockCli,
jest.fn(),
credentials,
extLogger,
mockDatabaseManager,
storagePath,
selectedItems,
);
});
it("returns the selected folder path", async () => {
const chosenPath = await wizard.determineStoragePath();
expect(chosenPath).toEqual(selectedItems[0].path);
});
});
describe("with selected file", () => {
let selectedItems: QueryTreeViewItem[];
beforeEach(async () => {
selectedItems = [
createQueryTreeFileItem(
"example.ql",
join(queriesDir.name, "queries-java", "example.ql"),
"java",
),
];
wizard = new SkeletonQueryWizard(
mockCli,
jest.fn(),
credentials,
extLogger,
mockDatabaseManager,
storagePath,
selectedItems,
);
});
it("returns the selected file path", async () => {
const chosenPath = await wizard.determineStoragePath();
expect(chosenPath).toEqual(dirname(selectedItems[0].path));
});
});
describe("with multiple selected items", () => {
let selectedItems: QueryTreeViewItem[];
beforeEach(async () => {
selectedItems = [
createQueryTreeFileItem(
"example.ql",
join(queriesDir.name, "queries-java", "example.ql"),
"java",
),
createQueryTreeFolderItem(
"folder",
join(queriesDir.name, "folder"),
[
createQueryTreeFileItem(
"example.ql",
join(queriesDir.name, "folder", "example.ql"),
"java",
),
],
),
];
wizard = new SkeletonQueryWizard(
mockCli,
jest.fn(),
credentials,
extLogger,
mockDatabaseManager,
storagePath,
selectedItems,
);
});
it("returns the first selected item path", async () => {
const chosenPath = await wizard.determineStoragePath();
expect(chosenPath).toEqual(dirname(selectedItems[0].path));
});
});
});
});
describe("determineRootStoragePath", () => {
it("should prompt the user to provide a storage path", async () => {
const chosenPath = await wizard.determineRootStoragePath();
expect(showInputBoxSpy).toHaveBeenCalledWith(
expect.objectContaining({ value: storagePath }),
);
expect(chosenPath).toEqual(storagePath);
});
it("should write the chosen folder to settings", async () => {
const updateValueSpy = jest.spyOn(Setting.prototype, "updateValue");
await wizard.determineStoragePath();
await wizard.determineRootStoragePath();
expect(updateValueSpy).toHaveBeenCalledWith(storagePath, 2);
});
@@ -453,7 +600,7 @@ describe("SkeletonQueryWizard", () => {
});
it("should not prompt the user", async () => {
const chosenPath = await wizard.determineStoragePath();
const chosenPath = await wizard.determineRootStoragePath();
expect(showInputBoxSpy).not.toHaveBeenCalled();
expect(chosenPath).toEqual(storagePath);
@@ -484,7 +631,7 @@ describe("SkeletonQueryWizard", () => {
});
it("should return it and not prompt the user", async () => {
const chosenPath = await wizard.determineStoragePath();
const chosenPath = await wizard.determineRootStoragePath();
expect(showInputBoxSpy).not.toHaveBeenCalled();
expect(chosenPath).toEqual(storedPath);
@@ -513,7 +660,7 @@ describe("SkeletonQueryWizard", () => {
});
it("should prompt the user for to provide a new folder name", async () => {
const chosenPath = await wizard.determineStoragePath();
const chosenPath = await wizard.determineRootStoragePath();
expect(showInputBoxSpy).toHaveBeenCalled();
expect(chosenPath).toEqual(storagePath);