diff --git a/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts b/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts index e03b9f39f..5a94b0e4c 100644 --- a/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts +++ b/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts @@ -201,6 +201,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}"`); + }); +}); + // -------------------------------------------------------------------------- // Relative vs absolute projectDir — verifies the bug where a relative // projectDir (e.g. ".") caused isQuartoYmlPresent() to check the wrong diff --git a/extensions/vscode/src/utils/quartoProjectHelper.test.ts b/extensions/vscode/src/utils/quartoProjectHelper.test.ts index 09de62f28..5f5978710 100644 --- a/extensions/vscode/src/utils/quartoProjectHelper.test.ts +++ b/extensions/vscode/src/utils/quartoProjectHelper.test.ts @@ -106,6 +106,58 @@ describe("QuartoProjectHelper", () => { }); }); + describe("rootDir resolves relative projectDir", () => { + const rootDir = path.resolve(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")}"`, + ); + }); + + 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}"`); + }); + + test("absolute projectDir is unchanged when rootDir is provided", () => { + const absProjectDir = path.resolve(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 9a053a04f..6d6c2504c 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 d579d34a3..f43fbb36d 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -305,6 +305,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { absProjectDir, sourceEntrypoint, renderedEntrypoint, + this.root?.uri.fsPath, ); } diff --git a/extensions/vscode/src/views/renders.ts b/extensions/vscode/src/views/renders.ts index 7b6dc6305..464a83b40 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(); },