Merge branch 'main' into dbartol/passthru

This commit is contained in:
Dave Bartolomeo
2023-10-09 14:06:14 -04:00
committed by GitHub
42 changed files with 1204 additions and 370 deletions

View File

@@ -1,5 +1,6 @@
**/* @github/codeql-vscode-reviewers **/* @github/codeql-vscode-reviewers
**/variant-analysis/ @github/code-scanning-secexp-reviewers **/variant-analysis/ @github/code-scanning-secexp-reviewers
**/databases/ @github/code-scanning-secexp-reviewers **/databases/ @github/code-scanning-secexp-reviewers
**/method-modeling/ @github/code-scanning-secexp-reviewers
**/model-editor/ @github/code-scanning-secexp-reviewers **/model-editor/ @github/code-scanning-secexp-reviewers
**/queries-panel/ @github/code-scanning-secexp-reviewers **/queries-panel/ @github/code-scanning-secexp-reviewers

View File

@@ -6,6 +6,8 @@
- It is now possible to show the language of query history items using the `%l` specifier in the `codeQL.queryHistory.format` setting. Note that this only works for queries run after this upgrade, and older items will show `unknown` as a language. [#2892](https://github.com/github/vscode-codeql/pull/2892) - It is now possible to show the language of query history items using the `%l` specifier in the `codeQL.queryHistory.format` setting. Note that this only works for queries run after this upgrade, and older items will show `unknown` as a language. [#2892](https://github.com/github/vscode-codeql/pull/2892)
- Increase the required version of VS Code to 1.82.0. [#2877](https://github.com/github/vscode-codeql/pull/2877) - Increase the required version of VS Code to 1.82.0. [#2877](https://github.com/github/vscode-codeql/pull/2877)
- Fix a bug where the query server was restarted twice after configuration changes. [#2884](https://github.com/github/vscode-codeql/pull/2884). - Fix a bug where the query server was restarted twice after configuration changes. [#2884](https://github.com/github/vscode-codeql/pull/2884).
- Add support for the `telemetry.telemetryLevel` setting. For more information, see the [telemetry documentation](https://codeql.github.com/docs/codeql-for-visual-studio-code/about-telemetry-in-codeql-for-visual-studio-code). [#2824](https://github.com/github/vscode-codeql/pull/2824).
- Fix syntax highlighting directly after import statements with instantiation arguments. [#2792](https://github.com/github/vscode-codeql/pull/2792)
## 1.9.1 - 29 September 2023 ## 1.9.1 - 29 September 2023

View File

@@ -450,13 +450,20 @@
"type": "boolean", "type": "boolean",
"default": false, "default": false,
"scope": "application", "scope": "application",
"markdownDescription": "Specifies whether to send CodeQL usage telemetry. This setting AND the global `#telemetry.enableTelemetry#` setting must be checked for telemetry to be sent to GitHub. For more information, see the [telemetry documentation](https://codeql.github.com/docs/codeql-for-visual-studio-code/about-telemetry-in-codeql-for-visual-studio-code)" "markdownDescription": "Specifies whether to send CodeQL usage telemetry. This setting AND the one of the global telemetry settings (`#telemetry.enableTelemetry#` or `#telemetry.telemetryLevel#`) must be enabled for telemetry to be sent to GitHub. For more information, see the [telemetry documentation](https://codeql.github.com/docs/codeql-for-visual-studio-code/about-telemetry-in-codeql-for-visual-studio-code)",
"tags": [
"telemetry",
"usesOnlineServices"
]
}, },
"codeQL.telemetry.logTelemetry": { "codeQL.telemetry.logTelemetry": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
"scope": "application", "scope": "application",
"description": "Specifies whether or not to write telemetry events to the extension log." "description": "Specifies whether or not to write telemetry events to the extension log.",
"tags": [
"telemetry"
]
} }
} }
} }

View File

@@ -546,8 +546,7 @@ interface RefreshMethods {
interface SaveModeledMethods { interface SaveModeledMethods {
t: "saveModeledMethods"; t: "saveModeledMethods";
methods: Method[]; methodSignatures?: string[];
modeledMethods: Record<string, ModeledMethod>;
} }
interface GenerateMethodMessage { interface GenerateMethodMessage {
@@ -587,7 +586,7 @@ interface SetInModelingModeMessage {
interface RevealMethodMessage { interface RevealMethodMessage {
t: "revealMethod"; t: "revealMethod";
method: Method; methodSignature: string;
} }
export type ToModelEditorMessage = export type ToModelEditorMessage =

View File

@@ -3,13 +3,13 @@ import {
Extension, Extension,
ExtensionContext, ExtensionContext,
ConfigurationChangeEvent, ConfigurationChangeEvent,
env,
} from "vscode"; } from "vscode";
import TelemetryReporter from "vscode-extension-telemetry"; import TelemetryReporter from "vscode-extension-telemetry";
import { import {
ConfigListener, ConfigListener,
CANARY_FEATURES, CANARY_FEATURES,
ENABLE_TELEMETRY, ENABLE_TELEMETRY,
GLOBAL_ENABLE_TELEMETRY,
LOG_TELEMETRY, LOG_TELEMETRY,
isIntegrationTestMode, isIntegrationTestMode,
isCanary, isCanary,
@@ -59,8 +59,6 @@ export class ExtensionTelemetryListener
extends ConfigListener extends ConfigListener
implements AppTelemetry implements AppTelemetry
{ {
static relevantSettings = [ENABLE_TELEMETRY, CANARY_FEATURES];
private reporter?: TelemetryReporter; private reporter?: TelemetryReporter;
private cliVersionStr = NOT_SET_CLI_VERSION; private cliVersionStr = NOT_SET_CLI_VERSION;
@@ -72,6 +70,10 @@ export class ExtensionTelemetryListener
private readonly ctx: ExtensionContext, private readonly ctx: ExtensionContext,
) { ) {
super(); super();
env.onDidChangeTelemetryEnabled(async () => {
await this.initialize();
});
} }
/** /**
@@ -91,10 +93,7 @@ export class ExtensionTelemetryListener
async handleDidChangeConfiguration( async handleDidChangeConfiguration(
e: ConfigurationChangeEvent, e: ConfigurationChangeEvent,
): Promise<void> { ): Promise<void> {
if ( if (e.affectsConfiguration(ENABLE_TELEMETRY.qualifiedName)) {
e.affectsConfiguration("codeQL.telemetry.enableTelemetry") ||
e.affectsConfiguration("telemetry.enableTelemetry")
) {
await this.initialize(); await this.initialize();
} }
@@ -102,7 +101,7 @@ export class ExtensionTelemetryListener
// Re-request if codeQL.canary is being set to `true` and telemetry // Re-request if codeQL.canary is being set to `true` and telemetry
// is not currently enabled. // is not currently enabled.
if ( if (
e.affectsConfiguration("codeQL.canary") && e.affectsConfiguration(CANARY_FEATURES.qualifiedName) &&
CANARY_FEATURES.getValue() && CANARY_FEATURES.getValue() &&
!ENABLE_TELEMETRY.getValue() !ENABLE_TELEMETRY.getValue()
) { ) {
@@ -212,7 +211,7 @@ export class ExtensionTelemetryListener
properties.stack = error.stack; properties.stack = error.stack;
} }
this.reporter.sendTelemetryEvent("error", properties, {}); this.reporter.sendTelemetryErrorEvent("error", properties, {});
} }
/** /**
@@ -224,7 +223,7 @@ export class ExtensionTelemetryListener
// if global telemetry is disabled, avoid showing the dialog or making any changes // if global telemetry is disabled, avoid showing the dialog or making any changes
let result = undefined; let result = undefined;
if ( if (
GLOBAL_ENABLE_TELEMETRY.getValue() && env.isTelemetryEnabled &&
// Avoid showing the dialog if we are in integration test mode. // Avoid showing the dialog if we are in integration test mode.
!isIntegrationTestMode() !isIntegrationTestMode()
) { ) {

View File

@@ -72,15 +72,8 @@ export const VSCODE_SAVE_BEFORE_START_SETTING = new Setting(
const ROOT_SETTING = new Setting("codeQL"); const ROOT_SETTING = new Setting("codeQL");
// Global configuration // Telemetry configuration
const TELEMETRY_SETTING = new Setting("telemetry", ROOT_SETTING); const TELEMETRY_SETTING = new Setting("telemetry", ROOT_SETTING);
const AST_VIEWER_SETTING = new Setting("astViewer", ROOT_SETTING);
const CONTEXTUAL_QUERIES_SETTINGS = new Setting(
"contextualQueries",
ROOT_SETTING,
);
const GLOBAL_TELEMETRY_SETTING = new Setting("telemetry");
const LOG_INSIGHTS_SETTING = new Setting("logInsights", ROOT_SETTING);
export const LOG_TELEMETRY = new Setting("logTelemetry", TELEMETRY_SETTING); export const LOG_TELEMETRY = new Setting("logTelemetry", TELEMETRY_SETTING);
export const ENABLE_TELEMETRY = new Setting( export const ENABLE_TELEMETRY = new Setting(
@@ -88,11 +81,6 @@ export const ENABLE_TELEMETRY = new Setting(
TELEMETRY_SETTING, TELEMETRY_SETTING,
); );
export const GLOBAL_ENABLE_TELEMETRY = new Setting(
"enableTelemetry",
GLOBAL_TELEMETRY_SETTING,
);
// Distribution configuration // Distribution configuration
const DISTRIBUTION_SETTING = new Setting("cli", ROOT_SETTING); const DISTRIBUTION_SETTING = new Setting("cli", ROOT_SETTING);
export const CUSTOM_CODEQL_PATH_SETTING = new Setting( export const CUSTOM_CODEQL_PATH_SETTING = new Setting(
@@ -475,6 +463,7 @@ export function allowCanaryQueryServer() {
return value === undefined ? true : !!value; return value === undefined ? true : !!value;
} }
const LOG_INSIGHTS_SETTING = new Setting("logInsights", ROOT_SETTING);
export const JOIN_ORDER_WARNING_THRESHOLD = new Setting( export const JOIN_ORDER_WARNING_THRESHOLD = new Setting(
"joinOrderWarningThreshold", "joinOrderWarningThreshold",
LOG_INSIGHTS_SETTING, LOG_INSIGHTS_SETTING,
@@ -484,6 +473,7 @@ export function joinOrderWarningThreshold(): number {
return JOIN_ORDER_WARNING_THRESHOLD.getValue<number>(); return JOIN_ORDER_WARNING_THRESHOLD.getValue<number>();
} }
const AST_VIEWER_SETTING = new Setting("astViewer", ROOT_SETTING);
/** /**
* Hidden setting: Avoids caching in the AST viewer if the user is also a canary user. * Hidden setting: Avoids caching in the AST viewer if the user is also a canary user.
*/ */
@@ -492,6 +482,10 @@ export const NO_CACHE_AST_VIEWER = new Setting(
AST_VIEWER_SETTING, AST_VIEWER_SETTING,
); );
const CONTEXTUAL_QUERIES_SETTINGS = new Setting(
"contextualQueries",
ROOT_SETTING,
);
/** /**
* Hidden setting: Avoids caching in jump to def and find refs contextual queries if the user is also a canary user. * Hidden setting: Avoids caching in jump to def and find refs contextual queries if the user is also a canary user.
*/ */
@@ -711,20 +705,33 @@ const LLM_GENERATION = new Setting("llmGeneration", MODEL_SETTING);
const EXTENSIONS_DIRECTORY = new Setting("extensionsDirectory", MODEL_SETTING); const EXTENSIONS_DIRECTORY = new Setting("extensionsDirectory", MODEL_SETTING);
const SHOW_MULTIPLE_MODELS = new Setting("showMultipleModels", MODEL_SETTING); const SHOW_MULTIPLE_MODELS = new Setting("showMultipleModels", MODEL_SETTING);
export function showFlowGeneration(): boolean { export interface ModelConfig {
return !!FLOW_GENERATION.getValue<boolean>(); flowGeneration: boolean;
llmGeneration: boolean;
getExtensionsDirectory(languageId: string): string | undefined;
showMultipleModels: boolean;
} }
export function showLlmGeneration(): boolean { export class ModelConfigListener extends ConfigListener implements ModelConfig {
return !!LLM_GENERATION.getValue<boolean>(); protected handleDidChangeConfiguration(e: ConfigurationChangeEvent): void {
} this.handleDidChangeConfigurationForRelevantSettings([MODEL_SETTING], e);
}
export function getExtensionsDirectory(languageId: string): string | undefined { public get flowGeneration(): boolean {
return EXTENSIONS_DIRECTORY.getValue<string>({ return !!FLOW_GENERATION.getValue<boolean>();
languageId, }
});
}
export function showMultipleModels(): boolean { public get llmGeneration(): boolean {
return !!SHOW_MULTIPLE_MODELS.getValue<boolean>(); return !!LLM_GENERATION.getValue<boolean>();
}
public getExtensionsDirectory(languageId: string): string | undefined {
return EXTENSIONS_DIRECTORY.getValue<string>({
languageId,
});
}
public get showMultipleModels(): boolean {
return !!SHOW_MULTIPLE_MODELS.getValue<boolean>();
}
} }

View File

@@ -11,7 +11,7 @@ import { getQlPackPath, QLPACK_FILENAMES } from "../common/ql";
import { getErrorMessage } from "../common/helpers-pure"; import { getErrorMessage } from "../common/helpers-pure";
import { ExtensionPack } from "./shared/extension-pack"; import { ExtensionPack } from "./shared/extension-pack";
import { NotificationLogger, showAndLogErrorMessage } from "../common/logging"; import { NotificationLogger, showAndLogErrorMessage } from "../common/logging";
import { getExtensionsDirectory } from "../config"; import { ModelConfig } from "../config";
import { import {
autoNameExtensionPack, autoNameExtensionPack,
ExtensionPackName, ExtensionPackName,
@@ -28,6 +28,7 @@ const extensionPackValidate = ajv.compile(extensionPackMetadataSchemaJson);
export async function pickExtensionPack( export async function pickExtensionPack(
cliServer: Pick<CodeQLCliServer, "resolveQlpacks">, cliServer: Pick<CodeQLCliServer, "resolveQlpacks">,
databaseItem: Pick<DatabaseItem, "name" | "language">, databaseItem: Pick<DatabaseItem, "name" | "language">,
modelConfig: ModelConfig,
logger: NotificationLogger, logger: NotificationLogger,
progress: ProgressCallback, progress: ProgressCallback,
maxStep: number, maxStep: number,
@@ -56,7 +57,9 @@ export async function pickExtensionPack(
}); });
// Get the `codeQL.model.extensionsDirectory` setting for the language // Get the `codeQL.model.extensionsDirectory` setting for the language
const userExtensionsDirectory = getExtensionsDirectory(databaseItem.language); const userExtensionsDirectory = modelConfig.getExtensionsDirectory(
databaseItem.language,
);
// If the setting is not set, automatically pick a suitable directory // If the setting is not set, automatically pick a suitable directory
const extensionsDirectory = userExtensionsDirectory const extensionsDirectory = userExtensionsDirectory

View File

@@ -5,6 +5,7 @@ import { MethodModelingViewProvider } from "./method-modeling-view-provider";
import { Method } from "../method"; import { Method } from "../method";
import { ModelingStore } from "../modeling-store"; import { ModelingStore } from "../modeling-store";
import { ModelEditorViewTracker } from "../model-editor-view-tracker"; import { ModelEditorViewTracker } from "../model-editor-view-tracker";
import { ModelConfigListener } from "../../config";
export class MethodModelingPanel extends DisposableObject { export class MethodModelingPanel extends DisposableObject {
private readonly provider: MethodModelingViewProvider; private readonly provider: MethodModelingViewProvider;
@@ -16,10 +17,16 @@ export class MethodModelingPanel extends DisposableObject {
) { ) {
super(); super();
// This is here instead of in MethodModelingViewProvider because we need to
// dispose this when the extension gets disposed, not when the webview gets
// disposed.
const modelConfig = this.push(new ModelConfigListener());
this.provider = new MethodModelingViewProvider( this.provider = new MethodModelingViewProvider(
app, app,
modelingStore, modelingStore,
editorViewTracker, editorViewTracker,
modelConfig,
); );
this.push( this.push(
window.registerWebviewViewProvider( window.registerWebviewViewProvider(

View File

@@ -12,7 +12,7 @@ import { DbModelingState, ModelingStore } from "../modeling-store";
import { AbstractWebviewViewProvider } from "../../common/vscode/abstract-webview-view-provider"; import { AbstractWebviewViewProvider } from "../../common/vscode/abstract-webview-view-provider";
import { assertNever } from "../../common/helpers-pure"; import { assertNever } from "../../common/helpers-pure";
import { ModelEditorViewTracker } from "../model-editor-view-tracker"; import { ModelEditorViewTracker } from "../model-editor-view-tracker";
import { showMultipleModels } from "../../config"; import { ModelConfigListener } from "../../config";
export class MethodModelingViewProvider extends AbstractWebviewViewProvider< export class MethodModelingViewProvider extends AbstractWebviewViewProvider<
ToMethodModelingMessage, ToMethodModelingMessage,
@@ -26,6 +26,7 @@ export class MethodModelingViewProvider extends AbstractWebviewViewProvider<
app: App, app: App,
private readonly modelingStore: ModelingStore, private readonly modelingStore: ModelingStore,
private readonly editorViewTracker: ModelEditorViewTracker, private readonly editorViewTracker: ModelEditorViewTracker,
private readonly modelConfig: ModelConfigListener,
) { ) {
super(app, "method-modeling"); super(app, "method-modeling");
} }
@@ -33,13 +34,14 @@ export class MethodModelingViewProvider extends AbstractWebviewViewProvider<
protected override async onWebViewLoaded(): Promise<void> { protected override async onWebViewLoaded(): Promise<void> {
await Promise.all([this.setViewState(), this.setInitialState()]); await Promise.all([this.setViewState(), this.setInitialState()]);
this.registerToModelingStoreEvents(); this.registerToModelingStoreEvents();
this.registerToModelConfigEvents();
} }
private async setViewState(): Promise<void> { private async setViewState(): Promise<void> {
await this.postMessage({ await this.postMessage({
t: "setMethodModelingPanelViewState", t: "setMethodModelingPanelViewState",
viewState: { viewState: {
showMultipleModels: showMultipleModels(), showMultipleModels: this.modelConfig.showMultipleModels,
}, },
}); });
} }
@@ -198,4 +200,12 @@ export class MethodModelingViewProvider extends AbstractWebviewViewProvider<
}), }),
); );
} }
private registerToModelConfigEvents(): void {
this.push(
this.modelConfig.onDidChangeConfiguration(() => {
void this.setViewState();
}),
);
}
} }

View File

@@ -102,7 +102,10 @@ export class MethodsUsageDataProvider
const modeledMethod = this.modeledMethods[method.signature]; const modeledMethod = this.modeledMethods[method.signature];
const modifiedMethod = this.modifiedMethodSignatures.has(method.signature); const modifiedMethod = this.modifiedMethodSignatures.has(method.signature);
const status = getModelingStatus(modeledMethod, modifiedMethod); const status = getModelingStatus(
modeledMethod ? [modeledMethod] : [],
modifiedMethod,
);
switch (status) { switch (status) {
case "unmodeled": case "unmodeled":
return new ThemeIcon("error", new ThemeColor("errorForeground")); return new ThemeIcon("error", new ThemeColor("errorForeground"));

View File

@@ -21,6 +21,7 @@ import { MethodModelingPanel } from "./method-modeling/method-modeling-panel";
import { ModelingStore } from "./modeling-store"; import { ModelingStore } from "./modeling-store";
import { showResolvableLocation } from "../databases/local-databases/locations"; import { showResolvableLocation } from "../databases/local-databases/locations";
import { ModelEditorViewTracker } from "./model-editor-view-tracker"; import { ModelEditorViewTracker } from "./model-editor-view-tracker";
import { ModelConfigListener } from "../config";
const SUPPORTED_LANGUAGES: string[] = ["java", "csharp"]; const SUPPORTED_LANGUAGES: string[] = ["java", "csharp"];
@@ -150,9 +151,12 @@ export class ModelEditorModule extends DisposableObject {
return; return;
} }
const modelConfig = this.push(new ModelConfigListener());
const modelFile = await pickExtensionPack( const modelFile = await pickExtensionPack(
this.cliServer, this.cliServer,
db, db,
modelConfig,
this.app.logger, this.app.logger,
progress, progress,
maxStep, maxStep,
@@ -172,7 +176,12 @@ export class ModelEditorModule extends DisposableObject {
unsafeCleanup: true, unsafeCleanup: true,
}); });
const success = await setUpPack(this.cliServer, queryDir, language); const success = await setUpPack(
this.cliServer,
queryDir,
language,
modelConfig,
);
if (!success) { if (!success) {
await cleanupQueryDir(); await cleanupQueryDir();
return; return;
@@ -188,6 +197,7 @@ export class ModelEditorModule extends DisposableObject {
this.app, this.app,
this.modelingStore, this.modelingStore,
this.editorViewTracker, this.editorViewTracker,
modelConfig,
this.databaseManager, this.databaseManager,
this.cliServer, this.cliServer,
this.queryRunner, this.queryRunner,

View File

@@ -4,7 +4,7 @@ import { writeFile } from "fs-extra";
import { dump } from "js-yaml"; import { dump } from "js-yaml";
import { prepareExternalApiQuery } from "./external-api-usage-queries"; import { prepareExternalApiQuery } from "./external-api-usage-queries";
import { CodeQLCliServer } from "../codeql-cli/cli"; import { CodeQLCliServer } from "../codeql-cli/cli";
import { showLlmGeneration } from "../config"; import { ModelConfig } from "../config";
import { Mode } from "./shared/mode"; import { Mode } from "./shared/mode";
import { resolveQueriesFromPacks } from "../local-queries"; import { resolveQueriesFromPacks } from "../local-queries";
import { modeTag } from "./mode-tag"; import { modeTag } from "./mode-tag";
@@ -28,12 +28,14 @@ export const syntheticQueryPackName = "codeql/external-api-usage";
* @param cliServer The CodeQL CLI server to use. * @param cliServer The CodeQL CLI server to use.
* @param queryDir The directory to set up. * @param queryDir The directory to set up.
* @param language The language to use for the queries. * @param language The language to use for the queries.
* @param modelConfig The model config to use.
* @returns true if the setup was successful, false otherwise. * @returns true if the setup was successful, false otherwise.
*/ */
export async function setUpPack( export async function setUpPack(
cliServer: CodeQLCliServer, cliServer: CodeQLCliServer,
queryDir: string, queryDir: string,
language: QueryLanguage, language: QueryLanguage,
modelConfig: ModelConfig,
): Promise<boolean> { ): Promise<boolean> {
// Download the required query packs // Download the required query packs
await cliServer.packDownload([`codeql/${language}-queries`]); await cliServer.packDownload([`codeql/${language}-queries`]);
@@ -84,7 +86,7 @@ export async function setUpPack(
} }
// Download any other required packs // Download any other required packs
if (language === "java" && showLlmGeneration()) { if (language === "java" && modelConfig.llmGeneration) {
await cliServer.packDownload([`codeql/${language}-automodel-queries`]); await cliServer.packDownload([`codeql/${language}-automodel-queries`]);
} }

View File

@@ -17,8 +17,8 @@ import {
import { ProgressCallback, withProgress } from "../common/vscode/progress"; import { ProgressCallback, withProgress } from "../common/vscode/progress";
import { QueryRunner } from "../query-server"; import { QueryRunner } from "../query-server";
import { import {
showAndLogExceptionWithTelemetry,
showAndLogErrorMessage, showAndLogErrorMessage,
showAndLogExceptionWithTelemetry,
} from "../common/logging"; } from "../common/logging";
import { DatabaseItem, DatabaseManager } from "../databases/local-databases"; import { DatabaseItem, DatabaseManager } from "../databases/local-databases";
import { CodeQLCliServer } from "../codeql-cli/cli"; import { CodeQLCliServer } from "../codeql-cli/cli";
@@ -34,11 +34,7 @@ import {
import { Method, Usage } from "./method"; import { Method, Usage } from "./method";
import { ModeledMethod } from "./modeled-method"; import { ModeledMethod } from "./modeled-method";
import { ExtensionPack } from "./shared/extension-pack"; import { ExtensionPack } from "./shared/extension-pack";
import { import { ModelConfigListener } from "../config";
showFlowGeneration,
showLlmGeneration,
showMultipleModels,
} from "../config";
import { Mode } from "./shared/mode"; import { Mode } from "./shared/mode";
import { loadModeledMethods, saveModeledMethods } from "./modeled-method-fs"; import { loadModeledMethods, saveModeledMethods } from "./modeled-method-fs";
import { pickExtensionPack } from "./extension-pack-picker"; import { pickExtensionPack } from "./extension-pack-picker";
@@ -47,6 +43,10 @@ import { AutoModeler } from "./auto-modeler";
import { telemetryListener } from "../common/vscode/telemetry"; import { telemetryListener } from "../common/vscode/telemetry";
import { ModelingStore } from "./modeling-store"; import { ModelingStore } from "./modeling-store";
import { ModelEditorViewTracker } from "./model-editor-view-tracker"; import { ModelEditorViewTracker } from "./model-editor-view-tracker";
import {
convertFromLegacyModeledMethods,
convertToLegacyModeledMethods,
} from "./modeled-methods-legacy";
export class ModelEditorView extends AbstractWebview< export class ModelEditorView extends AbstractWebview<
ToModelEditorMessage, ToModelEditorMessage,
@@ -58,6 +58,7 @@ export class ModelEditorView extends AbstractWebview<
protected readonly app: App, protected readonly app: App,
private readonly modelingStore: ModelingStore, private readonly modelingStore: ModelingStore,
private readonly viewTracker: ModelEditorViewTracker<ModelEditorView>, private readonly viewTracker: ModelEditorViewTracker<ModelEditorView>,
private readonly modelConfig: ModelConfigListener,
private readonly databaseManager: DatabaseManager, private readonly databaseManager: DatabaseManager,
private readonly cliServer: CodeQLCliServer, private readonly cliServer: CodeQLCliServer,
private readonly queryRunner: QueryRunner, private readonly queryRunner: QueryRunner,
@@ -71,6 +72,7 @@ export class ModelEditorView extends AbstractWebview<
this.modelingStore.initializeStateForDb(databaseItem); this.modelingStore.initializeStateForDb(databaseItem);
this.registerToModelingStoreEvents(); this.registerToModelingStoreEvents();
this.registerToModelConfigEvents();
this.viewTracker.registerView(this); this.viewTracker.registerView(this);
@@ -201,47 +203,58 @@ export class ModelEditorView extends AbstractWebview<
break; break;
case "saveModeledMethods": case "saveModeledMethods":
await withProgress( {
async (progress) => { const methods = this.modelingStore.getMethods(
progress({ this.databaseItem,
step: 1, msg.methodSignatures,
maxStep: 500 + externalApiQueriesProgressMaxStep, );
message: "Writing model files", const modeledMethods = this.modelingStore.getModeledMethods(
}); this.databaseItem,
await saveModeledMethods( msg.methodSignatures,
this.extensionPack, );
this.databaseItem.language,
msg.methods,
msg.modeledMethods,
this.mode,
this.cliServer,
this.app.logger,
);
await Promise.all([ await withProgress(
this.setViewState(), async (progress) => {
this.loadMethods((update) => progress({
progress({ step: 1,
...update, maxStep: 500 + externalApiQueriesProgressMaxStep,
step: update.step + 500, message: "Writing model files",
maxStep: 500 + externalApiQueriesProgressMaxStep, });
}), await saveModeledMethods(
), this.extensionPack,
]); this.databaseItem.language,
}, methods,
{ convertFromLegacyModeledMethods(modeledMethods),
cancellable: false, this.mode,
}, this.cliServer,
); this.app.logger,
);
this.modelingStore.removeModifiedMethods( await Promise.all([
this.databaseItem, this.setViewState(),
Object.keys(msg.modeledMethods), this.loadMethods((update) =>
); progress({
...update,
step: update.step + 500,
maxStep: 500 + externalApiQueriesProgressMaxStep,
}),
),
]);
},
{
cancellable: false,
},
);
void telemetryListener?.sendUIInteraction( this.modelingStore.removeModifiedMethods(
"model-editor-save-modeled-methods", this.databaseItem,
); Object.keys(modeledMethods),
);
void telemetryListener?.sendUIInteraction(
"model-editor-save-modeled-methods",
);
}
break; break;
case "generateMethod": case "generateMethod":
@@ -328,21 +341,21 @@ export class ModelEditorView extends AbstractWebview<
await this.postMessage({ await this.postMessage({
t: "revealMethod", t: "revealMethod",
method, methodSignature: method.signature,
}); });
} }
private async setViewState(): Promise<void> { private async setViewState(): Promise<void> {
const showLlmButton = const showLlmButton =
this.databaseItem.language === "java" && showLlmGeneration(); this.databaseItem.language === "java" && this.modelConfig.llmGeneration;
await this.postMessage({ await this.postMessage({
t: "setModelEditorViewState", t: "setModelEditorViewState",
viewState: { viewState: {
extensionPack: this.extensionPack, extensionPack: this.extensionPack,
showFlowGeneration: showFlowGeneration(), showFlowGeneration: this.modelConfig.flowGeneration,
showLlmButton, showLlmButton,
showMultipleModels: showMultipleModels(), showMultipleModels: this.modelConfig.showMultipleModels,
mode: this.mode, mode: this.mode,
}, },
}); });
@@ -359,7 +372,10 @@ export class ModelEditorView extends AbstractWebview<
this.cliServer, this.cliServer,
this.app.logger, this.app.logger,
); );
this.modelingStore.setModeledMethods(this.databaseItem, modeledMethods); this.modelingStore.setModeledMethods(
this.databaseItem,
convertToLegacyModeledMethods(modeledMethods),
);
} catch (e: unknown) { } catch (e: unknown) {
void showAndLogErrorMessage( void showAndLogErrorMessage(
this.app.logger, this.app.logger,
@@ -481,6 +497,7 @@ export class ModelEditorView extends AbstractWebview<
const modelFile = await pickExtensionPack( const modelFile = await pickExtensionPack(
this.cliServer, this.cliServer,
addedDatabase, addedDatabase,
this.modelConfig,
this.app.logger, this.app.logger,
progress, progress,
3, 3,
@@ -493,6 +510,7 @@ export class ModelEditorView extends AbstractWebview<
this.app, this.app,
this.modelingStore, this.modelingStore,
this.viewTracker, this.viewTracker,
this.modelConfig,
this.databaseManager, this.databaseManager,
this.cliServer, this.cliServer,
this.queryRunner, this.queryRunner,
@@ -614,6 +632,14 @@ export class ModelEditorView extends AbstractWebview<
); );
} }
private registerToModelConfigEvents() {
this.push(
this.modelConfig.onDidChangeConfiguration(() => {
void this.setViewState();
}),
);
}
private addModeledMethods(modeledMethods: Record<string, ModeledMethod>) { private addModeledMethods(modeledMethods: Record<string, ModeledMethod>) {
this.modelingStore.addModeledMethods(this.databaseItem, modeledMethods); this.modelingStore.addModeledMethods(this.databaseItem, modeledMethods);

View File

@@ -10,17 +10,12 @@ import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders";
import { load as loadYaml } from "js-yaml"; import { load as loadYaml } from "js-yaml";
import { CodeQLCliServer } from "../codeql-cli/cli"; import { CodeQLCliServer } from "../codeql-cli/cli";
import { pathsEqual } from "../common/files"; import { pathsEqual } from "../common/files";
import {
convertFromLegacyModeledMethods,
convertFromLegacyModeledMethodsFiles,
convertToLegacyModeledMethods,
} from "./modeled-methods-legacy";
export async function saveModeledMethods( export async function saveModeledMethods(
extensionPack: ExtensionPack, extensionPack: ExtensionPack,
language: string, language: string,
methods: Method[], methods: Method[],
modeledMethods: Record<string, ModeledMethod>, modeledMethods: Record<string, ModeledMethod[]>,
mode: Mode, mode: Mode,
cliServer: CodeQLCliServer, cliServer: CodeQLCliServer,
logger: NotificationLogger, logger: NotificationLogger,
@@ -34,8 +29,8 @@ export async function saveModeledMethods(
const yamls = createDataExtensionYamls( const yamls = createDataExtensionYamls(
language, language,
methods, methods,
convertFromLegacyModeledMethods(modeledMethods), modeledMethods,
convertFromLegacyModeledMethodsFiles(existingModeledMethods), existingModeledMethods,
mode, mode,
); );
@@ -50,12 +45,12 @@ async function loadModeledMethodFiles(
extensionPack: ExtensionPack, extensionPack: ExtensionPack,
cliServer: CodeQLCliServer, cliServer: CodeQLCliServer,
logger: NotificationLogger, logger: NotificationLogger,
): Promise<Record<string, Record<string, ModeledMethod>>> { ): Promise<Record<string, Record<string, ModeledMethod[]>>> {
const modelFiles = await listModelFiles(extensionPack.path, cliServer); const modelFiles = await listModelFiles(extensionPack.path, cliServer);
const modeledMethodsByFile: Record< const modeledMethodsByFile: Record<
string, string,
Record<string, ModeledMethod> Record<string, ModeledMethod[]>
> = {}; > = {};
for (const modelFile of modelFiles) { for (const modelFile of modelFiles) {
@@ -73,8 +68,7 @@ async function loadModeledMethodFiles(
); );
continue; continue;
} }
modeledMethodsByFile[modelFile] = modeledMethodsByFile[modelFile] = modeledMethods;
convertToLegacyModeledMethods(modeledMethods);
} }
return modeledMethodsByFile; return modeledMethodsByFile;
@@ -84,8 +78,8 @@ export async function loadModeledMethods(
extensionPack: ExtensionPack, extensionPack: ExtensionPack,
cliServer: CodeQLCliServer, cliServer: CodeQLCliServer,
logger: NotificationLogger, logger: NotificationLogger,
): Promise<Record<string, ModeledMethod>> { ): Promise<Record<string, ModeledMethod[]>> {
const existingModeledMethods: Record<string, ModeledMethod> = {}; const existingModeledMethods: Record<string, ModeledMethod[]> = {};
const modeledMethodsByFile = await loadModeledMethodFiles( const modeledMethodsByFile = await loadModeledMethodFiles(
extensionPack, extensionPack,
@@ -94,7 +88,11 @@ export async function loadModeledMethods(
); );
for (const modeledMethods of Object.values(modeledMethodsByFile)) { for (const modeledMethods of Object.values(modeledMethodsByFile)) {
for (const [key, value] of Object.entries(modeledMethods)) { for (const [key, value] of Object.entries(modeledMethods)) {
existingModeledMethods[key] = value; if (!(key in existingModeledMethods)) {
existingModeledMethods[key] = [];
}
existingModeledMethods[key].push(...value);
} }
} }

View File

@@ -21,13 +21,3 @@ export function convertToLegacyModeledMethods(
}), }),
); );
} }
export function convertFromLegacyModeledMethodsFiles(
modeledMethods: Record<string, Record<string, ModeledMethod>>,
): Record<string, Record<string, ModeledMethod[]>> {
return Object.fromEntries(
Object.entries(modeledMethods).map(([filename, modeledMethods]) => {
return [filename, convertFromLegacyModeledMethods(modeledMethods)];
}),
);
}

View File

@@ -169,6 +169,23 @@ export class ModelingStore extends DisposableObject {
return this.state.size > 0; return this.state.size > 0;
} }
/**
* Returns the methods for the given database item and method signatures.
* If the `methodSignatures` argument is not provided or is undefined, returns all methods.
*/
public getMethods(
dbItem: DatabaseItem,
methodSignatures?: string[],
): Method[] {
const methods = this.getState(dbItem).methods;
if (!methodSignatures) {
return methods;
}
return methods.filter((method) =>
methodSignatures.includes(method.signature),
);
}
public setMethods(dbItem: DatabaseItem, methods: Method[]) { public setMethods(dbItem: DatabaseItem, methods: Method[]) {
const dbState = this.getState(dbItem); const dbState = this.getState(dbItem);
const dbUri = dbItem.databaseUri.toString(); const dbUri = dbItem.databaseUri.toString();
@@ -197,6 +214,25 @@ export class ModelingStore extends DisposableObject {
}); });
} }
/**
* Returns the modeled methods for the given database item and method signatures.
* If the `methodSignatures` argument is not provided or is undefined, returns all modeled methods.
*/
public getModeledMethods(
dbItem: DatabaseItem,
methodSignatures?: string[],
): Record<string, ModeledMethod> {
const modeledMethods = this.getState(dbItem).modeledMethods;
if (!methodSignatures) {
return modeledMethods;
}
return Object.fromEntries(
Object.entries(modeledMethods).filter(([key]) =>
methodSignatures.includes(key),
),
);
}
public addModeledMethods( public addModeledMethods(
dbItem: DatabaseItem, dbItem: DatabaseItem,
methods: Record<string, ModeledMethod>, methods: Record<string, ModeledMethod>,

View File

@@ -3,13 +3,13 @@ import { ModeledMethod } from "../modeled-method";
export type ModelingStatus = "unmodeled" | "unsaved" | "saved"; export type ModelingStatus = "unmodeled" | "unsaved" | "saved";
export function getModelingStatus( export function getModelingStatus(
modeledMethod: ModeledMethod | undefined, modeledMethods: ModeledMethod[],
methodIsUnsaved: boolean, methodIsUnsaved: boolean,
): ModelingStatus { ): ModelingStatus {
if (modeledMethod) { if (modeledMethods.length > 0) {
if (methodIsUnsaved) { if (methodIsUnsaved) {
return "unsaved"; return "unsaved";
} else if (modeledMethod.type !== "none") { } else if (modeledMethods.some((m) => m.type !== "none")) {
return "saved"; return "saved";
} }
} }

View File

@@ -4,6 +4,7 @@ import { Meta, StoryFn } from "@storybook/react";
import { MethodModeling as MethodModelingComponent } from "../../view/method-modeling/MethodModeling"; import { MethodModeling as MethodModelingComponent } from "../../view/method-modeling/MethodModeling";
import { createMethod } from "../../../test/factories/model-editor/method-factories"; import { createMethod } from "../../../test/factories/model-editor/method-factories";
import { createModeledMethod } from "../../../test/factories/model-editor/modeled-method-factories";
export default { export default {
title: "Method Modeling/Method Modeling", title: "Method Modeling/Method Modeling",
component: MethodModelingComponent, component: MethodModelingComponent,
@@ -18,18 +19,53 @@ const method = createMethod();
export const MethodUnmodeled = Template.bind({}); export const MethodUnmodeled = Template.bind({});
MethodUnmodeled.args = { MethodUnmodeled.args = {
method, method,
modeledMethods: [],
modelingStatus: "unmodeled", modelingStatus: "unmodeled",
}; };
export const MethodModeled = Template.bind({}); export const MethodModeled = Template.bind({});
MethodModeled.args = { MethodModeled.args = {
method, method,
modeledMethods: [],
modelingStatus: "unsaved", modelingStatus: "unsaved",
}; };
export const MethodSaved = Template.bind({}); export const MethodSaved = Template.bind({});
MethodSaved.args = { MethodSaved.args = {
method, method,
modeledMethods: [],
modelingStatus: "saved",
};
export const MultipleModelingsUnmodeled = Template.bind({});
MultipleModelingsUnmodeled.args = {
method,
modeledMethods: [],
showMultipleModels: true,
modelingStatus: "saved",
};
export const MultipleModelingsModeledSingle = Template.bind({});
MultipleModelingsModeledSingle.args = {
method,
modeledMethods: [createModeledMethod(method)],
showMultipleModels: true,
modelingStatus: "saved",
};
export const MultipleModelingsModeledMultiple = Template.bind({});
MultipleModelingsModeledMultiple.args = {
method,
modeledMethods: [
createModeledMethod(method),
createModeledMethod({
...method,
type: "source",
input: "",
output: "ReturnValue",
kind: "remote",
}),
],
showMultipleModels: true,
modelingStatus: "saved", modelingStatus: "saved",
}; };

View File

@@ -7,6 +7,9 @@ import { CallClassification, Method } from "../../model-editor/method";
import { ModeledMethod } from "../../model-editor/modeled-method"; import { ModeledMethod } from "../../model-editor/modeled-method";
import { VSCodeDataGrid } from "@vscode/webview-ui-toolkit/react"; import { VSCodeDataGrid } from "@vscode/webview-ui-toolkit/react";
import { GRID_TEMPLATE_COLUMNS } from "../../view/model-editor/ModeledMethodDataGrid"; import { GRID_TEMPLATE_COLUMNS } from "../../view/model-editor/ModeledMethodDataGrid";
import { ModelEditorViewState } from "../../model-editor/shared/view-state";
import { createMockExtensionPack } from "../../../test/factories/model-editor/extension-pack";
import { Mode } from "../../model-editor/shared/mode";
export default { export default {
title: "CodeQL Model Editor/Method Row", title: "CodeQL Model Editor/Method Row",
@@ -66,51 +69,78 @@ const modeledMethod: ModeledMethod = {
methodParameters: "()", methodParameters: "()",
}; };
const viewState: ModelEditorViewState = {
extensionPack: createMockExtensionPack(),
showFlowGeneration: true,
showLlmButton: true,
showMultipleModels: true,
mode: Mode.Application,
};
export const Unmodeled = Template.bind({}); export const Unmodeled = Template.bind({});
Unmodeled.args = { Unmodeled.args = {
method, method,
modeledMethod: undefined, modeledMethods: [],
methodCanBeModeled: true, methodCanBeModeled: true,
viewState,
}; };
export const Source = Template.bind({}); export const Source = Template.bind({});
Source.args = { Source.args = {
method, method,
modeledMethod: { ...modeledMethod, type: "source" }, modeledMethods: [{ ...modeledMethod, type: "source" }],
methodCanBeModeled: true, methodCanBeModeled: true,
viewState,
}; };
export const Sink = Template.bind({}); export const Sink = Template.bind({});
Sink.args = { Sink.args = {
method, method,
modeledMethod: { ...modeledMethod, type: "sink" }, modeledMethods: [{ ...modeledMethod, type: "sink" }],
methodCanBeModeled: true, methodCanBeModeled: true,
viewState,
}; };
export const Summary = Template.bind({}); export const Summary = Template.bind({});
Summary.args = { Summary.args = {
method, method,
modeledMethod: { ...modeledMethod, type: "summary" }, modeledMethods: [{ ...modeledMethod, type: "summary" }],
methodCanBeModeled: true, methodCanBeModeled: true,
viewState,
}; };
export const Neutral = Template.bind({}); export const Neutral = Template.bind({});
Neutral.args = { Neutral.args = {
method, method,
modeledMethod: { ...modeledMethod, type: "neutral" }, modeledMethods: [{ ...modeledMethod, type: "neutral" }],
methodCanBeModeled: true, methodCanBeModeled: true,
viewState,
}; };
export const AlreadyModeled = Template.bind({}); export const AlreadyModeled = Template.bind({});
AlreadyModeled.args = { AlreadyModeled.args = {
method: { ...method, supported: true }, method: { ...method, supported: true },
modeledMethod: undefined, modeledMethods: [],
viewState,
}; };
export const ModelingInProgress = Template.bind({}); export const ModelingInProgress = Template.bind({});
ModelingInProgress.args = { ModelingInProgress.args = {
method, method,
modeledMethod, modeledMethods: [modeledMethod],
modelingInProgress: true, modelingInProgress: true,
methodCanBeModeled: true, methodCanBeModeled: true,
viewState,
};
export const MultipleModelings = Template.bind({});
MultipleModelings.args = {
method,
modeledMethods: [
{ ...modeledMethod, type: "source" },
{ ...modeledMethod, type: "sink" },
{ ...modeledMethod },
],
methodCanBeModeled: true,
viewState,
}; };

View File

@@ -4,7 +4,7 @@ import classNames from "classnames";
type Props = { type Props = {
name: string; name: string;
label: string; label?: string;
className?: string; className?: string;
slot?: string; slot?: string;
}; };

View File

@@ -5,9 +5,9 @@ import { ModelingStatusIndicator } from "../model-editor/ModelingStatusIndicator
import { Method } from "../../model-editor/method"; import { Method } from "../../model-editor/method";
import { MethodName } from "../model-editor/MethodName"; import { MethodName } from "../model-editor/MethodName";
import { ModeledMethod } from "../../model-editor/modeled-method"; import { ModeledMethod } from "../../model-editor/modeled-method";
import { MethodModelingInputs } from "./MethodModelingInputs";
import { VSCodeTag } from "@vscode/webview-ui-toolkit/react"; import { VSCodeTag } from "@vscode/webview-ui-toolkit/react";
import { ReviewInEditorButton } from "./ReviewInEditorButton"; import { ReviewInEditorButton } from "./ReviewInEditorButton";
import { ModeledMethodsPanel } from "./ModeledMethodsPanel";
const Container = styled.div` const Container = styled.div`
padding-top: 0.5rem; padding-top: 0.5rem;
@@ -38,10 +38,6 @@ const DependencyContainer = styled.div`
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
`; `;
const StyledMethodModelingInputs = styled(MethodModelingInputs)`
padding-bottom: 0.5rem;
`;
const StyledVSCodeTag = styled(VSCodeTag)<{ visible: boolean }>` const StyledVSCodeTag = styled(VSCodeTag)<{ visible: boolean }>`
visibility: ${(props) => (props.visible ? "visible" : "hidden")}; visibility: ${(props) => (props.visible ? "visible" : "hidden")};
`; `;
@@ -55,15 +51,16 @@ const UnsavedTag = ({ modelingStatus }: { modelingStatus: ModelingStatus }) => (
export type MethodModelingProps = { export type MethodModelingProps = {
modelingStatus: ModelingStatus; modelingStatus: ModelingStatus;
method: Method; method: Method;
modeledMethod: ModeledMethod | undefined; modeledMethods: ModeledMethod[];
showMultipleModels?: boolean; showMultipleModels?: boolean;
onChange: (modeledMethod: ModeledMethod) => void; onChange: (modeledMethod: ModeledMethod) => void;
}; };
export const MethodModeling = ({ export const MethodModeling = ({
modelingStatus, modelingStatus,
modeledMethod, modeledMethods,
method, method,
showMultipleModels = false,
onChange, onChange,
}: MethodModelingProps): JSX.Element => { }: MethodModelingProps): JSX.Element => {
return ( return (
@@ -77,9 +74,10 @@ export const MethodModeling = ({
<ModelingStatusIndicator status={modelingStatus} /> <ModelingStatusIndicator status={modelingStatus} />
<MethodName {...method} /> <MethodName {...method} />
</DependencyContainer> </DependencyContainer>
<StyledMethodModelingInputs <ModeledMethodsPanel
method={method} method={method}
modeledMethod={modeledMethod} modeledMethods={modeledMethods}
showMultipleModels={showMultipleModels}
onChange={onChange} onChange={onChange}
/> />
<ReviewInEditorButton method={method} /> <ReviewInEditorButton method={method} />

View File

@@ -30,7 +30,8 @@ export function MethodModelingView({ initialViewState }: Props): JSX.Element {
const [isMethodModified, setIsMethodModified] = useState<boolean>(false); const [isMethodModified, setIsMethodModified] = useState<boolean>(false);
const modelingStatus = useMemo( const modelingStatus = useMemo(
() => getModelingStatus(modeledMethod, isMethodModified), () =>
getModelingStatus(modeledMethod ? [modeledMethod] : [], isMethodModified),
[modeledMethod, isMethodModified], [modeledMethod, isMethodModified],
); );
@@ -94,7 +95,7 @@ export function MethodModelingView({ initialViewState }: Props): JSX.Element {
<MethodModeling <MethodModeling
modelingStatus={modelingStatus} modelingStatus={modelingStatus}
method={method} method={method}
modeledMethod={modeledMethod} modeledMethods={modeledMethod ? [modeledMethod] : []}
showMultipleModels={viewState?.showMultipleModels} showMultipleModels={viewState?.showMultipleModels}
onChange={onChange} onChange={onChange}
/> />

View File

@@ -0,0 +1,44 @@
import * as React from "react";
import { ModeledMethod } from "../../model-editor/modeled-method";
import { MethodModelingInputs } from "./MethodModelingInputs";
import { Method } from "../../model-editor/method";
import { styled } from "styled-components";
import { MultipleModeledMethodsPanel } from "./MultipleModeledMethodsPanel";
export type ModeledMethodsPanelProps = {
method: Method;
modeledMethods: ModeledMethod[];
showMultipleModels: boolean;
onChange: (modeledMethod: ModeledMethod) => void;
};
const SingleMethodModelingInputs = styled(MethodModelingInputs)`
padding-bottom: 0.5rem;
`;
export const ModeledMethodsPanel = ({
method,
modeledMethods,
showMultipleModels,
onChange,
}: ModeledMethodsPanelProps) => {
if (!showMultipleModels) {
return (
<SingleMethodModelingInputs
method={method}
modeledMethod={
modeledMethods.length > 0 ? modeledMethods[0] : undefined
}
onChange={onChange}
/>
);
}
return (
<MultipleModeledMethodsPanel
method={method}
modeledMethods={modeledMethods}
onChange={onChange}
/>
);
};

View File

@@ -0,0 +1,95 @@
import * as React from "react";
import { useCallback, useState } from "react";
import { Method } from "../../model-editor/method";
import { ModeledMethod } from "../../model-editor/modeled-method";
import { styled } from "styled-components";
import { MethodModelingInputs } from "./MethodModelingInputs";
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
import { Codicon } from "../common";
export type MultipleModeledMethodsPanelProps = {
method: Method;
modeledMethods: ModeledMethod[];
onChange: (modeledMethod: ModeledMethod) => void;
};
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-bottom: 0.5rem;
border-bottom: 0.05rem solid var(--vscode-panelSection-border);
`;
const Footer = styled.div`
display: flex;
flex-direction: row;
`;
const PaginationActions = styled.div`
display: flex;
flex-direction: row;
gap: 0.5rem;
`;
export const MultipleModeledMethodsPanel = ({
method,
modeledMethods,
onChange,
}: MultipleModeledMethodsPanelProps) => {
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const handlePreviousClick = useCallback(() => {
setSelectedIndex((previousIndex) => previousIndex - 1);
}, []);
const handleNextClick = useCallback(() => {
setSelectedIndex((previousIndex) => previousIndex + 1);
}, []);
return (
<Container>
{modeledMethods.length > 0 ? (
<MethodModelingInputs
method={method}
modeledMethod={modeledMethods[selectedIndex]}
onChange={onChange}
/>
) : (
<MethodModelingInputs
method={method}
modeledMethod={undefined}
onChange={onChange}
/>
)}
<Footer>
<PaginationActions>
<VSCodeButton
appearance="icon"
aria-label="Previous modeling"
onClick={handlePreviousClick}
disabled={modeledMethods.length < 2 || selectedIndex === 0}
>
<Codicon name="chevron-left" />
</VSCodeButton>
{modeledMethods.length > 1 && (
<div>
{selectedIndex + 1}/{modeledMethods.length}
</div>
)}
<VSCodeButton
appearance="icon"
aria-label="Next modeling"
onClick={handleNextClick}
disabled={
modeledMethods.length < 2 ||
selectedIndex === modeledMethods.length - 1
}
>
<Codicon name="chevron-right" />
</VSCodeButton>
</PaginationActions>
</Footer>
</Container>
);
};

View File

@@ -16,7 +16,7 @@ describe(MethodModeling.name, () => {
render({ render({
modelingStatus: "saved", modelingStatus: "saved",
method, method,
modeledMethod, modeledMethods: [modeledMethod],
onChange, onChange,
}); });

View File

@@ -0,0 +1,74 @@
import * as React from "react";
import { render as reactRender, screen } from "@testing-library/react";
import { createMethod } from "../../../../test/factories/model-editor/method-factories";
import { createModeledMethod } from "../../../../test/factories/model-editor/modeled-method-factories";
import {
ModeledMethodsPanel,
ModeledMethodsPanelProps,
} from "../ModeledMethodsPanel";
describe(ModeledMethodsPanel.name, () => {
const render = (props: ModeledMethodsPanelProps) =>
reactRender(<ModeledMethodsPanel {...props} />);
const method = createMethod();
const modeledMethods = [createModeledMethod(), createModeledMethod()];
const onChange = jest.fn();
describe("when show multiple models is disabled", () => {
const showMultipleModels = false;
it("renders the method modeling inputs", () => {
render({
method,
modeledMethods,
onChange,
showMultipleModels,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
});
it("does not render the pagination", () => {
render({
method,
modeledMethods,
onChange,
showMultipleModels,
});
expect(
screen.queryByLabelText("Previous modeling"),
).not.toBeInTheDocument();
expect(screen.queryByLabelText("Next modeling")).not.toBeInTheDocument();
});
});
describe("when show multiple models is enabled", () => {
const showMultipleModels = true;
it("renders the method modeling inputs once", () => {
render({
method,
modeledMethods,
onChange,
showMultipleModels,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
});
it("renders the pagination", () => {
render({
method,
modeledMethods,
onChange,
showMultipleModels,
});
expect(screen.getByLabelText("Previous modeling")).toBeInTheDocument();
expect(screen.getByLabelText("Next modeling")).toBeInTheDocument();
expect(screen.getByText("1/2")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,312 @@
import * as React from "react";
import { render as reactRender, screen, waitFor } from "@testing-library/react";
import { createMethod } from "../../../../test/factories/model-editor/method-factories";
import { createModeledMethod } from "../../../../test/factories/model-editor/modeled-method-factories";
import {
MultipleModeledMethodsPanel,
MultipleModeledMethodsPanelProps,
} from "../MultipleModeledMethodsPanel";
import userEvent from "@testing-library/user-event";
import { ModeledMethod } from "../../../model-editor/modeled-method";
describe(MultipleModeledMethodsPanel.name, () => {
const render = (props: MultipleModeledMethodsPanelProps) =>
reactRender(<MultipleModeledMethodsPanel {...props} />);
const method = createMethod();
const onChange = jest.fn();
describe("with no modeled methods", () => {
const modeledMethods: ModeledMethod[] = [];
it("renders the method modeling inputs once", () => {
render({
method,
modeledMethods,
onChange,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("none");
});
it("disables all pagination", () => {
render({
method,
modeledMethods,
onChange,
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeDisabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeDisabled();
expect(screen.queryByText("0/0")).not.toBeInTheDocument();
expect(screen.queryByText("1/0")).not.toBeInTheDocument();
});
});
describe("with one modeled method", () => {
const modeledMethods = [
createModeledMethod({
...method,
type: "sink",
input: "Argument[this]",
output: "",
kind: "path-injection",
}),
];
it("renders the method modeling inputs once", () => {
render({
method,
modeledMethods,
onChange,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("sink");
});
it("disables all pagination", () => {
render({
method,
modeledMethods,
onChange,
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeDisabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeDisabled();
expect(screen.queryByText("1/1")).not.toBeInTheDocument();
});
});
describe("with two modeled methods", () => {
const modeledMethods = [
createModeledMethod({
...method,
type: "sink",
input: "Argument[this]",
output: "",
kind: "path-injection",
}),
createModeledMethod({
...method,
type: "source",
input: "",
output: "ReturnValue",
kind: "remote",
}),
];
it("renders the method modeling inputs once", () => {
render({
method,
modeledMethods,
onChange,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("sink");
});
it("renders the pagination", () => {
render({
method,
modeledMethods,
onChange,
});
expect(screen.getByLabelText("Previous modeling")).toBeInTheDocument();
expect(screen.getByLabelText("Next modeling")).toBeInTheDocument();
expect(screen.getByText("1/2")).toBeInTheDocument();
});
it("disables the correct pagination", async () => {
render({
method,
modeledMethods,
onChange,
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeDisabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeEnabled();
});
it("can use the pagination", async () => {
render({
method,
modeledMethods,
onChange,
});
await userEvent.click(screen.getByLabelText("Next modeling"));
await waitFor(() => {
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeDisabled();
expect(screen.getByText("2/2")).toBeInTheDocument();
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("source");
});
});
describe("with three modeled methods", () => {
const modeledMethods = [
createModeledMethod({
...method,
type: "sink",
input: "Argument[this]",
output: "",
kind: "path-injection",
}),
createModeledMethod({
...method,
type: "source",
input: "",
output: "ReturnValue",
kind: "remote",
}),
createModeledMethod({
...method,
type: "source",
input: "",
output: "ReturnValue",
kind: "local",
}),
];
it("can use the pagination", async () => {
render({
method,
modeledMethods,
onChange,
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeDisabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeEnabled();
expect(screen.getByText("1/3")).toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Next modeling"));
await waitFor(() => {
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeEnabled();
expect(screen.getByText("2/3")).toBeInTheDocument();
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("source");
await userEvent.click(screen.getByLabelText("Next modeling"));
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeDisabled();
expect(screen.getByText("3/3")).toBeInTheDocument();
expect(
screen.getByRole("combobox", {
name: "Kind",
}),
).toHaveValue("local");
await userEvent.click(screen.getByLabelText("Previous modeling"));
await waitFor(() => {
expect(
screen
.getByLabelText("Next modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeEnabled();
expect(screen.getByText("2/3")).toBeInTheDocument();
expect(
screen.getByRole("combobox", {
name: "Kind",
}),
).toHaveValue("remote");
});
});
});

View File

@@ -78,10 +78,7 @@ export type LibraryRowProps = {
hideModeledMethods: boolean; hideModeledMethods: boolean;
revealedMethodSignature: string | null; revealedMethodSignature: string | null;
onChange: (modeledMethod: ModeledMethod) => void; onChange: (modeledMethod: ModeledMethod) => void;
onSaveModelClick: ( onSaveModelClick: (methodSignatures: string[]) => void;
methods: Method[],
modeledMethods: Record<string, ModeledMethod>,
) => void;
onGenerateFromLlmClick: ( onGenerateFromLlmClick: (
dependencyName: string, dependencyName: string,
methods: Method[], methods: Method[],
@@ -165,11 +162,11 @@ export const LibraryRow = ({
const handleSave = useCallback( const handleSave = useCallback(
async (e: React.MouseEvent) => { async (e: React.MouseEvent) => {
onSaveModelClick(methods, modeledMethods); onSaveModelClick(methods.map((m) => m.signature));
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
}, },
[methods, modeledMethods, onSaveModelClick], [methods, onSaveModelClick],
); );
const hasUnsavedChanges = useMemo(() => { const hasUnsavedChanges = useMemo(() => {
@@ -235,7 +232,7 @@ export const LibraryRow = ({
modeledMethods={modeledMethods} modeledMethods={modeledMethods}
modifiedSignatures={modifiedSignatures} modifiedSignatures={modifiedSignatures}
inProgressMethods={inProgressMethods} inProgressMethods={inProgressMethods}
mode={viewState.mode} viewState={viewState}
hideModeledMethods={hideModeledMethods} hideModeledMethods={hideModeledMethods}
revealedMethodSignature={revealedMethodSignature} revealedMethodSignature={revealedMethodSignature}
onChange={onChange} onChange={onChange}

View File

@@ -21,8 +21,16 @@ import { MethodName } from "./MethodName";
import { ModelTypeDropdown } from "./ModelTypeDropdown"; import { ModelTypeDropdown } from "./ModelTypeDropdown";
import { ModelInputDropdown } from "./ModelInputDropdown"; import { ModelInputDropdown } from "./ModelInputDropdown";
import { ModelOutputDropdown } from "./ModelOutputDropdown"; import { ModelOutputDropdown } from "./ModelOutputDropdown";
import { ModelEditorViewState } from "../../model-editor/shared/view-state";
const ApiOrMethodCell = styled(VSCodeDataGridCell)` const MultiModelColumn = styled(VSCodeDataGridCell)`
display: flex;
flex-direction: column;
gap: 0.5em;
`;
const ApiOrMethodRow = styled.div`
min-height: calc(var(--input-height) * 1px);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -55,10 +63,10 @@ const DataGridRow = styled(VSCodeDataGridRow)<{ focused?: boolean }>`
export type MethodRowProps = { export type MethodRowProps = {
method: Method; method: Method;
methodCanBeModeled: boolean; methodCanBeModeled: boolean;
modeledMethod: ModeledMethod | undefined; modeledMethods: ModeledMethod[];
methodIsUnsaved: boolean; methodIsUnsaved: boolean;
modelingInProgress: boolean; modelingInProgress: boolean;
mode: Mode; viewState: ModelEditorViewState;
revealedMethodSignature: string | null; revealedMethodSignature: string | null;
onChange: (modeledMethod: ModeledMethod) => void; onChange: (modeledMethod: ModeledMethod) => void;
}; };
@@ -88,19 +96,23 @@ const ModelableMethodRow = forwardRef<HTMLElement | undefined, MethodRowProps>(
(props, ref) => { (props, ref) => {
const { const {
method, method,
modeledMethod, modeledMethods: modeledMethodsProp,
methodIsUnsaved, methodIsUnsaved,
mode, viewState,
revealedMethodSignature, revealedMethodSignature,
onChange, onChange,
} = props; } = props;
const modeledMethods = viewState.showMultipleModels
? modeledMethodsProp
: modeledMethodsProp.slice(0, 1);
const jumpToUsage = useCallback( const jumpToUsage = useCallback(
() => sendJumpToUsageMessage(method), () => sendJumpToUsageMessage(method),
[method], [method],
); );
const modelingStatus = getModelingStatus(modeledMethod, methodIsUnsaved); const modelingStatus = getModelingStatus(modeledMethods, methodIsUnsaved);
return ( return (
<DataGridRow <DataGridRow
@@ -108,18 +120,20 @@ const ModelableMethodRow = forwardRef<HTMLElement | undefined, MethodRowProps>(
ref={ref} ref={ref}
focused={revealedMethodSignature === method.signature} focused={revealedMethodSignature === method.signature}
> >
<ApiOrMethodCell gridColumn={1}> <VSCodeDataGridCell gridColumn={1}>
<ModelingStatusIndicator status={modelingStatus} /> <ApiOrMethodRow>
<MethodClassifications method={method} /> <ModelingStatusIndicator status={modelingStatus} />
<MethodName {...props.method} /> <MethodClassifications method={method} />
{mode === Mode.Application && ( <MethodName {...props.method} />
<UsagesButton onClick={jumpToUsage}> {viewState.mode === Mode.Application && (
{method.usages.length} <UsagesButton onClick={jumpToUsage}>
</UsagesButton> {method.usages.length}
)} </UsagesButton>
<ViewLink onClick={jumpToUsage}>View</ViewLink> )}
{props.modelingInProgress && <ProgressRing />} <ViewLink onClick={jumpToUsage}>View</ViewLink>
</ApiOrMethodCell> {props.modelingInProgress && <ProgressRing />}
</ApiOrMethodRow>
</VSCodeDataGridCell>
{props.modelingInProgress && ( {props.modelingInProgress && (
<> <>
<VSCodeDataGridCell gridColumn={2}> <VSCodeDataGridCell gridColumn={2}>
@@ -138,34 +152,46 @@ const ModelableMethodRow = forwardRef<HTMLElement | undefined, MethodRowProps>(
)} )}
{!props.modelingInProgress && ( {!props.modelingInProgress && (
<> <>
<VSCodeDataGridCell gridColumn={2}> <MultiModelColumn gridColumn={2}>
<ModelTypeDropdown {forEachModeledMethod(modeledMethods, (modeledMethod, index) => (
method={method} <ModelTypeDropdown
modeledMethod={modeledMethod} key={index}
onChange={onChange} method={method}
/> modeledMethod={modeledMethod}
</VSCodeDataGridCell> onChange={onChange}
<VSCodeDataGridCell gridColumn={3}> />
<ModelInputDropdown ))}
method={method} </MultiModelColumn>
modeledMethod={modeledMethod} <MultiModelColumn gridColumn={3}>
onChange={onChange} {forEachModeledMethod(modeledMethods, (modeledMethod, index) => (
/> <ModelInputDropdown
</VSCodeDataGridCell> key={index}
<VSCodeDataGridCell gridColumn={4}> method={method}
<ModelOutputDropdown modeledMethod={modeledMethod}
method={method} onChange={onChange}
modeledMethod={modeledMethod} />
onChange={onChange} ))}
/> </MultiModelColumn>
</VSCodeDataGridCell> <MultiModelColumn gridColumn={4}>
<VSCodeDataGridCell gridColumn={5}> {forEachModeledMethod(modeledMethods, (modeledMethod, index) => (
<ModelKindDropdown <ModelOutputDropdown
method={method} key={index}
modeledMethod={modeledMethod} method={method}
onChange={onChange} modeledMethod={modeledMethod}
/> onChange={onChange}
</VSCodeDataGridCell> />
))}
</MultiModelColumn>
<MultiModelColumn gridColumn={5}>
{forEachModeledMethod(modeledMethods, (modeledMethod, index) => (
<ModelKindDropdown
key={index}
method={method}
modeledMethod={modeledMethod}
onChange={onChange}
/>
))}
</MultiModelColumn>
</> </>
)} )}
</DataGridRow> </DataGridRow>
@@ -178,7 +204,7 @@ const UnmodelableMethodRow = forwardRef<
HTMLElement | undefined, HTMLElement | undefined,
MethodRowProps MethodRowProps
>((props, ref) => { >((props, ref) => {
const { method, mode, revealedMethodSignature } = props; const { method, viewState, revealedMethodSignature } = props;
const jumpToUsage = useCallback( const jumpToUsage = useCallback(
() => sendJumpToUsageMessage(method), () => sendJumpToUsageMessage(method),
@@ -191,17 +217,19 @@ const UnmodelableMethodRow = forwardRef<
ref={ref} ref={ref}
focused={revealedMethodSignature === method.signature} focused={revealedMethodSignature === method.signature}
> >
<ApiOrMethodCell gridColumn={1}> <VSCodeDataGridCell gridColumn={1}>
<ModelingStatusIndicator status="saved" /> <ApiOrMethodRow>
<MethodName {...props.method} /> <ModelingStatusIndicator status="saved" />
{mode === Mode.Application && ( <MethodName {...props.method} />
<UsagesButton onClick={jumpToUsage}> {viewState.mode === Mode.Application && (
{method.usages.length} <UsagesButton onClick={jumpToUsage}>
</UsagesButton> {method.usages.length}
)} </UsagesButton>
<ViewLink onClick={jumpToUsage}>View</ViewLink> )}
<MethodClassifications method={method} /> <ViewLink onClick={jumpToUsage}>View</ViewLink>
</ApiOrMethodCell> <MethodClassifications method={method} />
</ApiOrMethodRow>
</VSCodeDataGridCell>
<VSCodeDataGridCell gridColumn="span 4"> <VSCodeDataGridCell gridColumn="span 4">
Method already modeled Method already modeled
</VSCodeDataGridCell> </VSCodeDataGridCell>
@@ -218,3 +246,17 @@ function sendJumpToUsageMessage(method: Method) {
usage: method.usages[0], usage: method.usages[0],
}); });
} }
function forEachModeledMethod(
modeledMethods: ModeledMethod[],
renderer: (
modeledMethod: ModeledMethod | undefined,
index: number,
) => JSX.Element,
): JSX.Element | JSX.Element[] {
if (modeledMethods.length === 0) {
return renderer(undefined, 0);
} else {
return modeledMethods.map(renderer);
}
}

View File

@@ -142,7 +142,7 @@ export function ModelEditor({
); );
break; break;
case "revealMethod": case "revealMethod":
setRevealedMethodSignature(msg.method.signature); setRevealedMethodSignature(msg.methodSignature);
break; break;
default: default:
@@ -196,21 +196,15 @@ export function ModelEditor({
const onSaveAllClick = useCallback(() => { const onSaveAllClick = useCallback(() => {
vscode.postMessage({ vscode.postMessage({
t: "saveModeledMethods", t: "saveModeledMethods",
methods,
modeledMethods,
}); });
}, [methods, modeledMethods]); }, []);
const onSaveModelClick = useCallback( const onSaveModelClick = useCallback((methodSignatures: string[]) => {
(methods: Method[], modeledMethods: Record<string, ModeledMethod>) => { vscode.postMessage({
vscode.postMessage({ t: "saveModeledMethods",
t: "saveModeledMethods", methodSignatures,
methods, });
modeledMethods, }, []);
});
},
[],
);
const onGenerateFromSourceClick = useCallback(() => { const onGenerateFromSourceClick = useCallback(() => {
vscode.postMessage({ vscode.postMessage({

View File

@@ -8,10 +8,10 @@ import { MethodRow } from "./MethodRow";
import { Method } from "../../model-editor/method"; import { Method } from "../../model-editor/method";
import { ModeledMethod } from "../../model-editor/modeled-method"; import { ModeledMethod } from "../../model-editor/modeled-method";
import { useMemo } from "react"; import { useMemo } from "react";
import { Mode } from "../../model-editor/shared/mode";
import { sortMethods } from "../../model-editor/shared/sorting"; import { sortMethods } from "../../model-editor/shared/sorting";
import { InProgressMethods } from "../../model-editor/shared/in-progress-methods"; import { InProgressMethods } from "../../model-editor/shared/in-progress-methods";
import { HiddenMethodsRow } from "./HiddenMethodsRow"; import { HiddenMethodsRow } from "./HiddenMethodsRow";
import { ModelEditorViewState } from "../../model-editor/shared/view-state";
export const GRID_TEMPLATE_COLUMNS = "0.5fr 0.125fr 0.125fr 0.125fr 0.125fr"; export const GRID_TEMPLATE_COLUMNS = "0.5fr 0.125fr 0.125fr 0.125fr 0.125fr";
@@ -21,7 +21,7 @@ export type ModeledMethodDataGridProps = {
modeledMethods: Record<string, ModeledMethod>; modeledMethods: Record<string, ModeledMethod>;
modifiedSignatures: Set<string>; modifiedSignatures: Set<string>;
inProgressMethods: InProgressMethods; inProgressMethods: InProgressMethods;
mode: Mode; viewState: ModelEditorViewState;
hideModeledMethods: boolean; hideModeledMethods: boolean;
revealedMethodSignature: string | null; revealedMethodSignature: string | null;
onChange: (modeledMethod: ModeledMethod) => void; onChange: (modeledMethod: ModeledMethod) => void;
@@ -33,7 +33,7 @@ export const ModeledMethodDataGrid = ({
modeledMethods, modeledMethods,
modifiedSignatures, modifiedSignatures,
inProgressMethods, inProgressMethods,
mode, viewState,
hideModeledMethods, hideModeledMethods,
revealedMethodSignature, revealedMethodSignature,
onChange, onChange,
@@ -84,22 +84,25 @@ export const ModeledMethodDataGrid = ({
Kind Kind
</VSCodeDataGridCell> </VSCodeDataGridCell>
</VSCodeDataGridRow> </VSCodeDataGridRow>
{methodsWithModelability.map(({ method, methodCanBeModeled }) => ( {methodsWithModelability.map(({ method, methodCanBeModeled }) => {
<MethodRow const modeledMethod = modeledMethods[method.signature];
key={method.signature} return (
method={method} <MethodRow
methodCanBeModeled={methodCanBeModeled} key={method.signature}
modeledMethod={modeledMethods[method.signature]} method={method}
methodIsUnsaved={modifiedSignatures.has(method.signature)} methodCanBeModeled={methodCanBeModeled}
modelingInProgress={inProgressMethods.hasMethod( modeledMethods={modeledMethod ? [modeledMethod] : []}
packageName, methodIsUnsaved={modifiedSignatures.has(method.signature)}
method.signature, modelingInProgress={inProgressMethods.hasMethod(
)} packageName,
mode={mode} method.signature,
revealedMethodSignature={revealedMethodSignature} )}
onChange={onChange} viewState={viewState}
/> revealedMethodSignature={revealedMethodSignature}
))} onChange={onChange}
/>
);
})}
</> </>
)} )}
<HiddenMethodsRow <HiddenMethodsRow

View File

@@ -20,10 +20,7 @@ export type ModeledMethodsListProps = {
viewState: ModelEditorViewState; viewState: ModelEditorViewState;
hideModeledMethods: boolean; hideModeledMethods: boolean;
onChange: (modeledMethod: ModeledMethod) => void; onChange: (modeledMethod: ModeledMethod) => void;
onSaveModelClick: ( onSaveModelClick: (methodSignatures: string[]) => void;
methods: Method[],
modeledMethods: Record<string, ModeledMethod>,
) => void;
onGenerateFromLlmClick: ( onGenerateFromLlmClick: (
packageName: string, packageName: string,
methods: Method[], methods: Method[],

View File

@@ -9,6 +9,8 @@ import { Mode } from "../../../model-editor/shared/mode";
import { MethodRow, MethodRowProps } from "../MethodRow"; import { MethodRow, MethodRowProps } from "../MethodRow";
import { ModeledMethod } from "../../../model-editor/modeled-method"; import { ModeledMethod } from "../../../model-editor/modeled-method";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { ModelEditorViewState } from "../../../model-editor/shared/view-state";
import { createMockExtensionPack } from "../../../../test/factories/model-editor/extension-pack";
describe(MethodRow.name, () => { describe(MethodRow.name, () => {
const method = createMethod({ const method = createMethod({
@@ -31,16 +33,24 @@ describe(MethodRow.name, () => {
}; };
const onChange = jest.fn(); const onChange = jest.fn();
const viewState: ModelEditorViewState = {
mode: Mode.Application,
showFlowGeneration: false,
showLlmButton: false,
showMultipleModels: false,
extensionPack: createMockExtensionPack(),
};
const render = (props: Partial<MethodRowProps> = {}) => const render = (props: Partial<MethodRowProps> = {}) =>
reactRender( reactRender(
<MethodRow <MethodRow
method={method} method={method}
methodCanBeModeled={true} methodCanBeModeled={true}
modeledMethod={modeledMethod} modeledMethods={[modeledMethod]}
methodIsUnsaved={false} methodIsUnsaved={false}
modelingInProgress={false} modelingInProgress={false}
revealedMethodSignature={null} revealedMethodSignature={null}
mode={Mode.Application} viewState={viewState}
onChange={onChange} onChange={onChange}
{...props} {...props}
/>, />,
@@ -54,6 +64,14 @@ describe(MethodRow.name, () => {
expect(screen.queryByLabelText("Loading")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Loading")).not.toBeInTheDocument();
}); });
it("renders when there is no modeled method", () => {
render({ modeledMethods: [] });
expect(screen.queryAllByRole("combobox")).toHaveLength(4);
expect(screen.getByLabelText("Method not modeled")).toBeInTheDocument();
expect(screen.queryByLabelText("Loading")).not.toBeInTheDocument();
});
it("can change the kind", async () => { it("can change the kind", async () => {
render(); render();
@@ -110,7 +128,7 @@ describe(MethodRow.name, () => {
it("shows the modeling status indicator when unmodeled", () => { it("shows the modeling status indicator when unmodeled", () => {
render({ render({
modeledMethod: undefined, modeledMethods: [],
}); });
expect(screen.getByLabelText("Method not modeled")).toBeInTheDocument(); expect(screen.getByLabelText("Method not modeled")).toBeInTheDocument();
@@ -124,10 +142,48 @@ describe(MethodRow.name, () => {
expect(screen.getByLabelText("Loading")).toBeInTheDocument(); expect(screen.getByLabelText("Loading")).toBeInTheDocument();
}); });
it("can render multiple models", () => {
render({
modeledMethods: [
{ ...modeledMethod, type: "source" },
{ ...modeledMethod, type: "sink" },
{ ...modeledMethod, type: "summary" },
],
viewState: {
...viewState,
showMultipleModels: true,
},
});
const kindInputs = screen.getAllByRole("combobox", { name: "Model type" });
expect(kindInputs).toHaveLength(3);
expect(kindInputs[0]).toHaveValue("source");
expect(kindInputs[1]).toHaveValue("sink");
expect(kindInputs[2]).toHaveValue("summary");
});
it("renders only first model when showMultipleModels feature flag is disabled", () => {
render({
modeledMethods: [
{ ...modeledMethod, type: "source" },
{ ...modeledMethod, type: "sink" },
{ ...modeledMethod, type: "summary" },
],
viewState: {
...viewState,
showMultipleModels: false,
},
});
const kindInputs = screen.getAllByRole("combobox", { name: "Model type" });
expect(kindInputs.length).toBe(1);
expect(kindInputs[0]).toHaveValue("source");
});
it("renders an unmodelable method", () => { it("renders an unmodelable method", () => {
render({ render({
methodCanBeModeled: false, methodCanBeModeled: false,
modeledMethod: undefined, modeledMethods: [],
}); });
expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); expect(screen.queryByRole("combobox")).not.toBeInTheDocument();

View File

@@ -57,6 +57,7 @@ describe(ModelKindDropdown.name, () => {
// Changing the type to sink should update the supported kinds // Changing the type to sink should update the supported kinds
const updatedModeledMethod = createModeledMethod({ const updatedModeledMethod = createModeledMethod({
type: "sink", type: "sink",
kind: "local",
}); });
rerender( rerender(

View File

@@ -7,6 +7,8 @@ import {
ModeledMethodDataGrid, ModeledMethodDataGrid,
ModeledMethodDataGridProps, ModeledMethodDataGridProps,
} from "../ModeledMethodDataGrid"; } from "../ModeledMethodDataGrid";
import { ModelEditorViewState } from "../../../model-editor/shared/view-state";
import { createMockExtensionPack } from "../../../../test/factories/model-editor/extension-pack";
describe(ModeledMethodDataGrid.name, () => { describe(ModeledMethodDataGrid.name, () => {
const method1 = createMethod({ const method1 = createMethod({
@@ -41,6 +43,14 @@ describe(ModeledMethodDataGrid.name, () => {
}); });
const onChange = jest.fn(); const onChange = jest.fn();
const viewState: ModelEditorViewState = {
mode: Mode.Application,
showFlowGeneration: false,
showLlmButton: false,
showMultipleModels: false,
extensionPack: createMockExtensionPack(),
};
const render = (props: Partial<ModeledMethodDataGridProps> = {}) => const render = (props: Partial<ModeledMethodDataGridProps> = {}) =>
reactRender( reactRender(
<ModeledMethodDataGrid <ModeledMethodDataGrid
@@ -58,7 +68,7 @@ describe(ModeledMethodDataGrid.name, () => {
}} }}
modifiedSignatures={new Set([method1.signature])} modifiedSignatures={new Set([method1.signature])}
inProgressMethods={new InProgressMethods()} inProgressMethods={new InProgressMethods()}
mode={Mode.Application} viewState={viewState}
hideModeledMethods={false} hideModeledMethods={false}
revealedMethodSignature={null} revealedMethodSignature={null}
onChange={onChange} onChange={onChange}

View File

@@ -170,6 +170,14 @@ repository:
match: '\]' match: '\]'
name: punctuation.squarebracket.close.ql name: punctuation.squarebracket.close.ql
open-angle:
match: '<'
name: punctuation.anglebracket.open.ql
close-angle:
match: '>'
name: punctuation.anglebracket.close.ql
operator-or-punctuation: operator-or-punctuation:
patterns: patterns:
- include: '#relational-operator' - include: '#relational-operator'
@@ -186,6 +194,8 @@ repository:
- include: '#close-brace' - include: '#close-brace'
- include: '#open-bracket' - include: '#open-bracket'
- include: '#close-bracket' - include: '#close-bracket'
- include: '#open-angle'
- include: '#close-angle'
# Keywords # Keywords
dont-care: dont-care:
@@ -651,18 +661,36 @@ repository:
- include: '#non-context-sensitive' - include: '#non-context-sensitive'
- include: '#annotation' - include: '#annotation'
# The argument list of an instantiation, enclosed in angle brackets.
instantiation-args:
beginPattern: '#open-angle'
endPattern: '#close-angle'
name: meta.type.parameters.ql
patterns:
# Include `#instantiation-args` first so that `#open-angle` and `#close-angle` take precedence
# over `#relational-operator`.
- include: '#instantiation-args'
- include: '#non-context-sensitive'
- match: '(?#simple-id)'
name: entity.name.type.namespace.ql
# An `import` directive. Note that we parse the optional `as` clause as a separate top-level # An `import` directive. Note that we parse the optional `as` clause as a separate top-level
# directive, because otherwise it's too hard to figure out where the `import` directive ends. # directive, because otherwise it's too hard to figure out where the `import` directive ends.
import-directive: import-directive:
beginPattern: '#import' beginPattern: '#import'
# Ends with a simple-id that is not followed by a `.` or a `::`. This does not handle comments or # TextMate makes it tricky to tell whether an identifier that we encounter is part of the
# line breaks between the simple-id and the `.` or `::`. # `import` directive or whether it's the first token of the next module-level declaration.
end: '(?#simple-id) (?!\s*(\.|\:\:))' # To find the end of the import directive, we'll look for a zero-width match where the previous
endCaptures: # token is either an identifier (other than `import`) or a `>`, and the next token is not a `.`,
'0': # `<`, `,`, or `::`. This works for nearly all real-world `import` directives, but it will end the
name: entity.name.type.namespace.ql # `import` directive too early if there is a comment or line break between two components of the
# module expression.
end: '(?<!\bimport)(?<=(?:\>)|[A-Za-z0-9_]) (?!\s*(\.|\:\:|\,|(?#open-angle)))'
name: meta.block.import-directive.ql name: meta.block.import-directive.ql
patterns: patterns:
# Include `#instantiation-args` first so that `#open-angle` and `#close-angle` take precedence
# over `#relational-operator`.
- include: '#instantiation-args'
- include: '#non-context-sensitive' - include: '#non-context-sensitive'
- match: '(?#simple-id)' - match: '(?#simple-id)'
name: entity.name.type.namespace.ql name: entity.name.type.namespace.ql
@@ -703,7 +731,6 @@ repository:
- match: '(?#simple-id)|(?#at-lower-id)' - match: '(?#simple-id)|(?#at-lower-id)'
name: entity.name.type.ql name: entity.name.type.ql
# A `module` declaration, whether a module definition or an alias declaration. # A `module` declaration, whether a module definition or an alias declaration.
module-declaration: module-declaration:
# Starts with the `module` keyword. # Starts with the `module` keyword.

View File

@@ -13,7 +13,7 @@ export function createModeledMethod(
type: "sink", type: "sink",
input: "Argument[0]", input: "Argument[0]",
output: "", output: "",
kind: "jndi-injection", kind: "path-injection",
provenance: "manual", provenance: "manual",
...data, ...data,
}; };

View File

@@ -1,10 +1,4 @@
import { import { Uri, workspace, WorkspaceFolder } from "vscode";
ConfigurationScope,
Uri,
workspace,
WorkspaceConfiguration as VSCodeWorkspaceConfiguration,
WorkspaceFolder,
} from "vscode";
import { dump as dumpYaml, load as loadYaml } from "js-yaml"; import { dump as dumpYaml, load as loadYaml } from "js-yaml";
import { outputFile, readFile } from "fs-extra"; import { outputFile, readFile } from "fs-extra";
import { join } from "path"; import { join } from "path";
@@ -14,7 +8,8 @@ import { QlpacksInfo } from "../../../../src/codeql-cli/cli";
import { pickExtensionPack } from "../../../../src/model-editor/extension-pack-picker"; import { pickExtensionPack } from "../../../../src/model-editor/extension-pack-picker";
import { ExtensionPack } from "../../../../src/model-editor/shared/extension-pack"; import { ExtensionPack } from "../../../../src/model-editor/shared/extension-pack";
import { createMockLogger } from "../../../__mocks__/loggerMock"; import { createMockLogger } from "../../../__mocks__/loggerMock";
import { vscodeGetConfigurationMock } from "../../test-config"; import { ModelConfig } from "../../../../src/config";
import { mockedObject } from "../../utils/mocking.helpers";
describe("pickExtensionPack", () => { describe("pickExtensionPack", () => {
let tmpDir: string; let tmpDir: string;
@@ -32,6 +27,7 @@ describe("pickExtensionPack", () => {
let workspaceFoldersSpy: jest.SpyInstance; let workspaceFoldersSpy: jest.SpyInstance;
let additionalPacks: string[]; let additionalPacks: string[];
let workspaceFolder: WorkspaceFolder; let workspaceFolder: WorkspaceFolder;
let modelConfig: ModelConfig;
const logger = createMockLogger(); const logger = createMockLogger();
const maxStep = 4; const maxStep = 4;
@@ -67,41 +63,20 @@ describe("pickExtensionPack", () => {
workspaceFoldersSpy = jest workspaceFoldersSpy = jest
.spyOn(workspace, "workspaceFolders", "get") .spyOn(workspace, "workspaceFolders", "get")
.mockReturnValue([workspaceFolder]); .mockReturnValue([workspaceFolder]);
modelConfig = mockedObject<ModelConfig>({
getExtensionsDirectory: jest.fn().mockReturnValue(undefined),
});
}); });
it("selects an existing extension pack", async () => { it("selects an existing extension pack", async () => {
vscodeGetConfigurationMock.mockImplementation(
(
section?: string,
scope?: ConfigurationScope | null,
): VSCodeWorkspaceConfiguration => {
expect(section).toEqual("codeQL.model");
expect((scope as any)?.languageId).toEqual("java");
return {
get: (key: string) => {
expect(key).toEqual("extensionsDirectory");
return undefined;
},
has: (key: string) => {
return key === "extensionsDirectory";
},
inspect: () => {
throw new Error("inspect not implemented");
},
update: () => {
throw new Error("update not implemented");
},
};
},
);
const cliServer = mockCliServer(qlPacks); const cliServer = mockCliServer(qlPacks);
expect( expect(
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,
@@ -112,35 +87,10 @@ describe("pickExtensionPack", () => {
additionalPacks, additionalPacks,
true, true,
); );
expect(modelConfig.getExtensionsDirectory).toHaveBeenCalledWith("java");
}); });
it("creates a new extension pack using default extensions directory", async () => { it("creates a new extension pack using default extensions directory", async () => {
vscodeGetConfigurationMock.mockImplementation(
(
section?: string,
scope?: ConfigurationScope | null,
): VSCodeWorkspaceConfiguration => {
expect(section).toEqual("codeQL.model");
expect((scope as any)?.languageId).toEqual("java");
return {
get: (key: string) => {
expect(key).toEqual("extensionsDirectory");
return undefined;
},
has: (key: string) => {
return key === "extensionsDirectory";
},
inspect: () => {
throw new Error("inspect not implemented");
},
update: () => {
throw new Error("update not implemented");
},
};
},
);
const tmpDir = await dir({ const tmpDir = await dir({
unsafeCleanup: true, unsafeCleanup: true,
}); });
@@ -183,6 +133,7 @@ describe("pickExtensionPack", () => {
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,
@@ -199,6 +150,7 @@ describe("pickExtensionPack", () => {
dataExtensions: ["models/**/*.yml"], dataExtensions: ["models/**/*.yml"],
}); });
expect(cliServer.resolveQlpacks).toHaveBeenCalled(); expect(cliServer.resolveQlpacks).toHaveBeenCalled();
expect(modelConfig.getExtensionsDirectory).toHaveBeenCalledWith("java");
expect( expect(
loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")), loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")),
@@ -223,31 +175,9 @@ describe("pickExtensionPack", () => {
"my-custom-extensions-directory", "my-custom-extensions-directory",
); );
vscodeGetConfigurationMock.mockImplementation( const modelConfig = mockedObject<ModelConfig>({
( getExtensionsDirectory: jest.fn().mockReturnValue(configExtensionsDir),
section?: string, });
scope?: ConfigurationScope | null,
): VSCodeWorkspaceConfiguration => {
expect(section).toEqual("codeQL.model");
expect((scope as any)?.languageId).toEqual("java");
return {
get: (key: string) => {
expect(key).toEqual("extensionsDirectory");
return configExtensionsDir;
},
has: (key: string) => {
return key === "extensionsDirectory";
},
inspect: () => {
throw new Error("inspect not implemented");
},
update: () => {
throw new Error("update not implemented");
},
};
},
);
const newPackDir = join(configExtensionsDir, "vscode-codeql-java"); const newPackDir = join(configExtensionsDir, "vscode-codeql-java");
@@ -257,6 +187,7 @@ describe("pickExtensionPack", () => {
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,
@@ -273,6 +204,7 @@ describe("pickExtensionPack", () => {
dataExtensions: ["models/**/*.yml"], dataExtensions: ["models/**/*.yml"],
}); });
expect(cliServer.resolveQlpacks).toHaveBeenCalled(); expect(cliServer.resolveQlpacks).toHaveBeenCalled();
expect(modelConfig.getExtensionsDirectory).toHaveBeenCalledWith("java");
expect( expect(
loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")), loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")),
@@ -299,6 +231,7 @@ describe("pickExtensionPack", () => {
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,
@@ -324,6 +257,7 @@ describe("pickExtensionPack", () => {
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,
@@ -351,6 +285,7 @@ describe("pickExtensionPack", () => {
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,
@@ -388,6 +323,7 @@ describe("pickExtensionPack", () => {
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,
@@ -425,6 +361,7 @@ describe("pickExtensionPack", () => {
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,
@@ -465,6 +402,7 @@ describe("pickExtensionPack", () => {
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,
@@ -522,6 +460,7 @@ describe("pickExtensionPack", () => {
await pickExtensionPack( await pickExtensionPack(
cliServer, cliServer,
databaseItem, databaseItem,
modelConfig,
logger, logger,
progress, progress,
maxStep, maxStep,

View File

@@ -8,6 +8,7 @@ import { QueryLanguage } from "../../../../src/common/query-language";
import { Mode } from "../../../../src/model-editor/shared/mode"; import { Mode } from "../../../../src/model-editor/shared/mode";
import { mockedObject } from "../../utils/mocking.helpers"; import { mockedObject } from "../../utils/mocking.helpers";
import { CodeQLCliServer } from "../../../../src/codeql-cli/cli"; import { CodeQLCliServer } from "../../../../src/codeql-cli/cli";
import { ModelConfig } from "../../../../src/config";
describe("setUpPack", () => { describe("setUpPack", () => {
let queryDir: string; let queryDir: string;
@@ -32,8 +33,11 @@ describe("setUpPack", () => {
packInstall: jest.fn(), packInstall: jest.fn(),
resolveQueriesInSuite: jest.fn().mockResolvedValue([]), resolveQueriesInSuite: jest.fn().mockResolvedValue([]),
}); });
const modelConfig = mockedObject<ModelConfig>({
llmGeneration: false,
});
await setUpPack(cliServer, queryDir, language); await setUpPack(cliServer, queryDir, language, modelConfig);
const queryFiles = await readdir(queryDir); const queryFiles = await readdir(queryDir);
expect(queryFiles.sort()).toEqual( expect(queryFiles.sort()).toEqual(
@@ -89,8 +93,11 @@ describe("setUpPack", () => {
.fn() .fn()
.mockResolvedValue(["/a/b/c/ApplicationModeEndpoints.ql"]), .mockResolvedValue(["/a/b/c/ApplicationModeEndpoints.ql"]),
}); });
const modelConfig = mockedObject<ModelConfig>({
llmGeneration: false,
});
await setUpPack(cliServer, queryDir, language); await setUpPack(cliServer, queryDir, language, modelConfig);
const queryFiles = await readdir(queryDir); const queryFiles = await readdir(queryDir);
expect(queryFiles.sort()).toEqual(["codeql-pack.yml"].sort()); expect(queryFiles.sort()).toEqual(["codeql-pack.yml"].sort());

View File

@@ -10,11 +10,15 @@ import { QueryRunner } from "../../../../src/query-server";
import { ExtensionPack } from "../../../../src/model-editor/shared/extension-pack"; import { ExtensionPack } from "../../../../src/model-editor/shared/extension-pack";
import { createMockModelingStore } from "../../../__mocks__/model-editor/modelingStoreMock"; import { createMockModelingStore } from "../../../__mocks__/model-editor/modelingStoreMock";
import { createMockModelEditorViewTracker } from "../../../__mocks__/model-editor/modelEditorViewTrackerMock"; import { createMockModelEditorViewTracker } from "../../../__mocks__/model-editor/modelEditorViewTrackerMock";
import { ModelConfigListener } from "../../../../src/config";
describe("ModelEditorView", () => { describe("ModelEditorView", () => {
const app = createMockApp({}); const app = createMockApp({});
const modelingStore = createMockModelingStore(); const modelingStore = createMockModelingStore();
const viewTracker = createMockModelEditorViewTracker(); const viewTracker = createMockModelEditorViewTracker();
const modelConfig = mockedObject<ModelConfigListener>({
onDidChangeConfiguration: jest.fn(),
});
const databaseManager = mockEmptyDatabaseManager(); const databaseManager = mockEmptyDatabaseManager();
const cliServer = mockedObject<CodeQLCliServer>({}); const cliServer = mockedObject<CodeQLCliServer>({});
const queryRunner = mockedObject<QueryRunner>({}); const queryRunner = mockedObject<QueryRunner>({});
@@ -41,6 +45,7 @@ describe("ModelEditorView", () => {
app, app,
modelingStore, modelingStore,
viewTracker, viewTracker,
modelConfig,
databaseManager, databaseManager,
cliServer, cliServer,
queryRunner, queryRunner,

View File

@@ -4,6 +4,7 @@ import {
workspace, workspace,
ConfigurationTarget, ConfigurationTarget,
window, window,
env,
} from "vscode"; } from "vscode";
import { import {
ExtensionTelemetryListener, ExtensionTelemetryListener,
@@ -30,13 +31,18 @@ describe("telemetry reporting", () => {
let sendTelemetryEventSpy: jest.SpiedFunction< let sendTelemetryEventSpy: jest.SpiedFunction<
typeof TelemetryReporter.prototype.sendTelemetryEvent typeof TelemetryReporter.prototype.sendTelemetryEvent
>; >;
let sendTelemetryExceptionSpy: jest.SpiedFunction< let sendTelemetryErrorEventSpy: jest.SpiedFunction<
typeof TelemetryReporter.prototype.sendTelemetryException typeof TelemetryReporter.prototype.sendTelemetryErrorEvent
>; >;
let disposeSpy: jest.SpiedFunction< let disposeSpy: jest.SpiedFunction<
typeof TelemetryReporter.prototype.dispose typeof TelemetryReporter.prototype.dispose
>; >;
let isTelemetryEnabledSpy: jest.SpyInstance<
typeof env.isTelemetryEnabled,
[]
>;
let showInformationMessageSpy: jest.SpiedFunction< let showInformationMessageSpy: jest.SpiedFunction<
typeof window.showInformationMessage typeof window.showInformationMessage
>; >;
@@ -56,8 +62,8 @@ describe("telemetry reporting", () => {
sendTelemetryEventSpy = jest sendTelemetryEventSpy = jest
.spyOn(TelemetryReporter.prototype, "sendTelemetryEvent") .spyOn(TelemetryReporter.prototype, "sendTelemetryEvent")
.mockReturnValue(undefined); .mockReturnValue(undefined);
sendTelemetryExceptionSpy = jest sendTelemetryErrorEventSpy = jest
.spyOn(TelemetryReporter.prototype, "sendTelemetryException") .spyOn(TelemetryReporter.prototype, "sendTelemetryErrorEvent")
.mockReturnValue(undefined); .mockReturnValue(undefined);
disposeSpy = jest disposeSpy = jest
.spyOn(TelemetryReporter.prototype, "dispose") .spyOn(TelemetryReporter.prototype, "dispose")
@@ -78,6 +84,9 @@ describe("telemetry reporting", () => {
.get<boolean>("codeQL.canary")).toString(); .get<boolean>("codeQL.canary")).toString();
// each test will default to telemetry being enabled // each test will default to telemetry being enabled
isTelemetryEnabledSpy = jest
.spyOn(env, "isTelemetryEnabled", "get")
.mockReturnValue(true);
await enableTelemetry("telemetry", true); await enableTelemetry("telemetry", true);
await enableTelemetry("codeQL.telemetry", true); await enableTelemetry("codeQL.telemetry", true);
@@ -116,6 +125,7 @@ describe("telemetry reporting", () => {
}); });
it("should initialize telemetry when global option disabled", async () => { it("should initialize telemetry when global option disabled", async () => {
isTelemetryEnabledSpy.mockReturnValue(false);
await enableTelemetry("telemetry", false); await enableTelemetry("telemetry", false);
await telemetryListener.initialize(); await telemetryListener.initialize();
expect(telemetryListener._reporter).toBeDefined(); expect(telemetryListener._reporter).toBeDefined();
@@ -133,6 +143,7 @@ describe("telemetry reporting", () => {
it("should not initialize telemetry when both options disabled", async () => { it("should not initialize telemetry when both options disabled", async () => {
await enableTelemetry("codeQL.telemetry", false); await enableTelemetry("codeQL.telemetry", false);
isTelemetryEnabledSpy.mockReturnValue(false);
await enableTelemetry("telemetry", false); await enableTelemetry("telemetry", false);
await telemetryListener.initialize(); await telemetryListener.initialize();
expect(telemetryListener._reporter).toBeUndefined(); expect(telemetryListener._reporter).toBeUndefined();
@@ -179,6 +190,7 @@ describe("telemetry reporting", () => {
const reporter: any = telemetryListener._reporter; const reporter: any = telemetryListener._reporter;
expect(reporter.userOptIn).toBe(true); // enabled expect(reporter.userOptIn).toBe(true); // enabled
isTelemetryEnabledSpy.mockReturnValue(false);
await enableTelemetry("telemetry", false); await enableTelemetry("telemetry", false);
expect(reporter.userOptIn).toBe(false); // disabled expect(reporter.userOptIn).toBe(false); // disabled
}); });
@@ -198,8 +210,7 @@ describe("telemetry reporting", () => {
}, },
{ executionTime: 1234 }, { executionTime: 1234 },
); );
expect(sendTelemetryErrorEventSpy).not.toBeCalled();
expect(sendTelemetryExceptionSpy).not.toBeCalled();
}); });
it("should send a command usage event with an error", async () => { it("should send a command usage event with an error", async () => {
@@ -221,8 +232,7 @@ describe("telemetry reporting", () => {
}, },
{ executionTime: 1234 }, { executionTime: 1234 },
); );
expect(sendTelemetryErrorEventSpy).not.toBeCalled();
expect(sendTelemetryExceptionSpy).not.toBeCalled();
}); });
it("should send a command usage event with a cli version", async () => { it("should send a command usage event with a cli version", async () => {
@@ -245,8 +255,7 @@ describe("telemetry reporting", () => {
}, },
{ executionTime: 1234 }, { executionTime: 1234 },
); );
expect(sendTelemetryErrorEventSpy).not.toBeCalled();
expect(sendTelemetryExceptionSpy).not.toBeCalled();
// Verify that if the cli version is not set, then the telemetry falls back to "not-set" // Verify that if the cli version is not set, then the telemetry falls back to "not-set"
sendTelemetryEventSpy.mockClear(); sendTelemetryEventSpy.mockClear();
@@ -268,6 +277,7 @@ describe("telemetry reporting", () => {
}, },
{ executionTime: 5678 }, { executionTime: 5678 },
); );
expect(sendTelemetryErrorEventSpy).not.toBeCalled();
}); });
it("should avoid sending an event when telemetry is disabled", async () => { it("should avoid sending an event when telemetry is disabled", async () => {
@@ -278,7 +288,7 @@ describe("telemetry reporting", () => {
telemetryListener.sendCommandUsage("command-id", 1234, new Error()); telemetryListener.sendCommandUsage("command-id", 1234, new Error());
expect(sendTelemetryEventSpy).not.toBeCalled(); expect(sendTelemetryEventSpy).not.toBeCalled();
expect(sendTelemetryExceptionSpy).not.toBeCalled(); expect(sendTelemetryErrorEventSpy).not.toBeCalled();
}); });
it("should send an event when telemetry is re-enabled", async () => { it("should send an event when telemetry is re-enabled", async () => {
@@ -298,6 +308,7 @@ describe("telemetry reporting", () => {
}, },
{ executionTime: 1234 }, { executionTime: 1234 },
); );
expect(sendTelemetryErrorEventSpy).not.toBeCalled();
}); });
it("should filter undesired properties from telemetry payload", async () => { it("should filter undesired properties from telemetry payload", async () => {
@@ -345,6 +356,8 @@ describe("telemetry reporting", () => {
resolveArg(3 /* "yes" item */), resolveArg(3 /* "yes" item */),
); );
await ctx.globalState.update("telemetry-request-viewed", false); await ctx.globalState.update("telemetry-request-viewed", false);
expect(env.isTelemetryEnabled).toBe(true);
await enableTelemetry("codeQL.telemetry", false); await enableTelemetry("codeQL.telemetry", false);
await telemetryListener.initialize(); await telemetryListener.initialize();
@@ -411,6 +424,7 @@ describe("telemetry reporting", () => {
// If the user ever turns global telemetry back on, then we can // If the user ever turns global telemetry back on, then we can
// show the dialog. // show the dialog.
isTelemetryEnabledSpy.mockReturnValue(false);
await enableTelemetry("telemetry", false); await enableTelemetry("telemetry", false);
await ctx.globalState.update("telemetry-request-viewed", false); await ctx.globalState.update("telemetry-request-viewed", false);
@@ -455,6 +469,7 @@ describe("telemetry reporting", () => {
}, },
{}, {},
); );
expect(sendTelemetryErrorEventSpy).not.toBeCalled();
}); });
it("should send a ui-interaction telementry event with a cli version", async () => { it("should send a ui-interaction telementry event with a cli version", async () => {
@@ -472,6 +487,7 @@ describe("telemetry reporting", () => {
}, },
{}, {},
); );
expect(sendTelemetryErrorEventSpy).not.toBeCalled();
}); });
it("should send an error telementry event", async () => { it("should send an error telementry event", async () => {
@@ -479,7 +495,8 @@ describe("telemetry reporting", () => {
telemetryListener.sendError(redactableError`test`); telemetryListener.sendError(redactableError`test`);
expect(sendTelemetryEventSpy).toHaveBeenCalledWith( expect(sendTelemetryEventSpy).not.toBeCalled();
expect(sendTelemetryErrorEventSpy).toHaveBeenCalledWith(
"error", "error",
{ {
message: "test", message: "test",
@@ -497,7 +514,8 @@ describe("telemetry reporting", () => {
telemetryListener.sendError(redactableError`test`); telemetryListener.sendError(redactableError`test`);
expect(sendTelemetryEventSpy).toHaveBeenCalledWith( expect(sendTelemetryEventSpy).not.toBeCalled();
expect(sendTelemetryErrorEventSpy).toHaveBeenCalledWith(
"error", "error",
{ {
message: "test", message: "test",
@@ -516,7 +534,8 @@ describe("telemetry reporting", () => {
redactableError`test message with secret information: ${42} and more ${"secret"} parts`, redactableError`test message with secret information: ${42} and more ${"secret"} parts`,
); );
expect(sendTelemetryEventSpy).toHaveBeenCalledWith( expect(sendTelemetryEventSpy).not.toBeCalled();
expect(sendTelemetryErrorEventSpy).toHaveBeenCalledWith(
"error", "error",
{ {
message: message:

View File

@@ -108,6 +108,14 @@
"match": "(?x)\\]", "match": "(?x)\\]",
"name": "punctuation.squarebracket.close.ql" "name": "punctuation.squarebracket.close.ql"
}, },
"open-angle": {
"match": "(?x)<",
"name": "punctuation.anglebracket.open.ql"
},
"close-angle": {
"match": "(?x)>",
"name": "punctuation.anglebracket.close.ql"
},
"operator-or-punctuation": { "operator-or-punctuation": {
"patterns": [ "patterns": [
{ {
@@ -151,6 +159,12 @@
}, },
{ {
"include": "#close-bracket" "include": "#close-bracket"
},
{
"include": "#open-angle"
},
{
"include": "#close-angle"
} }
] ]
}, },
@@ -661,9 +675,9 @@
"begin": "(?x)(?<=/\\*\\*)([^*]|\\*(?!/))*$", "begin": "(?x)(?<=/\\*\\*)([^*]|\\*(?!/))*$",
"while": "(?x)(^|\\G)\\s*([^*]|\\*(?!/))(?=([^*]|[*](?!/))*$)", "while": "(?x)(^|\\G)\\s*([^*]|\\*(?!/))(?=([^*]|[*](?!/))*$)",
"patterns": [ "patterns": [
{ {
"match": "(?x)\\G\\s* (@\\S+)", "match": "(?x)\\G\\s* (@\\S+)",
"name": "keyword.tag.ql" "name": "keyword.tag.ql"
@@ -723,15 +737,48 @@
} }
] ]
}, },
"import-directive": { "instantiation-args": {
"end": "(?x)(?:\\b [A-Za-z][0-9A-Za-z_]* (?:(?!(?:[0-9A-Za-z_])))) (?!\\s*(\\.|\\:\\:))", "name": "meta.type.parameters.ql",
"endCaptures": { "patterns": [
"0": { {
"include": "#instantiation-args"
},
{
"include": "#non-context-sensitive"
},
{
"match": "(?x)(?:\\b [A-Za-z][0-9A-Za-z_]* (?:(?!(?:[0-9A-Za-z_]))))",
"name": "entity.name.type.namespace.ql" "name": "entity.name.type.namespace.ql"
} }
],
"begin": "(?x)((?:<))",
"beginCaptures": {
"1": {
"patterns": [
{
"include": "#open-angle"
}
]
}
}, },
"end": "(?x)((?:>))",
"endCaptures": {
"1": {
"patterns": [
{
"include": "#close-angle"
}
]
}
}
},
"import-directive": {
"end": "(?x)(?<!\\bimport)(?<=(?:\\>)|[A-Za-z0-9_]) (?!\\s*(\\.|\\:\\:|\\,|(?:<)))",
"name": "meta.block.import-directive.ql", "name": "meta.block.import-directive.ql",
"patterns": [ "patterns": [
{
"include": "#instantiation-args"
},
{ {
"include": "#non-context-sensitive" "include": "#non-context-sensitive"
}, },
@@ -1493,4 +1540,4 @@
"name": "constant.character.escape.ql" "name": "constant.character.escape.ql"
} }
} }
} }