diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 2e4216d74ac..46c821a13fc 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -3,6 +3,7 @@ ## In this release - ([#14267](https://github.com/quarto-dev/quarto-cli/issues/14267)): Fix Windows paths with accented characters (e.g., `C:\Users\Sébastien\`) breaking dart-sass compilation. +- ([#14281](https://github.com/quarto-dev/quarto-cli/issues/14281)): Fix transient `.quarto_ipynb` files accumulating during `quarto preview` with Jupyter engine. ## In previous releases diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 7b85cafd0e8..edb2ac0b75c 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -427,9 +427,10 @@ export async function renderForPreview( // Invalidate file cache for the file being rendered so changes are picked up. // The project context persists across re-renders in preview mode, but the // fileInformationCache contains file content that needs to be refreshed. - // TODO(#13955): Consider adding a dedicated invalidateForFile() method on ProjectContext + // Uses invalidateForFile() to also clean up transient notebook files + // (.quarto_ipynb) from disk before removing the cache entry (#14281). if (project?.fileInformationCache) { - project.fileInformationCache.delete(file); + project.fileInformationCache.invalidateForFile(file); } // render diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index cf121d63d88..d9daaf0803c 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -22,6 +22,7 @@ import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts"; import { FileInclusion, FileInformation, + FileInformationCache, kProjectOutputDir, kProjectType, ProjectConfig, @@ -660,7 +661,7 @@ export async function projectResolveBrand( // Implements Cloneable but shares state intentionally - in preview mode, // the project context is reused across renders and cache state must persist. export class FileInformationCacheMap extends Map - implements Cloneable> { + implements FileInformationCache, Cloneable> { override get(key: string): FileInformation | undefined { return super.get(normalizePath(key)); } @@ -681,6 +682,22 @@ export class FileInformationCacheMap extends Map // return normalized keys as stored. Code iterating over the cache sees // normalized paths, which is consistent with how keys are stored. + // Removes a cache entry and cleans up any associated transient files. + // In preview mode, this should be used instead of delete() to ensure + // transient notebooks (.quarto_ipynb) are removed from disk before the + // cache entry is dropped. Without this, the collision-avoidance logic + // in jupyter.ts target() creates numbered variants on each re-render. + invalidateForFile(key: string): void { + const existing = this.get(key); + if (existing?.target?.data) { + const data = existing.target.data as { transient?: boolean }; + if (data.transient && existing.target.input) { + safeRemoveSync(existing.target.input); + } + } + this.delete(key); + } + // Returns this instance (shared reference) rather than a copy. // This is intentional: in preview mode, project context is cloned for // each render but the cache must be shared so invalidations persist. diff --git a/src/project/types.ts b/src/project/types.ts index 2c4948dc182..d36f04f2282 100644 --- a/src/project/types.ts +++ b/src/project/types.ts @@ -68,6 +68,13 @@ export type FileInformation = { brand?: LightDarkBrandDarkFlag; }; +export interface FileInformationCache extends Map { + // Removes a cache entry and cleans up any associated transient files from disk. + // Use this instead of delete() when invalidating entries that may reference + // transient notebooks (.quarto_ipynb) to prevent file accumulation. + invalidateForFile(key: string): void; +} + export interface ProjectContext extends Cloneable { dir: string; engines: string[]; @@ -76,7 +83,7 @@ export interface ProjectContext extends Cloneable { notebookContext: NotebookContext; outputNameIndex?: Map; - fileInformationCache: Map; + fileInformationCache: FileInformationCache; // This is a cache of _brand.yml for a project brandCache?: { brand?: LightDarkBrandDarkFlag }; @@ -182,7 +189,7 @@ export interface EngineProjectContext { * For file information cache management * Used for the transient notebook tracking in Jupyter */ - fileInformationCache: Map; + fileInformationCache: FileInformationCache; /** * Get the output directory for the project diff --git a/tests/unit/project/file-information-cache.test.ts b/tests/unit/project/file-information-cache.test.ts index 9f552e9c96b..666a0d2915b 100644 --- a/tests/unit/project/file-information-cache.test.ts +++ b/tests/unit/project/file-information-cache.test.ts @@ -9,6 +9,8 @@ import { unitTest } from "../../test.ts"; import { assert } from "testing/asserts"; +import { asMappedString } from "../../../src/core/lib/mapped-text.ts"; +import { existsSync } from "../../../src/deno_ral/fs.ts"; import { join, relative } from "../../../src/deno_ral/path.ts"; import { ensureFileInformationCache, @@ -130,3 +132,78 @@ unitTest( ); }, ); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile deletes transient notebook file", + async () => { + const project = createMockProjectContext(); + const sourcePath = join(project.dir, "doc.qmd"); + + // Create a real temp file simulating a transient .quarto_ipynb + const notebookPath = join(project.dir, "doc.quarto_ipynb"); + Deno.writeTextFileSync(notebookPath, '{"cells": []}'); + assert(existsSync(notebookPath), "Temp notebook file should exist"); + + // Populate cache entry with a transient target pointing to the file + const entry = ensureFileInformationCache(project, sourcePath); + entry.target = { + source: sourcePath, + input: notebookPath, + markdown: asMappedString(""), + metadata: {}, + data: { transient: true, kernelspec: {} }, + }; + + // Invalidate the cache entry for this file + project.fileInformationCache.invalidateForFile(sourcePath); + + // The transient file should be deleted from disk + assert( + !existsSync(notebookPath), + "Transient notebook file should be deleted on invalidation", + ); + // The cache entry should be removed + assert( + !project.fileInformationCache.has(sourcePath), + "Cache entry should be removed after invalidation", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile preserves non-transient files", + async () => { + const project = createMockProjectContext(); + const sourcePath = join(project.dir, "notebook.ipynb"); + + // Create a real file simulating a user's .ipynb (non-transient) + const notebookPath = join(project.dir, "notebook.ipynb"); + Deno.writeTextFileSync(notebookPath, '{"cells": []}'); + + // Populate cache entry with a non-transient target + const entry = ensureFileInformationCache(project, sourcePath); + entry.target = { + source: sourcePath, + input: notebookPath, + markdown: asMappedString(""), + metadata: {}, + data: { transient: false, kernelspec: {} }, + }; + + // Invalidate the cache entry + project.fileInformationCache.invalidateForFile(sourcePath); + + // The non-transient file should NOT be deleted + assert( + existsSync(notebookPath), + "Non-transient file should be preserved on invalidation", + ); + // But the cache entry should still be removed + assert( + !project.fileInformationCache.has(sourcePath), + "Cache entry should be removed after invalidation", + ); + }, +);