diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index 0dfa8d649..b351c7ab0 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -561,6 +561,10 @@ "command": "codeQL.copyVersion", "title": "CodeQL: Copy Version Information" }, + { + "command": "codeQLLanguageSelection.setSelectedItem", + "title": "Select" + }, { "command": "codeQLQueries.runLocalQueryFromQueriesPanel", "title": "Run local query", @@ -1147,6 +1151,11 @@ "when": "view == codeQLVariantAnalysisRepositories && viewItem =~ /canImportCodeSearch/", "group": "2_qlContextMenu@1" }, + { + "command": "codeQLLanguageSelection.setSelectedItem", + "when": "view == codeQLLanguageSelection && viewItem =~ /canBeSelected/", + "group": "inline" + }, { "command": "codeQLDatabases.setCurrentDatabase", "group": "inline", @@ -1495,6 +1504,10 @@ { "command": "codeQL.openModelEditor" }, + { + "command": "codeQLLanguageSelection.setSelectedItem", + "when": "false" + }, { "command": "codeQLQueries.runLocalQueryContextMenu", "when": "false" @@ -1965,6 +1978,11 @@ }, "views": { "ql-container": [ + { + "id": "codeQLLanguageSelection", + "name": "Language", + "when": "config.codeQL.canary && config.codeQL.showLanguageFilter" + }, { "id": "codeQLDatabases", "name": "Databases" diff --git a/extensions/ql-vscode/src/common/commands.ts b/extensions/ql-vscode/src/common/commands.ts index 214f74333..c15b66f7b 100644 --- a/extensions/ql-vscode/src/common/commands.ts +++ b/extensions/ql-vscode/src/common/commands.ts @@ -12,6 +12,7 @@ import type { } from "../variant-analysis/shared/variant-analysis"; import type { QLDebugConfiguration } from "../debugger/debug-configuration"; import type { QueryTreeViewItem } from "../queries-panel/query-tree-view-item"; +import type { LanguageSelectionTreeViewItem } from "../language-selection-panel/language-selection-data-provider"; // A command function matching the signature that VS Code calls when // a command is invoked from a context menu on a TreeView with @@ -198,6 +199,13 @@ export type QueryHistoryCommands = { "codeQL.exportSelectedVariantAnalysisResults": () => Promise; }; +// Commands user for the language selector panel +export type LanguageSelectionCommands = { + "codeQLLanguageSelection.setSelectedItem": ( + item: LanguageSelectionTreeViewItem, + ) => Promise; +}; + // Commands used for the local databases panel export type LocalDatabasesCommands = { // Command palette commands @@ -360,6 +368,7 @@ export type AllExtensionCommands = BaseCommands & QueryEditorCommands & ResultsViewCommands & QueryHistoryCommands & + LanguageSelectionCommands & LocalDatabasesCommands & DebuggerCommands & VariantAnalysisCommands & diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index 2d9824ca2..4892de25a 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -136,6 +136,7 @@ import { NewQueryRunner, QueryRunner, QueryServerClient } from "./query-server"; import { QueriesModule } from "./queries-panel/queries-module"; import { OpenReferencedFileCodeLensProvider } from "./local-queries/open-referenced-file-code-lens-provider"; import { LanguageContextStore } from "./language-context-store"; +import { LanguageSelectionPanel } from "./language-selection-panel/language-selection-panel"; /** * extension.ts @@ -779,6 +780,10 @@ async function activateWithInstalledDistribution( void extLogger.log("Initializing language context."); const languageContext = new LanguageContextStore(app); + void extLogger.log("Initializing language selector."); + const languageSelectionPanel = new LanguageSelectionPanel(languageContext); + ctx.subscriptions.push(languageSelectionPanel); + void extLogger.log("Initializing database panel."); const databaseUI = new DatabaseUI( app, @@ -1016,6 +1021,7 @@ async function activateWithInstalledDistribution( ...getPackagingCommands({ cliServer, }), + ...languageSelectionPanel.getCommands(), ...modelEditorModule.getCommands(), ...evalLogViewer.getCommands(), ...summaryLanguageSupport.getCommands(), diff --git a/extensions/ql-vscode/src/language-context-store.ts b/extensions/ql-vscode/src/language-context-store.ts index c1fb33a0b..e3a819511 100644 --- a/extensions/ql-vscode/src/language-context-store.ts +++ b/extensions/ql-vscode/src/language-context-store.ts @@ -43,7 +43,28 @@ export class LanguageContextStore extends DisposableObject { ); } + /** + * This returns true if the given language should be included. + * + * That means that either the given language is selected or the "All" option is selected. + * + * @param language a query language or undefined if the language is unknown. + */ public shouldInclude(language: QueryLanguage | undefined): boolean { return this.languageFilter === "All" || this.languageFilter === language; } + + /** + * This returns true if the given language is selected. + * + * If no language is given then it returns true if the "All" option is selected. + * + * @param language a query language or undefined. + */ + public isSelectedLanguage(language: QueryLanguage | undefined): boolean { + return ( + (this.languageFilter === "All" && language === undefined) || + this.languageFilter === language + ); + } } diff --git a/extensions/ql-vscode/src/language-selection-panel/language-selection-data-provider.ts b/extensions/ql-vscode/src/language-selection-panel/language-selection-data-provider.ts new file mode 100644 index 000000000..c77e7e020 --- /dev/null +++ b/extensions/ql-vscode/src/language-selection-panel/language-selection-data-provider.ts @@ -0,0 +1,91 @@ +import { DisposableObject } from "../common/disposable-object"; +import { LanguageContextStore } from "../language-context-store"; +import { + Event, + EventEmitter, + ThemeIcon, + TreeDataProvider, + TreeItem, +} from "vscode"; +import { + QueryLanguage, + getLanguageDisplayName, +} from "../common/query-language"; + +const ALL_LANGUAGE_SELECTION_OPTIONS = [ + undefined, // All languages + QueryLanguage.Cpp, + QueryLanguage.CSharp, + QueryLanguage.Go, + QueryLanguage.Java, + QueryLanguage.Javascript, + QueryLanguage.Python, + QueryLanguage.Ruby, + QueryLanguage.Swift, +]; + +// A tree view items consisting of of a language (or undefined for all languages) +// and a boolean indicating whether it is selected or not. +export class LanguageSelectionTreeViewItem extends TreeItem { + constructor( + public readonly language: QueryLanguage | undefined, + public readonly selected: boolean = false, + ) { + const label = language ? getLanguageDisplayName(language) : "All languages"; + super(label); + + this.iconPath = selected ? new ThemeIcon("check") : undefined; + this.contextValue = selected ? undefined : "canBeSelected"; + } +} + +export class LanguageSelectionTreeDataProvider + extends DisposableObject + implements TreeDataProvider +{ + private treeItems: LanguageSelectionTreeViewItem[]; + private readonly onDidChangeTreeDataEmitter = this.push( + new EventEmitter(), + ); + + public constructor(private readonly languageContext: LanguageContextStore) { + super(); + + this.treeItems = this.createTree(); + + // If the language context changes, we need to update the tree. + this.push( + this.languageContext.onLanguageContextChanged(() => { + this.treeItems = this.createTree(); + this.onDidChangeTreeDataEmitter.fire(); + }), + ); + } + + public get onDidChangeTreeData(): Event { + return this.onDidChangeTreeDataEmitter.event; + } + + public getTreeItem(item: LanguageSelectionTreeViewItem): TreeItem { + return item; + } + + public getChildren( + item?: LanguageSelectionTreeViewItem, + ): LanguageSelectionTreeViewItem[] { + if (!item) { + return this.treeItems; + } else { + return []; + } + } + + private createTree(): LanguageSelectionTreeViewItem[] { + return ALL_LANGUAGE_SELECTION_OPTIONS.map((language) => { + return new LanguageSelectionTreeViewItem( + language, + this.languageContext.isSelectedLanguage(language), + ); + }); + } +} diff --git a/extensions/ql-vscode/src/language-selection-panel/language-selection-panel.ts b/extensions/ql-vscode/src/language-selection-panel/language-selection-panel.ts new file mode 100644 index 000000000..23e7d2cae --- /dev/null +++ b/extensions/ql-vscode/src/language-selection-panel/language-selection-panel.ts @@ -0,0 +1,41 @@ +import { DisposableObject } from "../common/disposable-object"; +import { window } from "vscode"; +import { + LanguageSelectionTreeDataProvider, + LanguageSelectionTreeViewItem, +} from "./language-selection-data-provider"; +import { LanguageContextStore } from "../language-context-store"; +import { LanguageSelectionCommands } from "../common/commands"; + +// This panel allows the selection of a single language, that will +// then filter all other relevant views (e.g. db panel, query history). +export class LanguageSelectionPanel extends DisposableObject { + constructor(private readonly languageContext: LanguageContextStore) { + super(); + + const dataProvider = new LanguageSelectionTreeDataProvider(languageContext); + this.push(dataProvider); + + const treeView = window.createTreeView("codeQLLanguageSelection", { + treeDataProvider: dataProvider, + }); + this.push(treeView); + } + + public getCommands(): LanguageSelectionCommands { + return { + "codeQLLanguageSelection.setSelectedItem": + this.handleSetSelectedLanguage.bind(this), + }; + } + + private async handleSetSelectedLanguage( + item: LanguageSelectionTreeViewItem, + ): Promise { + if (item.language) { + await this.languageContext.setLanguageContext(item.language); + } else { + await this.languageContext.clearLanguageContext(); + } + } +} diff --git a/extensions/ql-vscode/test/unit-tests/command-lint.test.ts b/extensions/ql-vscode/test/unit-tests/command-lint.test.ts index 2b289ecdc..5c0936a8e 100644 --- a/extensions/ql-vscode/test/unit-tests/command-lint.test.ts +++ b/extensions/ql-vscode/test/unit-tests/command-lint.test.ts @@ -38,6 +38,7 @@ describe("commands declared in package.json", () => { expect(title).toBeDefined(); commandTitles[command] = title!; } else if ( + command.match(/^codeQLLanguageSelection\./) || command.match(/^codeQLDatabases\./) || command.match(/^codeQLQueries\./) || command.match(/^codeQLVariantAnalysisRepositories\./) || diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/language-selection-panel/language-selection-data-provider.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/language-selection-panel/language-selection-data-provider.test.ts new file mode 100644 index 000000000..950fd8ae7 --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/language-selection-panel/language-selection-data-provider.test.ts @@ -0,0 +1,63 @@ +import { + QueryLanguage, + getLanguageDisplayName, +} from "../../../../src/common/query-language"; +import { LanguageContextStore } from "../../../../src/language-context-store"; +import { + LanguageSelectionTreeDataProvider, + LanguageSelectionTreeViewItem, +} from "../../../../src/language-selection-panel/language-selection-data-provider"; +import { createMockApp } from "../../../__mocks__/appMock"; +import { EventEmitter, ThemeIcon } from "vscode"; + +describe("LanguageSelectionTreeDataProvider", () => { + function expectSelected( + items: LanguageSelectionTreeViewItem[], + expected: QueryLanguage | undefined, + ) { + items.forEach((item) => { + if (item.language === expected) { + expect(item.selected).toBe(true); + expect(item.iconPath).toEqual(new ThemeIcon("check")); + } else { + expect(item.selected).toBe(false); + expect(item.iconPath).toBe(undefined); + } + }); + } + + describe("getChildren", () => { + const app = createMockApp({ + createEventEmitter: () => new EventEmitter(), + }); + const languageContext = new LanguageContextStore(app); + const dataProvider = new LanguageSelectionTreeDataProvider(languageContext); + + it("returns list of all languages", async () => { + const expectedLanguageNames = [ + "All languages", + ...Object.values(QueryLanguage).map((language) => { + return getLanguageDisplayName(language); + }), + ]; + const actualLanguagesNames = dataProvider.getChildren().map((item) => { + return item.label; + }); + + // Note that the internal order of C# and C / C++ is different from what is shown in the UI. + // So we sort to make sure we can compare the two lists. + expect(actualLanguagesNames.sort()).toEqual(expectedLanguageNames.sort()); + }); + + it("has a default selection of All languages", async () => { + const items = dataProvider.getChildren(); + expectSelected(items, undefined); + }); + + it("changes the selected element when the language is changed", async () => { + await languageContext.setLanguageContext(QueryLanguage.CSharp); + const items = dataProvider.getChildren(); + expectSelected(items, QueryLanguage.CSharp); + }); + }); +});