From 7d302aa05746f91e455013ac1c32771587af8cd6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:40:27 +0000 Subject: [PATCH] refactor: move projectDir resolution into QuartoProjectHelper Add optional rootDir parameter to QuartoProjectHelper constructor so it can resolve relative projectDir values internally. This removes the need for callers (homeView.ts) to resolve paths before constructing the helper. Addresses review feedback from #4048. Co-authored-by: zackverham --- .../utils/quartoProjectHelper.smoke.test.ts | 87 +++++++++++++++++++ .../src/utils/quartoProjectHelper.test.ts | 54 ++++++++++++ .../vscode/src/utils/quartoProjectHelper.ts | 17 +++- extensions/vscode/src/views/homeView.ts | 1 + extensions/vscode/src/views/renders.ts | 2 + 5 files changed, 159 insertions(+), 2 deletions(-) diff --git a/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts b/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts index ef678b25d3..a8046230ff 100644 --- a/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts +++ b/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts @@ -132,6 +132,93 @@ Hello project }, ); +// -------------------------------------------------------------------------- +// rootDir resolution — verifies that QuartoProjectHelper resolves a relative +// projectDir against rootDir internally, so callers don't need to. +// -------------------------------------------------------------------------- +describe("QuartoProjectHelper - rootDir resolution", () => { + test("relative projectDir '.' is resolved to rootDir", () => { + const helper = new QuartoProjectHelper( + "index.qmd", + "index.html", + ".", + tmpDir, + ); + expect(helper.projectDir).toBe(tmpDir); + }); + + test("nested relative projectDir is resolved against rootDir", () => { + const subDir = path.join("sub", "project"); + const helper = new QuartoProjectHelper( + "index.qmd", + "index.html", + subDir, + tmpDir, + ); + expect(helper.projectDir).toBe(path.join(tmpDir, subDir)); + }); + + test("absolute projectDir is unchanged when rootDir is provided", () => { + const absDir = path.join(tmpDir, "already-absolute"); + const helper = new QuartoProjectHelper( + "index.qmd", + "index.html", + absDir, + tmpDir, + ); + expect(helper.projectDir).toBe(absDir); + }); + + test("rootDir enables _quarto.yml detection with relative projectDir", async () => { + fs.writeFileSync( + path.join(tmpDir, "_quarto.yml"), + "project:\n type: website\n", + ); + // Without rootDir, "." would resolve against cwd (not tmpDir) + const helperWithout = new QuartoProjectHelper( + "index.qmd", + "index.html", + ".", + ); + // With rootDir, "." resolves to tmpDir where _quarto.yml exists + const helperWith = new QuartoProjectHelper( + "index.qmd", + "index.html", + ".", + tmpDir, + ); + expect(await helperWithout.isQuartoYmlPresent()).toBe(false); + expect(await helperWith.isQuartoYmlPresent()).toBe(true); + }); + + test("rootDir triggers project render with relative projectDir", async () => { + fs.writeFileSync( + path.join(tmpDir, "_quarto.yml"), + "project:\n type: website\n", + ); + + let capturedCommand: string | undefined; + mockRunTerminalCommand.mockImplementation((cmd: string) => { + if (cmd === "quarto --version") { + return Promise.resolve(0); + } + capturedCommand = cmd; + return Promise.resolve(0); + }); + + const helper = new QuartoProjectHelper( + "index.qmd", + "index.html", + ".", + tmpDir, + ); + await helper.render(); + + // Should render the resolved absolute directory + expect(capturedCommand).toBe(`quarto render "${tmpDir}" --to html`); + }); +}); + // -------------------------------------------------------------------------- // Command construction with real filesystem // -------------------------------------------------------------------------- diff --git a/extensions/vscode/src/utils/quartoProjectHelper.test.ts b/extensions/vscode/src/utils/quartoProjectHelper.test.ts index 5d9b1834a9..f10867fef2 100644 --- a/extensions/vscode/src/utils/quartoProjectHelper.test.ts +++ b/extensions/vscode/src/utils/quartoProjectHelper.test.ts @@ -106,6 +106,60 @@ describe("QuartoProjectHelper", () => { }); }); + describe("rootDir resolves relative projectDir", () => { + const rootDir = path.join(path.sep, "workspace", "my project"); + + test("relative projectDir is resolved against rootDir", async () => { + mockFileExistsAt.mockResolvedValue(false); + + const helper = new QuartoProjectHelper( + "index.qmd", + "index.html", + ".", + rootDir, + ); + expect(helper.projectDir).toBe(rootDir); + await helper.render(); + expect(mockRenderCmd).toHaveBeenCalledWith( + `quarto render "${path.join(rootDir, "index.qmd")}" --to html`, + ); + }); + + test("nested relative projectDir is resolved against rootDir", async () => { + mockFileExistsAt.mockResolvedValue(true); + + const relProjectDir = path.join("sub", "dir"); + const helper = new QuartoProjectHelper( + "index.qmd", + "index.html", + relProjectDir, + rootDir, + ); + const expected = path.resolve(rootDir, relProjectDir); + expect(helper.projectDir).toBe(expected); + await helper.render(); + expect(mockRenderCmd).toHaveBeenCalledWith( + `quarto render "${expected}" --to html`, + ); + }); + + test("absolute projectDir is unchanged when rootDir is provided", () => { + const absProjectDir = path.join(path.sep, "already", "absolute"); + const helper = new QuartoProjectHelper( + "index.qmd", + "index.html", + absProjectDir, + rootDir, + ); + expect(helper.projectDir).toBe(absProjectDir); + }); + + test("projectDir is unchanged when rootDir is omitted", () => { + const helper = new QuartoProjectHelper("index.qmd", "index.html", "."); + expect(helper.projectDir).toBe("."); + }); + }); + describe("errors", () => { test("there is no quarto binary", async () => { mockFileExistsAt.mockResolvedValue(false); diff --git a/extensions/vscode/src/utils/quartoProjectHelper.ts b/extensions/vscode/src/utils/quartoProjectHelper.ts index 7c911516b7..27c16cc44a 100644 --- a/extensions/vscode/src/utils/quartoProjectHelper.ts +++ b/extensions/vscode/src/utils/quartoProjectHelper.ts @@ -23,10 +23,23 @@ export class QuartoProjectHelper { readonly renderedEntrypoint: string; readonly projectDir: string; - constructor(source: string, renderedEntrypoint: string, projectDir: string) { + /** + * @param source - Source entrypoint filename (e.g. "index.qmd") + * @param renderedEntrypoint - Rendered output filename (e.g. "index.html") + * @param projectDir - Project directory (may be relative) + * @param rootDir - If provided, projectDir is resolved against this root to + * produce an absolute path. This ensures filesystem checks and quarto + * render commands work correctly regardless of the extension host's cwd. + */ + constructor( + source: string, + renderedEntrypoint: string, + projectDir: string, + rootDir?: string, + ) { this.source = source; this.renderedEntrypoint = renderedEntrypoint; - this.projectDir = projectDir; + this.projectDir = rootDir ? path.resolve(rootDir, projectDir) : projectDir; } async render() { diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index 3b199d8081..cf26bb582f 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -295,6 +295,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { projectDir, sourceEntrypoint, renderedEntrypoint, + this.root?.uri.fsPath, ); } diff --git a/extensions/vscode/src/views/renders.ts b/extensions/vscode/src/views/renders.ts index 7b6dc63052..464a83b40d 100644 --- a/extensions/vscode/src/views/renders.ts +++ b/extensions/vscode/src/views/renders.ts @@ -14,6 +14,7 @@ export const renderQuartoContent = async ( projectDir: string, sourceEntrypoint: string, renderedEntrypoint: string, + rootDir?: string, ) => { try { await window.withProgress( @@ -26,6 +27,7 @@ export const renderQuartoContent = async ( sourceEntrypoint, renderedEntrypoint, projectDir, + rootDir, ); await quartoHelper.render(); },