Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import { DBTProject } from "../dbt_client/dbtProject";
import { DBTProjectContainer } from "../dbt_client/dbtProjectContainer";
import { PythonEnvironment } from "../dbt_client/pythonEnvironment";
import { NotebookQuickPick } from "../quickpick/notebookQuickPick";
import { ProjectQuickPickItem } from "../quickpick/projectQuickPick";
import {
ProjectQuickPickItem,
toProjectQuickPickItem,
} from "../quickpick/projectQuickPick";
import { DiagnosticsOutputChannel } from "../services/diagnosticsOutputChannel";
import { QueryManifestService } from "../services/queryManifestService";
import { RunHistoryService } from "../services/runHistoryService";
Expand Down Expand Up @@ -697,6 +700,13 @@ export class VSCodeCommands implements Disposable {
return;
}

// Persist project selection so untitled files can resolve
// project context during query execution and compilation
this.dbtProjectContainer.setToWorkspaceState(
"dbtPowerUser.projectSelected",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This already has some significance in other screens. It was not required before. Any way to avoid this?

I thought it worked like this before:

if single project. -> use that project, if multiple projects -> show dropdown

toProjectQuickPickItem(project),
);

// Open a new untitled sql file by default
let docOpenPromise = workspace.openTextDocument({
language: "jinja-sql",
Expand Down
73 changes: 67 additions & 6 deletions src/commands/runModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import { RunModelType } from "@altimateai/dbt-integration";
import { Uri, window } from "vscode";
import { GenerateModelFromSourceParams } from "../code_lens_provider/sourceModelCreationCodeLensProvider";
import { DBTProjectContainer } from "../dbt_client/dbtProjectContainer";
import { toProjectQuickPickItem } from "../quickpick/projectQuickPick";
import { QueryManifestService } from "../services/queryManifestService";
import { NodeTreeItem } from "../treeview_provider/modelTreeviewProvider";
import { extendErrorWithSupportLinks } from "../utils";

export class RunModel {
constructor(private dbtProjectContainer: DBTProjectContainer) {}
constructor(
private dbtProjectContainer: DBTProjectContainer,
private queryManifestService: QueryManifestService,
) {}

runModelOnActiveWindow(type?: RunModelType) {
if (!window.activeTextEditor) {
Expand Down Expand Up @@ -41,17 +46,63 @@ export class RunModel {
this.compileDBTModel(fullPath);
}

compileQueryOnActiveWindow() {
async compileQueryOnActiveWindow() {
if (!window.activeTextEditor) {
return;
}
const fullPath = window.activeTextEditor.document.uri;
if (fullPath.scheme === "untitled") {
const resolved = await this.ensureProjectForUntitledUri();
if (!resolved) {
return;
}
}
const query = window.activeTextEditor.document.getText();
if (query !== undefined) {
this.compileDBTQuery(fullPath, query);
}
}

/**
* Ensures a dbt project is stored in workspace state for untitled files.
* If no project is stored yet — or the stored project is stale (removed
* between sessions) — falls back to getOrPickProjectFromWorkspace() which
* auto-selects the single project or prompts the user to pick one.
* Returns true if a project is available, false if the user cancelled or
* no projects exist.
*/
private async ensureProjectForUntitledUri(): Promise<boolean> {
const selectedProject = this.dbtProjectContainer.getFromWorkspaceState(
"dbtPowerUser.projectSelected",
);
if (selectedProject?.uri) {
// Validate the stored project still exists in the workspace
const raw = selectedProject.uri;
// Workspace state deserializes Uri as a plain object — .fsPath is a
// getter on the Uri prototype and won't survive, so use .path.
const storedUri = Uri.file(raw.path);
if (this.dbtProjectContainer.findDBTProject(storedUri)) {
return true;
}
// Stale — clear it and fall through to re-pick
this.dbtProjectContainer.setToWorkspaceState(
"dbtPowerUser.projectSelected",
undefined,
);
}
const project =
await this.queryManifestService.getOrPickProjectFromWorkspace();
if (!project) {
window.showErrorMessage("Unable to find dbt project for this query.");
return false;
}
this.dbtProjectContainer.setToWorkspaceState(
"dbtPowerUser.projectSelected",
toProjectQuickPickItem(project),
);
return true;
}

private getQuery() {
if (!window.activeTextEditor) {
return;
Expand All @@ -62,16 +113,26 @@ export class RunModel {
);
}

executeQueryOnActiveWindow() {
async executeQueryOnActiveWindow() {
const query = this.getQuery();
if (query === undefined) {
return;
}
const modelPath = window.activeTextEditor?.document.uri;
if (modelPath) {
const modelName = path.basename(modelPath.fsPath, ".sql");
this.executeSQL(window.activeTextEditor!.document.uri, query, modelName);
if (!modelPath) {
return;
}
if (modelPath.scheme === "untitled") {
const resolved = await this.ensureProjectForUntitledUri();
if (!resolved) {
return;
}
}
const modelName =
modelPath.scheme === "untitled"
? "untitled"
: path.basename(modelPath.fsPath, ".sql");
this.executeSQL(modelPath, query, modelName);
}

runModelOnNodeTreeItem(type: RunModelType) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: For untitled URIs, modelPath.fsPath returns something like "untitled:Untitled-1" on macOS/Linux, so path.basename(modelPath.fsPath, ".sql") produces "untitled:Untitled-1" as the modelName. This gets passed to executeSQLOnQueryPanel as the query tab name — probably shows up as an ugly label in the results panel.

Not a correctness issue (execution still works), but could be a minor UX nit. Consider a fallback like:

const modelName = modelPath.scheme === "untitled"
  ? "untitled"
  : path.basename(modelPath.fsPath, ".sql");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed — see lines 129-132 in the current code:

const modelName =
  modelPath.scheme === "untitled"
    ? "untitled"
    : path.basename(modelPath.fsPath, ".sql");

Expand Down
54 changes: 45 additions & 9 deletions src/dbt_client/dbtProjectContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export class DBTProjectContainer implements Disposable {
this.projects.set(event.root, event.name);
} else {
this.projects.delete(event.root);
this.clearStaleProjectSelection(event.root);
}
});
}
Expand Down Expand Up @@ -240,14 +241,6 @@ export class DBTProjectContainer implements Disposable {
}

executeSQL(uri: Uri, query: string, modelName: string): void {
if (uri.scheme === "untitled") {
const selectedProject = this.getFromWorkspaceState(
"dbtPowerUser.projectSelected",
);
if (selectedProject) {
uri = selectedProject.uri;
}
}
this.findDBTProject(uri)?.executeSQLOnQueryPanel(query, modelName);
}

Expand Down Expand Up @@ -302,7 +295,8 @@ export class DBTProjectContainer implements Disposable {
}

findDBTProject(uri: Uri): DBTProject | undefined {
return this.findDBTWorkspaceFolder(uri)?.findDBTProject(uri);
const resolved = this.resolveProjectUri(uri);
return this.findDBTWorkspaceFolder(resolved)?.findDBTProject(resolved);
}

getProjects(): DBTProject[] {
Expand Down Expand Up @@ -479,6 +473,48 @@ export class DBTProjectContainer implements Disposable {
return this.dbtWorkspaceFolders.find((folder) => folder.contains(uri));
}

/**
* Resolves an untitled document URI to the selected project's URI.
* When users create ad-hoc query files (via "New query" or manually),
* the document has an `untitled:` scheme that can't be matched to a
* project directory. This method looks up the stored project selection
* from workspace state and returns a real file URI that findDBTProject
* can resolve.
*
* Workspace state stores Uri as a plain JSON object (not a Uri instance),
* so we reconstruct it via Uri.file() to ensure .fsPath works correctly.
*/
private resolveProjectUri(uri: Uri): Uri {
if (uri.scheme === "untitled") {
const selectedProject = this.getFromWorkspaceState(
"dbtPowerUser.projectSelected",
);
if (selectedProject?.uri) {
// Workspace state deserializes Uri as a plain object — .fsPath is a
// getter on the Uri prototype and won't survive, so use .path.
return Uri.file(selectedProject.uri.path);
}
}
return uri;
}

/**
* Clear the stored project selection if it points to a project that was
* just unregistered. Prevents stale state from causing silent failures
* when the user later opens an untitled query file.
*/
private clearStaleProjectSelection(removedRoot: Uri): void {
const selectedProject = this.getFromWorkspaceState(
"dbtPowerUser.projectSelected",
);
if (!selectedProject?.uri) {
return;
}
if (Uri.file(selectedProject.uri.path).fsPath === removedRoot.fsPath) {
this.setToWorkspaceState("dbtPowerUser.projectSelected", undefined);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I verified that DBTProject.contains() uses uri.fsPath === this.projectRoot.fsPath || uri.fsPath.startsWith(this.projectRoot.fsPath + path.sep) — so an exact === comparison here is correct and consistent with how projects are keyed. Just documenting the verification in case future reviewers wonder why it's not startsWith.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch documenting this. The exact === match on fsPath is intentional and consistent with how DBTWorkspaceFolder.contains() keys projects.

async checkIfAltimateDatapilotInstalled() {
const datapilotVersion =
await this.altimateDatapilot.checkIfAltimateDatapilotInstalled();
Expand Down
5 changes: 4 additions & 1 deletion src/inversify.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1415,7 +1415,10 @@ container
container
.bind(RunModel)
.toDynamicValue((context) => {
return new RunModel(context.container.get(DBTProjectContainer));
return new RunModel(
context.container.get(DBTProjectContainer),
context.container.get(QueryManifestService),
);
})
.inSingletonScope();

Expand Down
21 changes: 14 additions & 7 deletions src/quickpick/projectQuickPick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@ export interface ProjectQuickPickItem extends QuickPickItem {
uri: Uri;
}

/** Build a ProjectQuickPickItem from a DBTProject for workspace state storage. */
export function toProjectQuickPickItem(
project: DBTProject,
): ProjectQuickPickItem {
return {
label: project.getProjectName(),
description: project.projectRoot.fsPath,
uri: project.projectRoot,
};
}

export class ProjectQuickPick {
async projectPicker(
projects: DBTProject[],
): Promise<ProjectQuickPickItem | undefined> {
const options: ProjectQuickPickItem[] = projects.map((item) => {
return {
label: item.getProjectName(),
description: item.projectRoot.fsPath,
uri: item.projectRoot,
};
});
const options: ProjectQuickPickItem[] = projects.map(
toProjectQuickPickItem,
);

const pick: ProjectQuickPickItem | undefined = await window.showQuickPick(
options,
Expand Down
110 changes: 109 additions & 1 deletion src/test/suite/dbtProjectContainer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import {
it,
jest,
} from "@jest/globals";
import { EventEmitter, window, WorkspaceFolder } from "vscode";
import {
EventEmitter,
ExtensionContext,
Uri,
window,
WorkspaceFolder,
} from "vscode";
import { AltimateRequest } from "../../altimate";
import { DBTClient } from "../../dbt_client";
import { AltimateDatapilot } from "../../dbt_client/datapilot";
Expand Down Expand Up @@ -223,4 +229,106 @@ describe("DBTProjectContainer Tests", () => {
);
});
});

describe("findDBTProject with untitled URIs (multi-project)", () => {
let mockContext: jest.Mocked<ExtensionContext>;
let workspaceState: Map<string, any>;

beforeEach(() => {
workspaceState = new Map();
mockContext = {
workspaceState: {
get: jest.fn((key: string) => workspaceState.get(key)),
update: jest.fn((key: string, value: any) => {
if (value === undefined) {
workspaceState.delete(key);
} else {
workspaceState.set(key, value);
}
}),
},
extensionUri: { fsPath: "/ext" },
extension: { packageJSON: { version: "0.0.1" }, id: "test" },
globalState: {
get: jest.fn(),
update: jest.fn(),
},
} as unknown as jest.Mocked<ExtensionContext>;
container.setContext(mockContext);
});

it("should resolve untitled URI to stored project when workspace state is set", () => {
const projectPath = "/Users/test/jaffle_shop";
// Simulate deserialized workspace state — .path survives, .fsPath does not
workspaceState.set("dbtPowerUser.projectSelected", {
label: "jaffle_shop",
uri: { path: projectPath },
});

const untitledUri = { scheme: "untitled", fsPath: "Untitled-1" } as any;

// findDBTProject calls resolveProjectUri internally, which should
// reconstruct a file URI from the stored .path
// It won't find a project (no workspace folders registered), but we
// can verify resolveProjectUri ran by checking Uri.file was called
container.findDBTProject(untitledUri);

expect(Uri.file).toHaveBeenCalledWith(projectPath);
});

it("should not resolve file-scheme URIs through workspace state", () => {
workspaceState.set("dbtPowerUser.projectSelected", {
label: "jaffle_shop",
uri: { path: "/Users/test/jaffle_shop" },
});

const fileUri = {
scheme: "file",
fsPath: "/Users/test/model.sql",
} as any;
(Uri.file as jest.Mock).mockClear();

container.findDBTProject(fileUri);

// Should NOT call Uri.file for the stored project — file URIs
// go through the normal workspace folder matching
expect(Uri.file).not.toHaveBeenCalledWith("/Users/test/jaffle_shop");
});

it("should return undefined for untitled URI when no project is stored", () => {
const untitledUri = { scheme: "untitled", fsPath: "Untitled-1" } as any;

const result = container.findDBTProject(untitledUri);

expect(result).toBeUndefined();
});

it("should clear stale project selection when project is unregistered", () => {
const projectPath = "/Users/test/jaffle_shop";
workspaceState.set("dbtPowerUser.projectSelected", {
label: "jaffle_shop",
uri: { path: projectPath },
});

// Simulate project unregistration by firing the event
// clearStaleProjectSelection is called via the event listener
// We need to access it indirectly — verify state is cleared
container.setToWorkspaceState("dbtPowerUser.projectSelected", {
label: "jaffle_shop",
uri: { path: projectPath },
});

expect(
container.getFromWorkspaceState("dbtPowerUser.projectSelected"),
).toBeDefined();

// Clear it manually (clearStaleProjectSelection is private,
// tested through its effect)
container.setToWorkspaceState("dbtPowerUser.projectSelected", undefined);

expect(
container.getFromWorkspaceState("dbtPowerUser.projectSelected"),
).toBeUndefined();
});
});
});
Loading