Save dirty documents before evaluating queries

This commit is contained in:
Dave Bartolomeo
2023-06-21 14:12:05 -04:00
parent 0451dd8d1b
commit 7a46bac078
10 changed files with 241 additions and 76 deletions

View File

@@ -71,7 +71,8 @@
"contributes": {
"configurationDefaults": {
"[ql]": {
"editor.wordBasedSuggestions": false
"editor.wordBasedSuggestions": false,
"debug.saveBeforeStart": "nonUntitledEditorsInActiveGroup"
},
"[dbscheme]": {
"editor.wordBasedSuggestions": false
@@ -246,8 +247,8 @@
},
"codeQL.runningQueries.autoSave": {
"type": "boolean",
"default": false,
"description": "Enable automatically saving a modified query file when running a query."
"description": "Enable automatically saving a modified query file when running a query.",
"markdownDeprecationMessage": "This property is deprecated and no longer has any effect. To control automatic saving of documents before running queries, use the `debug.saveBeforeStart` setting."
},
"codeQL.runningQueries.maxQueries": {
"type": "integer",

View File

@@ -5,6 +5,7 @@ import {
EventEmitter,
ConfigurationChangeEvent,
ConfigurationTarget,
ConfigurationScope,
} from "vscode";
import { DistributionManager } from "./codeql-cli/distribution";
import { extLogger } from "./common/logging/vscode";
@@ -23,7 +24,11 @@ export class Setting {
parent?: Setting;
private _hasChildren = false;
constructor(name: string, parent?: Setting) {
constructor(
name: string,
parent?: Setting,
private readonly languageId?: string,
) {
this.name = name;
this.parent = parent;
if (parent !== undefined) {
@@ -44,12 +49,22 @@ export class Setting {
}
}
get scope(): ConfigurationScope | undefined {
if (this.languageId !== undefined) {
return {
languageId: this.languageId,
};
} else {
return this.parent?.scope;
}
}
getValue<T>(): T {
if (this.parent === undefined) {
throw new Error("Cannot get the value of a root setting.");
}
return workspace
.getConfiguration(this.parent.qualifiedName)
.getConfiguration(this.parent.qualifiedName, this.parent.scope)
.get<T>(this.name)!;
}
@@ -58,7 +73,7 @@ export class Setting {
throw new Error("Cannot update the value of a root setting.");
}
return workspace
.getConfiguration(this.parent.qualifiedName)
.getConfiguration(this.parent.qualifiedName, this.parent.scope)
.update(this.name, value, target);
}
}
@@ -69,6 +84,12 @@ export interface InspectionResult<T> {
workspaceFolderValue?: T;
}
const VSCODE_DEBUG_SETTING = new Setting("debug", undefined, "ql");
export const VSCODE_SAVE_BEFORE_START_SETTING = new Setting(
"saveBeforeStart",
VSCODE_DEBUG_SETTING,
);
const ROOT_SETTING = new Setting("codeQL");
// Global configuration
@@ -160,10 +181,6 @@ export const NUMBER_OF_TEST_THREADS_SETTING = new Setting(
RUNNING_TESTS_SETTING,
);
export const MAX_QUERIES = new Setting("maxQueries", RUNNING_QUERIES_SETTING);
export const AUTOSAVE_SETTING = new Setting(
"autoSave",
RUNNING_QUERIES_SETTING,
);
export const PAGE_SIZE = new Setting("pageSize", RESULTS_DISPLAY_SETTING);
const CUSTOM_LOG_DIRECTORY_SETTING = new Setting(
"customLogDirectory",

View File

@@ -14,6 +14,7 @@ import { CoreQueryResults } from "../query-server";
import {
getQuickEvalContext,
QueryOutputDir,
saveBeforeStart,
validateQueryUri,
} from "../run-queries-shared";
import { QLResolvedDebugConfiguration } from "./debug-configuration";
@@ -73,6 +74,9 @@ class QLDebugAdapterTracker
}
public async quickEval(): Promise<void> {
// Since we're not going through VS Code's launch path, we need to save dirty files ourselves.
await saveBeforeStart();
const args: CodeQLProtocol.QuickEvalRequest["arguments"] = {
quickEvalContext: await getQuickEvalContext(undefined, false),
};

View File

@@ -10,7 +10,6 @@ import {
Range,
Uri,
window,
workspace,
} from "vscode";
import {
TeeLogger,
@@ -32,8 +31,8 @@ import {
createInitialQueryInfo,
createTimestampFile,
getQuickEvalContext,
promptUserToSaveChanges,
QueryOutputDir,
saveBeforeStart,
SelectedQuery,
validateQueryUri,
} from "../run-queries-shared";
@@ -53,25 +52,6 @@ interface DatabaseQuickPickItem extends QuickPickItem {
databaseItem: DatabaseItem;
}
/**
* If either the query file or the quickeval file is dirty, give the user the chance to save them.
*/
async function promptToSaveQueryIfNeeded(query: SelectedQuery): Promise<void> {
// There seems to be no way to ask VS Code to find an existing text document by name, without
// automatically opening the document if it is not found.
const queryUri = Uri.file(query.queryPath).toString();
const quickEvalUri =
query.quickEval !== undefined
? Uri.file(query.quickEval.quickEvalPosition.fileName).toString()
: undefined;
for (const openDocument of workspace.textDocuments) {
const documentUri = openDocument.uri.toString();
if (documentUri === queryUri || documentUri === quickEvalUri) {
await promptUserToSaveChanges(openDocument);
}
}
}
export enum QuickEvalType {
None,
QuickEval,
@@ -408,7 +388,7 @@ export class LocalQueries extends DisposableObject {
const additionalPacks = getOnDiskWorkspaceFolders();
const extensionPacks = await this.getDefaultExtensionPacks(additionalPacks);
await promptToSaveQueryIfNeeded(selectedQuery);
await saveBeforeStart();
const coreQueryRun = this.queryRunner.createQueryRun(
databaseItem.databaseUri.fsPath,

View File

@@ -3,15 +3,14 @@ import * as legacyMessages from "./pure/legacy-messages";
import { DatabaseInfo, QueryMetadata } from "./common/interface-types";
import { join, parse, dirname, basename } from "path";
import {
ConfigurationTarget,
Range,
TextDocument,
TextEditor,
Uri,
window,
workspace,
} from "vscode";
import { isCanary, AUTOSAVE_SETTING } from "./config";
import { UserCancellationException } from "./common/vscode/progress";
import { isCanary, VSCODE_SAVE_BEFORE_START_SETTING } from "./config";
import {
pathExists,
readFile,
@@ -491,55 +490,78 @@ async function getSelectedPosition(
};
}
type SaveBeforeStartMode =
| "nonUntitledEditorsInActiveGroup"
| "allEditorsInActiveGroup"
| "none";
/**
* Prompts the user to save `document` if it has unsaved changes.
*
* @param document The document to save.
*
* @returns true if we should save changes and false if we should continue without saving changes.
* @throws UserCancellationException if we should abort whatever operation triggered this prompt
* Saves dirty files before running queries, based on the user's settings.
*/
export async function promptUserToSaveChanges(
document: TextDocument,
): Promise<boolean> {
if (document.isDirty) {
if (AUTOSAVE_SETTING.getValue()) {
return true;
} else {
const yesItem = { title: "Yes", isCloseAffordance: false };
const alwaysItem = { title: "Always Save", isCloseAffordance: false };
const noItem = {
title: "No (run version on disk)",
isCloseAffordance: false,
};
const cancelItem = { title: "Cancel", isCloseAffordance: true };
const message = `Query file '${basename(
document.uri.fsPath,
)}' has unsaved changes. Save now?`;
const chosenItem = await window.showInformationMessage(
message,
{ modal: true },
yesItem,
alwaysItem,
noItem,
cancelItem,
);
export async function saveBeforeStart(): Promise<void> {
const mode: SaveBeforeStartMode =
(VSCODE_SAVE_BEFORE_START_SETTING.getValue<string>() as SaveBeforeStartMode) ??
"nonUntitledEditorsInActiveGroup";
if (chosenItem === alwaysItem) {
await AUTOSAVE_SETTING.updateValue(true, ConfigurationTarget.Workspace);
return true;
}
switch (mode) {
case "nonUntitledEditorsInActiveGroup":
await saveAllInGroup(false);
break;
if (chosenItem === yesItem) {
return true;
}
case "allEditorsInActiveGroup":
await saveAllInGroup(true);
break;
if (chosenItem === cancelItem) {
throw new UserCancellationException("Query run cancelled.", true);
case "none":
break;
default:
// Unexpected value. Fall back to the default behavior.
await saveAllInGroup(false);
break;
}
}
// Used in tests
export async function saveAllInGroup(includeUntitled: boolean): Promise<void> {
// There's no good way to get from a `Tab` to a `TextDocument`, so we'll collect all of the dirty
// documents indexed by their URI, and then compare those URIs against the URIs of the tabs.
const dirtyDocumentUris = new Map<string, TextDocument>();
for (const openDocument of workspace.textDocuments) {
if (openDocument.isDirty) {
console.warn(`${openDocument.uri.toString()} is dirty.`);
if (!openDocument.isUntitled || includeUntitled) {
dirtyDocumentUris.set(openDocument.uri.toString(), openDocument);
}
}
}
if (dirtyDocumentUris.size > 0) {
console.warn(`${window.tabGroups.all.length} tab groups open`);
console.warn(`${workspace.textDocuments.length} documents open`);
const tabGroup = window.tabGroups.activeTabGroup;
console.warn(`${tabGroup.tabs.length} tabs open in active group`);
for (const tab of tabGroup.tabs) {
const input = tab.input;
// The `input` property can be of an arbitrary type, depending on the underlying tab type. For
// text editors (and potentially others), it's an object with a `uri` property. That's all we
// need to know to match it up with a dirty document.
if (typeof input === "object") {
const uri = (input as any).uri;
if (uri instanceof Uri) {
const document = dirtyDocumentUris.get(uri.toString());
if (document !== undefined) {
console.warn(`Saving ${uri.toString()}`);
await document.save();
// Remove the URI from the dirty list so we don't wind up saving the same file twice
// if it's open in multiple editors.
dirtyDocumentUris.delete(uri.toString());
} else {
console.warn(`Can't find ${uri.toString()}`);
}
}
}
}
}
return false;
}
/**

View File

@@ -0,0 +1 @@
select "clean"

View File

@@ -0,0 +1 @@
testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestselect "dirty"

View File

@@ -0,0 +1 @@
select "other-clean"

View File

@@ -0,0 +1 @@
testtesttesttesttesttestselect "other-dirty"

View File

@@ -0,0 +1,137 @@
import * as path from "path";
import {
Position,
TextDocument,
ViewColumn,
commands,
window,
workspace,
} from "vscode";
import { saveAllInGroup } from "../../../../src/run-queries-shared";
/**
* Returns a new `TextDocument` with the given file name.
*
* @param file Path to the file to open, or undefined to create an untitled document.
* @param isDirty True if the document should have an edit applied.
* @param viewColumn The `ViewColumn` in which to open the file's editor.
* @returns The new `TextDocument`
*/
async function mockDocument(
file: string | undefined,
isDirty: boolean,
viewColumn: ViewColumn,
): Promise<TextDocument> {
let doc: TextDocument;
if (file !== undefined) {
doc = await workspace.openTextDocument(
path.join(workspace.workspaceFolders![0].uri.fsPath, file),
);
const editor = await window.showTextDocument(doc, viewColumn, true);
if (isDirty) {
await editor.edit((edit) => {
edit.insert(new Position(0, 0), "test");
});
}
} else {
doc = await workspace.openTextDocument({
content: 'select "untitled"',
language: "ql",
});
await window.showTextDocument(doc, viewColumn, true);
}
return doc;
}
/**
* Returns a promise that resolves after the given number of milliseconds, and returns the string
* "timeout".
*/
function delay(ms: number): Promise<string> {
return new Promise((resolve) => setTimeout(() => resolve("timeout"), ms));
}
jest.setTimeout(10000); // A little extra time for the save dialog to appear.
const dirtyFile = "debugger/dirty.ql";
const cleanFile = "debugger/clean.ql";
const otherGroupDirtyFile = "debugger/other-dirty.ql";
const otherGroupCleanFile = "debugger/other-clean.ql";
describe("saveBeforeStart", () => {
// We can't easily mock `TextDocument` because its properties are read-only and/or not
// configurable, so we rely on the actual `save()` method and the actual `isDirty` property.
beforeEach(async () => {
await commands.executeCommand("workbench.action.closeAllEditors");
});
it("should not save untitled documents without `includeUntitled`", async () => {
const dirtyDoc = await mockDocument(dirtyFile, true, ViewColumn.One);
const cleanDoc = await mockDocument(cleanFile, false, ViewColumn.One);
await saveAllInGroup(false);
expect(dirtyDoc.isDirty).toBe(false);
expect(cleanDoc.isDirty).toBe(false);
});
it("should not save dirty documents in other tab groups", async () => {
const dirtyDoc = await mockDocument(dirtyFile, true, ViewColumn.One);
const cleanDoc = await mockDocument(cleanFile, false, ViewColumn.One);
const otherGroupDirtyDoc = await mockDocument(
otherGroupDirtyFile,
true,
ViewColumn.Two,
);
const otherGroupCleanDoc = await mockDocument(
otherGroupCleanFile,
false,
ViewColumn.Two,
);
await saveAllInGroup(false);
expect(dirtyDoc.isDirty).toBe(false);
expect(cleanDoc.isDirty).toBe(false);
expect(otherGroupDirtyDoc.isDirty).toBe(true);
expect(otherGroupCleanDoc.isDirty).toBe(false);
});
it("should save untitled documents with `includeUntitled`", async () => {
const dirtyDoc = await mockDocument(dirtyFile, true, ViewColumn.One);
const cleanDoc = await mockDocument(cleanFile, false, ViewColumn.One);
const untitledDoc = await mockDocument(undefined, true, ViewColumn.One);
const otherGroupDirtyDoc = await mockDocument(
otherGroupDirtyFile,
true,
ViewColumn.Two,
);
const otherGroupCleanDoc = await mockDocument(
otherGroupCleanFile,
false,
ViewColumn.Two,
);
// Calling `save()` on an untitled document will bring up the file save dialog, and there's no
// good way to spy on `save()` because it's defined as read-only. Instead, we'll do the save all
// operation _anyway_, and _expect_ it to time out after 4 seconds. If it doesn't time out, then
// we know that the save dialog never popped up.
// This is pretty horrible, but it's the best I can come up with. It does need to be the last
// test in the suite, because it leaves the save dialog open.
const saveAll = async () => {
await saveAllInGroup(true);
return "saved";
};
const result = await Promise.race([saveAll(), delay(4000)]);
expect(result).toBe("timeout");
expect(dirtyDoc.isDirty).toBe(false);
expect(cleanDoc.isDirty).toBe(false);
expect(untitledDoc.isDirty).toBe(true);
expect(otherGroupDirtyDoc.isDirty).toBe(true);
expect(otherGroupCleanDoc.isDirty).toBe(false);
});
});