Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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);
Comment on lines 60 to 62

This comment was marked as outdated.

}
}

/**
* 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 (selectedProject.uri.path === removedRoot.fsPath) {

This comment was marked as outdated.

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.

Valid catch. Fixed — now reconstructs via Uri.file(selectedProject.uri.path).fsPath before comparing to removedRoot.fsPath, so both sides use OS-native separators.

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
Loading