diff --git a/webviews/dev-server/galleryHost.ts b/webviews/dev-server/galleryHost.ts index cf0400aad155..53997fc5ccd5 100644 --- a/webviews/dev-server/galleryHost.ts +++ b/webviews/dev-server/galleryHost.ts @@ -6,8 +6,8 @@ // virtual:cmux-gallery/themes every theme the app ships (Resources/ghostty/themes, Resources/cmux-themes) // virtual:cmux-gallery/web-theme WebTheme.bootstrapScript, the script the app injects at // document start in every cmux web view (WebTheme.swift) -// virtual:cmux-gallery/agent-pane.css the agent pane's shipped stylesheet, the files and the -// order scripts/cmux-next/build-agent-pane-web.sh inlines +// virtual:cmux-gallery/agent-pane.css the agent pane's shipped stylesheet: the sheets dev.tsx +// reaches, in the order build-agent-pane-web.sh inlines them // virtual:cmux-gallery/metrics the chrome metrics (MetricTunables.swift: sidebar width, // tab strip height, ...) per density, for window mode // virtual:cmux-gallery/fixtures every shared fixture JSON (schemas/gallery/fixtures.json @@ -17,10 +17,12 @@ // virtual:cmux-gallery/revision the checkout's commit (sha, subject, commit time, branch), // read when the module loads; the live server pushes newer // ones (galleryLive.ts) -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import type { Plugin } from "vite-plus"; import { parseGhosttyTheme, type GhosttyTheme } from "../src/gallery/theme/ghostty"; @@ -32,6 +34,7 @@ export const CMUX_THEMES_DIR = path.join(repoRoot, "Resources/cmux-themes"); const WEB_THEME_SWIFT = path.join(repoRoot, "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift"); const PANE_BUILD_SCRIPT = path.join(repoRoot, "scripts/cmux-next/build-agent-pane-web.sh"); const SESSION = path.join(webviewsRoot, "src/agent-session"); +const PANE_TAILWIND = path.join(SESSION, "acpmux/tailwind.css"); const galleryDir = path.join(webviewsRoot, "src/gallery"); const FIXTURE_INDEX = path.join(repoRoot, "schemas/gallery/fixtures.json"); @@ -117,21 +120,99 @@ export function readWebThemeBootstrap(file = WEB_THEME_SWIFT): string { } /** - * The pane's stylesheets in shipped order: desktop.css, the shared stylesheet without its Tailwind - * @import lines, then every `$SRC/...css` file the build script concatenates. (KaTeX's stylesheet - * loads beside it; the shipped copy only inlines its fonts.) + * The pane's stylesheets in shipped order, derived as build-agent-pane-web.sh derives them: every + * sheet dev.tsx (the dev server's entry) reaches, in the order its modules run (bundle.mjs + * --css-inputs-only, cssInImportOrder), with Tailwind's entry last, where the script compiles it. + * No list to parse here: a sheet a component imports lands in the gallery pane as in the app. + * (KaTeX's stylesheet loads beside it; the shipped copy only inlines its fonts.) */ -export function agentPaneStylesheets(script = PANE_BUILD_SCRIPT): string[] { - const text = fs.readFileSync(script, "utf8"); - // `$SRC/...` (the session sources) and `$ROOT/webviews/src/...` (shared ui/ files such as - // ui/popupSurface.css), in the script's order, so the gallery pane matches the shipped one. - const files = [...text.matchAll(/"\$(SRC|ROOT\/webviews\/src)\/([^"$]+\.css)"/g)].map((match) => - match[1] === "SRC" ? path.join(SESSION, match[2]!) : path.join(webviewsRoot, "src", match[2]!), - ); - if (!files.some((file) => file.endsWith("shared/styles.css"))) - throw new Error("gallery: build-agent-pane-web.sh no longer names shared/styles.css; update agentPaneStylesheets"); - const desktop = path.join(webviewsRoot, "src/pages/shared/desktop.css"); - return [desktop, ...files.filter((file) => file !== desktop)]; +export function agentPaneStylesheets(): string[] { + return (paneSheets ??= derivePaneSheets()); +} + +let paneSheets: string[] | undefined; + +/** bundle.mjs's arguments: the script's own command, writing the sheet list to `list`. */ +function paneSheetsArgs(work: string, list: string): string[] { + return [ + "scripts/agent-pane/bundle.mjs", + path.join(SESSION, "acpmux/dev.tsx"), + path.join(SESSION, "acpmux/shiki"), + path.join(work, "order/dev.js"), + "--split", + "--css-inputs", + list, + "--css-inputs-only", + ]; +} + +/** The list bundle.mjs wrote, as absolute paths, Tailwind's entry moved last. */ +function readPaneSheets(list: string): string[] { + const sheets = fs + .readFileSync(list, "utf8") + .split("\n") + .filter(Boolean) + .map((sheet) => path.join(webviewsRoot, sheet)) + .filter((file) => file !== PANE_TAILWIND); + if (!sheets.some((file) => file.endsWith("shared/styles.css"))) + throw new Error("gallery: dev.tsx's sheets leave out shared/styles.css; the pane would render unstyled"); + return [...sheets, PANE_TAILWIND]; +} + +// process.execPath: bun or node, whichever runs the gallery (touched.ts, Vite). +function derivePaneSheets(): string[] { + const work = fs.mkdtempSync(path.join(os.tmpdir(), "cmux-gallery-pane-sheets-")); + try { + const list = path.join(work, "page-sheets"); + execFileSync(process.execPath, paneSheetsArgs(work, list), { + cwd: webviewsRoot, + stdio: ["ignore", "ignore", "inherit"], + }); + return readPaneSheets(list); + } finally { + fs.rmSync(work, { recursive: true, force: true }); + } +} + +async function derivePaneSheetsAsync(): Promise { + const work = await fs.promises.mkdtemp(path.join(os.tmpdir(), "cmux-gallery-pane-sheets-")); + try { + const list = path.join(work, "page-sheets"); + await promisify(execFile)(process.execPath, paneSheetsArgs(work, list), { cwd: webviewsRoot }); + return readPaneSheets(list); + } finally { + await fs.promises.rm(work, { recursive: true, force: true }); + } +} + +let rederiving: Promise | undefined; +let rederiveAgain = false; + +/** + * The dev server's answer to a saved source, which may add or drop a stylesheet import: the list + * derived again off the save's path, and `changed` called when it differs. A save while one runs + * runs it once more after (it may have read the file before the save); it stops when no save came. + */ +function rederivePaneSheets(changed: () => void): void { + if (rederiving) { + rederiveAgain = true; + return; + } + rederiving = (async () => { + do { + rederiveAgain = false; + try { + const next = await derivePaneSheetsAsync(); + const before = paneSheets; + paneSheets = next; + if (before && before.join("\n") !== next.join("\n")) changed(); + } catch (error) { + console.warn(`gallery: the agent pane's sheets were not derived again: ${String(error)}`); + } + } while (rederiveAgain); + })().finally(() => { + rederiving = undefined; + }); } export type Revision = { sha: string; subject: string; committedAt: number; branch: string }; @@ -160,24 +241,12 @@ export function readRevision(root = repoRoot): Revision { } function agentPaneCSS(): string { - const inlineRelativeImports = (file: string, css: string, seen = new Set()): string => - css.replace(/^@import\s+["']([^"']+)["'];\s*$/gm, (statement, specifier: string) => { - if (!specifier.startsWith(".")) return statement; - const imported = path.resolve(path.dirname(file), specifier); - if (seen.has(imported)) return ""; - seen.add(imported); - if (!fs.existsSync(imported)) return statement; - return inlineRelativeImports(imported, fs.readFileSync(imported, "utf8"), seen); - }); - + // As the script: a sheet's own @import never loads in the page (the walk already lists an imported + // local sheet before its importer); Tailwind's entry keeps its import and gets the pane's sources. return agentPaneStylesheets() .map((file) => { const css = fs.readFileSync(file, "utf8"); - const body = file.endsWith("shared/styles.css") - ? css.replace(/^@import .*$/gm, "") - : file.endsWith("acpmux/tailwind.css") - ? `${css}\n${tailwindSources()}` - : inlineRelativeImports(file, css); + const body = file === PANE_TAILWIND ? `${css}\n${tailwindSources()}` : css.replace(/^@import .*$/gm, ""); return `/* ${path.relative(webviewsRoot, file)} */\n${body}`; }) .join("\n"); @@ -261,7 +330,18 @@ export function galleryModules(): Plugin { }); }, handleHotUpdate({ file, server, modules }) { - // A save of any pane stylesheet updates the combined sheet in place. + // A saved source may add or drop a stylesheet import: the list is derived again after this + // update, and the combined sheet reloads when it changed. A save of a pane stylesheet updates + // the combined sheet in place. + if (/\.(tsx?|mjs)$/.test(file) && file.startsWith(`${webviewsRoot}/src/`)) { + rederivePaneSheets(() => { + const combined = server.moduleGraph.getModuleById(PANE_CSS_PATH); + if (!combined) return; + server.moduleGraph.invalidateModule(combined); + server.ws.send({ type: "full-reload" }); + }); + return undefined; + } if (!agentPaneStylesheets().includes(file)) return undefined; const combined = server.moduleGraph.getModuleById(PANE_CSS_PATH); if (!combined) return undefined; diff --git a/webviews/scripts/gallery/touched.ts b/webviews/scripts/gallery/touched.ts index b4a74514fe68..2facea0800ab 100644 --- a/webviews/scripts/gallery/touched.ts +++ b/webviews/scripts/gallery/touched.ts @@ -29,6 +29,10 @@ const EVERYTHING = [ /^webviews\/scripts\/gallery\//, /^webviews\/dev-server\/galleryHost\.ts$/, /^scripts\/cmux-next\/build-agent-pane-web\.sh$/, + // The pane's sheets and their order (build-agent-pane-web.sh derives the page's