diff --git a/README.md b/README.md index f74b332..e023aec 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ The session UI is built for small screens first, then scales up to desktop. Tabs ### Workspace Explorer -The responsive Explorer opens the active session's working directory as a lazy-loaded file tree and a full CodeMirror editor. It supports syntax highlighting, multiple closeable tabs, conflict-aware saves, search and editor shortcuts, line wrapping, pinch or slider font resizing, and a resizable or collapsible tree. Desktop keeps chat, tree, and editor visible together; phones and touch-first foldables switch cleanly between the tree and editor without summoning the keyboard until the editor is tapped. +The responsive Explorer opens the active session's working directory as a lazy-loaded file tree and a full CodeMirror editor. A dedicated Artifacts scope presents generated project output as a visual gallery, with large interactive previews for images, sandboxed HTML, rendered Markdown, video, and PDFs—without digging through Pi's internal storage folders. Workspace and Artifacts each preserve their folder, scroll, and preview state when switching views or reopening the panel. Browser Back returns an open file or artifact to its prior tree or gallery before closing the panel on the next step. The Explorer supports syntax highlighting, multiple closeable tabs, conflict-aware saves, search and editor shortcuts, line wrapping, pinch or slider font resizing, and a resizable or collapsible tree. Desktop keeps chat, tree, and editor visible together; phones and touch-first foldables switch cleanly between the tree and editor without summoning the keyboard until the editor is tapped. File access stays scoped to the session working directory. The server rejects path traversal and escaping symlinks, detects binary and oversized files, writes atomically, and uses revisions to prevent silently overwriting changes made elsewhere. diff --git a/index.html b/index.html index ef80e6c..65bb76d 100644 --- a/index.html +++ b/index.html @@ -84,8 +84,11 @@

Scan to connect to pi web

- -

Explorer

+ +

Explorer

@@ -93,7 +96,29 @@

Explorer

- +
@@ -111,6 +136,22 @@

Explorer

+
+
+ +
+ Artifact + +
+
+ Open + Download +
+
+
+
diff --git a/server.ts b/server.ts index e560a4c..6273960 100644 --- a/server.ts +++ b/server.ts @@ -1,5 +1,5 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { createReadStream, existsSync } from "node:fs"; +import { createReadStream, existsSync, statSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { extname, join, resolve } from "node:path"; import { createServer as createViteServer, type ViteDevServer } from "vite"; @@ -38,6 +38,8 @@ const mockMode = process.env.PI_WEB_MOCK === "1"; const contentTypes: Record = { ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".xhtml": "application/xhtml+xml", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", @@ -49,6 +51,12 @@ const contentTypes: Record = { ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", + ".bmp": "image/bmp", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".ogv": "video/ogg", + ".pdf": "application/pdf", }; function sendJson(res: ServerResponse, status: number, value: unknown) { @@ -60,8 +68,8 @@ function sendJson(res: ServerResponse, status: number, value: unknown) { res.end(body); } -function pipeReadStream(res: ServerResponse, file: string) { - const stream = createReadStream(file); +function pipeReadStream(res: ServerResponse, file: string, range?: { start: number; end: number }) { + const stream = createReadStream(file, range); res.on("close", () => stream.destroy()); stream.pipe(res); } @@ -88,20 +96,62 @@ async function readBody(req: IncomingMessage): Promise { return text ? JSON.parse(text) : {}; } -function serveArtifact(req: IncomingMessage, res: ServerResponse) { +async function serveArtifact(req: IncomingMessage, res: ServerResponse, sessionScoped = false) { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); - const artifactPath = decodeURIComponent(url.pathname.slice("/api/artifacts/".length)); - if (!isValidArtifactPath(artifactPath)) return sendJson(res, 400, { ok: false, error: "Invalid artifact path" }); - - const artifactRoots = new Set([piCwd, ...knownCwds, ...sessionService.knownCwds()]); + const routePrefix = sessionScoped ? "/api/session-artifacts/" : "/api/artifacts/"; + const routePath = url.pathname.slice(routePrefix.length); + const separator = routePath.indexOf("/"); + const pathSessionId = sessionScoped && separator > 0 ? decodeURIComponent(routePath.slice(0, separator)) : ""; + const artifactPath = decodeURIComponent(sessionScoped ? routePath.slice(separator + 1) : routePath); + if ((sessionScoped && !pathSessionId) || !isValidArtifactPath(artifactPath)) return sendJson(res, 400, { ok: false, error: "Invalid artifact path" }); + + let preferredCwd = ""; + const requestedSessionId = pathSessionId || url.searchParams.get("sessionId"); + if (requestedSessionId) { + try { preferredCwd = await sessionService.cwdForSessionId(requestedSessionId); } catch { + if (sessionScoped) return sendJson(res, 404, { ok: false, error: "Session not found" }); + } + } + const artifactRoots = sessionScoped + ? new Set([preferredCwd]) + : new Set([...(preferredCwd ? [preferredCwd] : []), piCwd, ...knownCwds, ...sessionService.knownCwds()]); const resolvedFile = findArtifactFile(artifactRoots, artifactPath); if (!resolvedFile) return sendJson(res, 404, { ok: false, error: "Artifact not found" }); - res.writeHead(200, { + const size = statSync(resolvedFile).size; + const headers: Record = { "content-type": contentTypes[extname(resolvedFile).toLowerCase()] || "application/octet-stream", + "content-length": size, + "accept-ranges": "bytes", "cache-control": "no-store", + }; + const rangeHeader = req.headers.range?.trim(); + if (!rangeHeader) { + res.writeHead(200, headers); + pipeReadStream(res, resolvedFile); + return; + } + + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader); + let start = match?.[1] ? Number(match[1]) : 0; + let end = match?.[2] ? Number(match[2]) : size - 1; + if (match && !match[1] && match[2]) { + const suffixLength = Number(match[2]); + start = Math.max(0, size - suffixLength); + end = size - 1; + } + if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) { + res.writeHead(416, { ...headers, "content-length": 0, "content-range": `bytes */${size}` }); + res.end(); + return; + } + end = Math.min(end, size - 1); + res.writeHead(206, { + ...headers, + "content-length": end - start + 1, + "content-range": `bytes ${start}-${end}/${size}`, }); - pipeReadStream(res, resolvedFile); + pipeReadStream(res, resolvedFile, { start, end }); } function serveStatic(req: IncomingMessage, res: ServerResponse) { @@ -341,8 +391,11 @@ const server = createServer(async (req, res) => { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); if (url.pathname.startsWith("/api/")) { + if (method === "GET" && url.pathname.startsWith("/api/session-artifacts/")) { + return await serveArtifact(req, res, true); + } if (method === "GET" && url.pathname.startsWith("/api/artifacts/")) { - return serveArtifact(req, res); + return await serveArtifact(req, res); } if (!isAuthorized(req)) return unauthorized(res); diff --git a/src/app/types.ts b/src/app/types.ts index a8e283f..424018e 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -343,6 +343,8 @@ export const defaultPiWebSettings: PiWebSettings = { const tokenStorageKey = "pi-web-token"; const collapsedFoldersStorageKey = "pi-web-collapsed-session-folders"; const sessionIdUrlParam = "sessionId"; +const sessionIdHistoryStateKey = "piWebSessionId"; +const sessionScopedHistoryStateKeys = ["piWebArtifactView", "piWebWorkspaceView"] as const; function consumeUrlToken() { const urlToken = new URLSearchParams(location.search).get("token"); @@ -358,12 +360,31 @@ export function readActiveSessionIdFromUrl() { return new URLSearchParams(location.search).get(sessionIdUrlParam) || ""; } +function objectHistoryState(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +export function readActiveSessionIdFromHistoryState(value: unknown = history.state): string | undefined { + const sessionId = objectHistoryState(value)[sessionIdHistoryStateKey]; + return typeof sessionId === "string" ? sessionId : undefined; +} + +export function syncActiveSessionIdHistoryState(sessionId: string) { + if (readActiveSessionIdFromHistoryState() === sessionId) return; + history.replaceState({ ...objectHistoryState(history.state), [sessionIdHistoryStateKey]: sessionId }, ""); +} + export function writeActiveSessionIdToUrl(sessionId: string, mode: "push" | "replace" = "push") { const url = new URL(location.href); if (sessionId) url.searchParams.set(sessionIdUrlParam, sessionId); else url.searchParams.delete(sessionIdUrlParam); - if (url.href === location.href) return; - history[mode === "replace" ? "replaceState" : "pushState"](null, "", url.toString()); + if (url.href === location.href) { + syncActiveSessionIdHistoryState(sessionId); + return; + } + const nextState: Record = { ...objectHistoryState(history.state), [sessionIdHistoryStateKey]: sessionId }; + for (const key of sessionScopedHistoryStateKeys) delete nextState[key]; + history[mode === "replace" ? "replaceState" : "pushState"](nextState, "", url.toString()); } function readCollapsedSessionFolders() { diff --git a/src/files/artifactBrowser.ts b/src/files/artifactBrowser.ts new file mode 100644 index 0000000..95980d1 --- /dev/null +++ b/src/files/artifactBrowser.ts @@ -0,0 +1,409 @@ +import { renderStandaloneMarkdown } from "../markdown/render.js"; + +type ArtifactEntry = { name: string; path: string; kind: "file" | "directory" | "symlink"; size?: number }; +type ArtifactKind = "image" | "html" | "markdown" | "video" | "pdf" | "file"; + +export const artifactRootPath = ".pi/web/artifacts"; +const artifactHistoryStateKey = "piWebArtifactView"; + +type ArtifactHistoryState = + | { view: "gallery" } + | { view: "preview"; entry: ArtifactEntry }; + +class ArtifactRequestError extends Error { + constructor(message: string, readonly status: number) { + super(message); + this.name = "ArtifactRequestError"; + } +} + +function artifactKind(path: string): ArtifactKind { + const lower = path.toLowerCase(); + if (/\.(?:png|jpe?g|gif|webp|svg|bmp)$/.test(lower)) return "image"; + if (/\.(?:html?|xhtml)$/.test(lower)) return "html"; + if (/\.(?:md|markdown)$/.test(lower)) return "markdown"; + if (/\.(?:mp4|webm|mov|ogv)$/.test(lower)) return "video"; + if (lower.endsWith(".pdf")) return "pdf"; + return "file"; +} + +function artifactKindLabel(kind: ArtifactKind) { + return ({ image: "Image", html: "Interactive HTML", markdown: "Markdown", video: "Video", pdf: "PDF", file: "File" } as const)[kind]; +} + +function videoMimeType(path: string) { + const lower = path.toLowerCase(); + if (lower.endsWith(".mp4")) return "video/mp4"; + if (lower.endsWith(".webm")) return "video/webm"; + if (lower.endsWith(".mov")) return "video/quicktime"; + if (lower.endsWith(".ogv")) return "video/ogg"; + return "video/*"; +} + +function formatFileSize(size?: number) { + if (!Number.isFinite(size)) return ""; + const bytes = Number(size); + if (bytes < 1_024) return `${bytes} B`; + if (bytes < 1_024 * 1_024) return `${Math.round(bytes / 1_024)} KB`; + return `${(bytes / 1_024 / 1_024).toFixed(bytes < 10 * 1_024 * 1_024 ? 1 : 0)} MB`; +} + +function fileExtension(name: string) { + const extension = name.includes(".") ? name.split(".").pop() || "" : ""; + return extension.slice(0, 5).toUpperCase() || "FILE"; +} + +function artifactRelativePath(path: string) { + if (path === artifactRootPath) return ""; + return path.startsWith(`${artifactRootPath}/`) ? path.slice(artifactRootPath.length + 1) : path; +} + +function parentArtifactPath(path: string) { + if (path === artifactRootPath) return artifactRootPath; + const parent = path.split("/").slice(0, -1).join("/"); + return parent.startsWith(artifactRootPath) ? parent : artifactRootPath; +} + +export type ArtifactBrowserController = { + refresh(): void; + reset(): void; +}; + +export function initArtifactBrowser(options: { + panel: HTMLElement; + tree: HTMLElement; + apiHeaders: () => HeadersInit; + getSessionId: () => string; +}): ArtifactBrowserController { + const { panel, tree, apiHeaders, getSessionId } = options; + const explorer = panel.querySelector(".filesExplorer")!; + const galleryBack = panel.querySelector("#artifactsGalleryBack")!; + const breadcrumb = panel.querySelector("#artifactsGalleryBreadcrumb")!; + const galleryCount = panel.querySelector("#artifactsGalleryCount")!; + const preview = panel.querySelector("#artifactBrowserPreview")!; + const previewBack = panel.querySelector("#artifactBrowserPreviewBack")!; + const previewTitle = panel.querySelector("#artifactBrowserPreviewTitle")!; + const previewBody = panel.querySelector("#artifactBrowserPreviewBody")!; + const previewOpen = panel.querySelector("#artifactBrowserPreviewOpen")!; + const previewDownload = panel.querySelector("#artifactBrowserPreviewDownload")!; + let deferredObserver: IntersectionObserver | undefined; + let deferredLoads = new WeakMap void>(); + const directoryScrollPositions = new Map(); + let currentDirectory = artifactRootPath; + let activeEntry: ArtifactEntry | undefined; + let loadGeneration = 0; + let previewGeneration = 0; + + function historyRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + } + + function artifactHistoryState(value: unknown = history.state): ArtifactHistoryState | undefined { + const candidate = historyRecord(value)[artifactHistoryStateKey]; + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return undefined; + const record = candidate as Record; + if (record.view === "gallery") return { view: "gallery" }; + const entry = record.entry as Partial | undefined; + if (record.view !== "preview" || !entry || typeof entry.name !== "string" || typeof entry.path !== "string" || !entry.path.startsWith(`${artifactRootPath}/`) || !["file", "symlink"].includes(entry.kind || "")) return undefined; + return { view: "preview", entry: { name: entry.name, path: entry.path, kind: entry.kind as ArtifactEntry["kind"], size: typeof entry.size === "number" ? entry.size : undefined } }; + } + + function replaceArtifactHistory(state: ArtifactHistoryState) { + history.replaceState({ ...historyRecord(history.state), [artifactHistoryStateKey]: state }, ""); + } + + function pushArtifactPreviewHistory(entry: ArtifactEntry) { + replaceArtifactHistory({ view: "gallery" }); + history.pushState({ ...historyRecord(history.state), [artifactHistoryStateKey]: { view: "preview", entry } satisfies ArtifactHistoryState }, ""); + } + + function query(path = "") { + const params = new URLSearchParams({ sessionId: getSessionId() }); + if (path) params.set("path", path); + return params; + } + + async function responseJson(res: Response) { + const data = await res.json().catch(() => ({})); + if (!res.ok || data.ok === false) throw new ArtifactRequestError(data.error || res.statusText, res.status); + return data; + } + + function artifactUrl(path: string) { + const relative = artifactRelativePath(path); + const encoded = relative.split("/").filter(Boolean).map(encodeURIComponent).join("/"); + const sessionId = getSessionId(); + return sessionId + ? `/api/session-artifacts/${encodeURIComponent(sessionId)}/${encoded}` + : `/api/artifacts/${encoded}`; + } + + function clearDeferredPreviews() { + deferredObserver?.disconnect(); + deferredObserver = undefined; + deferredLoads = new WeakMap(); + } + + function deferPreview(element: Element, load: () => void) { + if (!("IntersectionObserver" in window)) { load(); return; } + deferredObserver ??= new IntersectionObserver((entries, observer) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + observer.unobserve(entry.target); + const loadEntry = deferredLoads.get(entry.target); + deferredLoads.delete(entry.target); + loadEntry?.(); + } + }, { root: explorer, rootMargin: "240px 0px" }); + deferredLoads.set(element, load); + deferredObserver.observe(element); + } + + function renderGalleryState(kind: "loading" | "empty" | "error", title: string, description = "") { + tree.textContent = ""; + tree.className = "filesTree artifactGallery fileTreeContainer--state"; + const state = document.createElement("div"); + state.className = `artifactGalleryState artifactGalleryState--${kind}`; + state.setAttribute("role", kind === "error" ? "alert" : "status"); + const icon = document.createElement("span"); icon.className = "artifactGalleryStateIcon"; icon.setAttribute("aria-hidden", "true"); + if (kind === "loading") icon.classList.add("artifactGallerySpinner"); + else icon.innerHTML = kind === "error" + ? '' + : ''; + const copy = document.createElement("span"); copy.className = "artifactGalleryStateCopy"; + const heading = document.createElement("strong"); heading.textContent = title; copy.append(heading); + if (description) { const detail = document.createElement("span"); detail.textContent = description; copy.append(detail); } + state.append(icon, copy); tree.append(state); + } + + function directoryPathAt(index: number, segments: string[]) { + return [artifactRootPath, ...segments.slice(0, index + 1)].join("/"); + } + + function renderBreadcrumb(count?: number) { + breadcrumb.textContent = ""; + const relative = artifactRelativePath(currentDirectory); + const segments = relative ? relative.split("/") : []; + const root = document.createElement("button"); root.type = "button"; root.textContent = "Artifacts"; + root.disabled = !segments.length; root.addEventListener("click", () => void loadDirectory(artifactRootPath)); breadcrumb.append(root); + segments.forEach((segment, index) => { + const separator = document.createElement("span"); separator.textContent = "/"; separator.setAttribute("aria-hidden", "true"); breadcrumb.append(separator); + const item = document.createElement("button"); item.type = "button"; item.textContent = segment; item.disabled = index === segments.length - 1; + item.addEventListener("click", () => void loadDirectory(directoryPathAt(index, segments))); breadcrumb.append(item); + }); + galleryBack.disabled = currentDirectory === artifactRootPath; + galleryCount.textContent = typeof count === "number" ? `${count} ${count === 1 ? "item" : "items"}` : ""; + } + + function renderFolderPreview(host: HTMLElement) { + host.className = "artifactGalleryCardVisual artifactGalleryCardVisual--folder"; + host.innerHTML = ''; + } + + async function loadMarkdownExcerpt(entry: ArtifactEntry, host: HTMLElement, generation: number) { + try { + const data = await responseJson(await fetch(`/api/files/read?${query(entry.path)}`, { headers: apiHeaders() })); + if (generation !== loadGeneration || !host.isConnected) return; + const lines = String(data.content || "").split(/\r?\n/).map((line) => line.replace(/^\s{0,3}#{1,6}\s+/, "").replace(/[*_`>#]/g, "").trim()).filter(Boolean); + host.textContent = ""; + const heading = document.createElement("strong"); heading.textContent = lines[0] || entry.name; host.append(heading); + const excerpt = document.createElement("span"); excerpt.textContent = lines.slice(1).join(" ").slice(0, 180) || "Rendered Markdown artifact"; host.append(excerpt); + } catch { /* Keep the designed Markdown placeholder. */ } + } + + function renderCardPreview(entry: ArtifactEntry, host: HTMLElement, generation: number) { + if (entry.kind === "directory") { renderFolderPreview(host); return; } + const kind = artifactKind(entry.path); + host.className = `artifactGalleryCardVisual artifactGalleryCardVisual--${kind}`; + const url = artifactUrl(entry.path); + if (kind === "image") { + const image = document.createElement("img"); image.alt = ""; image.loading = "lazy"; image.decoding = "async"; host.append(image); + deferPreview(image, () => { image.src = url; }); + return; + } + if (kind === "html") { + const frame = document.createElement("iframe"); frame.title = `Thumbnail of ${entry.name}`; frame.tabIndex = -1; frame.inert = true; frame.loading = "lazy"; frame.setAttribute("aria-hidden", "true"); frame.setAttribute("sandbox", "allow-scripts"); host.append(frame); + deferPreview(frame, () => { frame.src = url; }); + return; + } + if (kind === "markdown") { + host.innerHTML = 'M↓Markdown'; + deferPreview(host, () => { void loadMarkdownExcerpt(entry, host, generation); }); + return; + } + if (kind === "video") { + const video = document.createElement("video"); video.muted = true; video.playsInline = true; video.preload = "metadata"; host.append(video); + const play = document.createElement("span"); play.className = "artifactGalleryPlay"; play.innerHTML = ''; host.append(play); + deferPreview(video, () => { video.src = url; }); + return; + } + const mark = document.createElement("span"); mark.className = "artifactGalleryFileMark"; mark.textContent = fileExtension(entry.name); host.append(mark); + } + + function createGalleryCard(entry: ArtifactEntry, generation: number) { + const card = document.createElement("article"); + card.className = `artifactGalleryCard${entry.kind === "directory" ? " artifactGalleryCard--folder" : ""}`; + card.dataset.artifactPath = entry.path; + const visual = document.createElement("div"); renderCardPreview(entry, visual, generation); + const metadata = document.createElement("div"); metadata.className = "artifactGalleryCardMeta"; + const name = document.createElement("strong"); name.textContent = entry.name; name.title = entry.name; + const detail = document.createElement("span"); + detail.textContent = entry.kind === "directory" ? "Folder" : [artifactKindLabel(artifactKind(entry.path)), formatFileSize(entry.size)].filter(Boolean).join(" · "); + metadata.append(name, detail); + const open = document.createElement("button"); open.type = "button"; open.className = "artifactGalleryCardOpen"; + open.setAttribute("aria-label", entry.kind === "directory" ? `Open folder ${entry.name}` : `Preview ${entry.name}`); + open.addEventListener("click", () => entry.kind === "directory" ? void loadDirectory(entry.path) : showPreview(entry)); + card.append(visual, metadata, open); return card; + } + + function renderGallery(entries: ArtifactEntry[], generation: number) { + tree.textContent = ""; + tree.className = "filesTree artifactGallery"; + for (const entry of entries) tree.append(createGalleryCard(entry, generation)); + } + + async function loadDirectory(path: string) { + directoryScrollPositions.set(currentDirectory, explorer.scrollTop); + currentDirectory = path; + activeEntry = undefined; + panel.dataset.artifactView = "gallery"; + const generation = ++loadGeneration; + clearDeferredPreviews(); renderBreadcrumb(); renderGalleryState("loading", "Loading artifacts…"); tree.setAttribute("aria-busy", "true"); + try { + const data = await responseJson(await fetch(`/api/files/tree?${query(path)}`, { headers: apiHeaders() })); + if (generation !== loadGeneration) return; + const entries = (data.entries as ArtifactEntry[]).filter((entry) => !entry.name.startsWith(".")); + renderBreadcrumb(entries.length); + if (entries.length) renderGallery(entries, generation); + else renderGalleryState("empty", path === artifactRootPath ? "No artifacts yet" : "Nothing in this folder", path === artifactRootPath ? "Generated images, pages, reports, and videos will appear here." : "This artifact folder is empty."); + } catch (error) { + if (generation !== loadGeneration) return; + renderBreadcrumb(0); + if (path === artifactRootPath && error instanceof ArtifactRequestError && error.status === 404) { + renderGalleryState("empty", "No artifacts yet", "Generated images, pages, reports, and videos will appear here."); + } else { + renderGalleryState("error", "Couldn’t load artifacts", error instanceof Error ? error.message : String(error)); + } + } finally { + if (generation === loadGeneration) { + tree.removeAttribute("aria-busy"); + const scrollTop = directoryScrollPositions.get(path) || 0; + requestAnimationFrame(() => { + if (generation === loadGeneration && currentDirectory === path && panel.dataset.filesScope === "artifacts" && panel.dataset.artifactView === "gallery") explorer.scrollTop = scrollTop; + }); + } + } + } + + function renderPreviewLoading(label = "Loading preview…") { + previewBody.className = "artifactBrowserPreviewBody artifactBrowserPreviewBody--loading"; + previewBody.textContent = ""; + const spinner = document.createElement("span"); spinner.className = "artifactBrowserPreviewSpinner"; spinner.setAttribute("aria-hidden", "true"); + const text = document.createElement("span"); text.textContent = label; previewBody.append(spinner, text); + } + + function renderPreviewError(message: string) { + previewBody.className = "artifactBrowserPreviewBody artifactBrowserPreviewBody--error"; + previewBody.textContent = ""; + const title = document.createElement("strong"); title.textContent = "Preview unavailable"; + const detail = document.createElement("span"); detail.textContent = message; previewBody.append(title, detail); + } + + async function renderTextPreview(entry: ArtifactEntry, kind: ArtifactKind, generation: number) { + try { + const data = await responseJson(await fetch(`/api/files/read?${query(entry.path)}`, { headers: apiHeaders() })); + if (generation !== previewGeneration || activeEntry?.path !== entry.path) return; + previewBody.className = `artifactBrowserPreviewBody artifactBrowserPreviewBody--${kind}`; + previewBody.textContent = ""; + if (kind === "markdown") renderStandaloneMarkdown(previewBody, String(data.content || "")); + else { const pre = document.createElement("pre"); pre.textContent = String(data.content || ""); previewBody.append(pre); } + } catch (error) { + if (generation !== previewGeneration) return; + renderPreviewError(error instanceof Error ? error.message : String(error)); + } + } + + function renderPreview(entry: ArtifactEntry) { + const generation = ++previewGeneration; + const kind = artifactKind(entry.path); + const url = artifactUrl(entry.path); + preview.dataset.artifactKind = kind; + previewTitle.textContent = entry.name; + previewOpen.href = url; + previewDownload.href = url; + previewDownload.download = entry.name; + renderPreviewLoading(); + if (kind === "image") { + previewBody.className = "artifactBrowserPreviewBody artifactBrowserPreviewBody--image"; previewBody.textContent = ""; + const image = document.createElement("img"); image.alt = entry.name; image.src = url; + image.addEventListener("error", () => { if (generation === previewGeneration) renderPreviewError("The image could not be loaded."); }, { once: true }); + previewBody.append(image); return; + } + if (kind === "html") { + previewBody.className = "artifactBrowserPreviewBody artifactBrowserPreviewBody--html"; previewBody.textContent = ""; + const frame = document.createElement("iframe"); frame.src = url; frame.title = `Interactive preview of ${entry.name}`; frame.setAttribute("sandbox", "allow-scripts"); previewBody.append(frame); return; + } + if (kind === "video") { + previewBody.className = "artifactBrowserPreviewBody artifactBrowserPreviewBody--video"; previewBody.textContent = ""; + const video = document.createElement("video"); video.controls = true; video.playsInline = true; video.preload = "metadata"; + const source = document.createElement("source"); source.src = url; source.type = videoMimeType(entry.path); video.append(source); previewBody.append(video); return; + } + if (kind === "pdf") { + previewBody.className = "artifactBrowserPreviewBody artifactBrowserPreviewBody--pdf"; previewBody.textContent = ""; + const frame = document.createElement("iframe"); frame.src = url; frame.title = `Preview of ${entry.name}`; previewBody.append(frame); return; + } + void renderTextPreview(entry, kind, generation); + } + + function showPreview(entry: ArtifactEntry, pushHistory = true) { + if (pushHistory && !panel.hidden) pushArtifactPreviewHistory(entry); + activeEntry = entry; + panel.dataset.artifactView = "preview"; + renderPreview(entry); + requestAnimationFrame(() => previewBack.focus()); + } + + function showGallery() { + const previousPath = activeEntry?.path; + activeEntry = undefined; + ++previewGeneration; + panel.dataset.artifactView = "gallery"; + previewBody.className = "artifactBrowserPreviewBody"; + previewBody.textContent = ""; + if (previousPath) requestAnimationFrame(() => { + for (const card of tree.querySelectorAll(".artifactGalleryCard")) { + if (card.dataset.artifactPath === previousPath) { card.querySelector(".artifactGalleryCardOpen")?.focus(); break; } + } + }); + } + + function refresh() { + if (panel.dataset.artifactView === "preview" && activeEntry) renderPreview(activeEntry); + else void loadDirectory(currentDirectory); + } + + function reset() { + ++loadGeneration; ++previewGeneration; clearDeferredPreviews(); + directoryScrollPositions.clear(); + currentDirectory = artifactRootPath; activeEntry = undefined; panel.dataset.artifactView = "gallery"; + tree.className = "filesTree artifactGallery"; tree.textContent = ""; tree.removeAttribute("aria-busy"); + previewBody.className = "artifactBrowserPreviewBody"; previewBody.textContent = ""; renderBreadcrumb(); + } + + galleryBack.addEventListener("click", () => { if (currentDirectory !== artifactRootPath) void loadDirectory(parentArtifactPath(currentDirectory)); }); + previewBack.addEventListener("click", () => { showGallery(); replaceArtifactHistory({ view: "gallery" }); }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && panel.dataset.filesScope === "artifacts" && panel.dataset.artifactView === "preview") { + event.preventDefault(); showGallery(); replaceArtifactHistory({ view: "gallery" }); + } + }); + window.addEventListener("popstate", (event) => { + if (panel.hidden || panel.dataset.filesScope !== "artifacts") return; + const state = artifactHistoryState(event.state); + if (state?.view === "preview") showPreview(state.entry, false); + else if (panel.dataset.artifactView === "preview") showGallery(); + }); + panel.dataset.artifactView = "gallery"; + renderBreadcrumb(); + return { refresh, reset }; +} diff --git a/src/files/artifacts.css b/src/files/artifacts.css new file mode 100644 index 0000000..bd96cf8 --- /dev/null +++ b/src/files/artifacts.css @@ -0,0 +1,113 @@ +.artifactsGalleryToolbar, .artifactBrowserPreview { display: none; } + +.filesPanel[data-files-scope="artifacts"] .filesPanelBody { grid-template-columns: minmax(0, 1fr); } +.filesPanel[data-files-scope="artifacts"] .filesTreeResize, +.filesPanel[data-files-scope="artifacts"] .fileWorkspace { display: none; } +.filesPanel[data-files-scope="artifacts"] .fileHeaderSaveButton { display: none; } +.filesPanel[data-files-scope="artifacts"] .filesExplorer { display: block; visibility: visible; min-width: 0; padding: 0 10px 24px; border: 0; background: radial-gradient(circle at 50% -80px, color-mix(in srgb, var(--accent) 5%, transparent), transparent 340px), color-mix(in srgb, var(--panel) 97%, black); } +.filesPanel[data-files-scope="artifacts"] .filesExplorerScopeBar { min-width: 0; padding: 9px 2px 7px; background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 99%, black) 0 82%, transparent); } +.filesPanel[data-files-scope="artifacts"] .filesExplorerScope { width: min(360px, 100%); margin: 0 auto; } +.filesPanel[data-files-scope="artifacts"] .artifactsGalleryToolbar { position: sticky; top: 43px; left: 0; z-index: 3; display: flex; align-items: center; gap: 7px; min-height: 34px; margin: 0 2px 8px; padding: 3px 5px; border: 1px solid color-mix(in srgb, var(--border) 82%, var(--text) 5%); border-radius: 8px; background: color-mix(in srgb, var(--panel) 94%, transparent); box-shadow: 0 7px 22px rgba(0,0,0,.2); backdrop-filter: blur(14px); } +.artifactsGalleryBack { display: grid; place-items: center; flex: 0 0 26px; width: 26px; height: 26px; min-height: 26px; padding: 0; border: 1px solid transparent; border-radius: 6px; color: var(--muted); background: transparent; } +.artifactsGalleryBack svg { width: 15px; height: 15px; stroke-width: 1.9; } +.artifactsGalleryBack:hover:not(:disabled) { border-color: var(--border); color: var(--text); background: color-mix(in srgb, var(--text) 6%, transparent); } +.artifactsGalleryBack:disabled { opacity: .25; } +.artifactsGalleryBreadcrumb { display: flex; align-items: center; flex: 1; min-width: 0; overflow: hidden; } +.artifactsGalleryBreadcrumb button { flex: 0 1 auto; min-width: 0; height: 24px; min-height: 24px; overflow: hidden; padding: 0 4px; border: 0; border-radius: 4px; color: color-mix(in srgb, var(--text) 86%, var(--muted)); background: transparent; font-size: 10.5px; font-weight: 640; text-overflow: ellipsis; white-space: nowrap; } +.artifactsGalleryBreadcrumb button:hover:not(:disabled) { color: var(--accent); background: color-mix(in srgb, var(--text) 5%, transparent); } +.artifactsGalleryBreadcrumb button:disabled { color: var(--text); opacity: 1; } +.artifactsGalleryBreadcrumb > span { flex: 0 0 auto; color: color-mix(in srgb, var(--muted) 42%, transparent); font-size: 10px; } +.artifactsGalleryCount { flex: 0 0 auto; color: color-mix(in srgb, var(--muted) 82%, transparent); font-size: 9.5px; white-space: nowrap; } + +.filesPanel[data-files-scope="artifacts"] .filesTree.artifactGallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(172px, 1fr)); align-content: start; gap: 10px; min-width: 0; padding: 0 2px 6px; } +.filesPanel[data-files-scope="artifacts"] .filesTree.artifactGallery.fileTreeContainer--state { display: flex; justify-content: center; min-height: 230px; } +.artifactGalleryCard { position: relative; min-width: 0; overflow: hidden; border: 1px solid color-mix(in srgb, var(--border) 88%, var(--text) 5%); border-radius: 10px; background: linear-gradient(155deg, color-mix(in srgb, var(--panel-2) 90%, var(--text) 2%), color-mix(in srgb, var(--panel) 98%, black)); box-shadow: 0 9px 28px rgba(0,0,0,.16); transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease; } +.artifactGalleryCard:hover, .artifactGalleryCard:focus-within { z-index: 1; border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); box-shadow: 0 13px 34px rgba(0,0,0,.28), 0 0 0 1px color-mix(in srgb, var(--accent) 8%, transparent); transform: translateY(-1px); } +.artifactGalleryCardVisual { position: relative; display: grid; place-items: center; aspect-ratio: 16 / 10; overflow: hidden; border-bottom: 1px solid color-mix(in srgb, var(--border) 84%, transparent); background: radial-gradient(circle at 32% 18%, rgba(255,255,255,.055), transparent 45%), #111318; } +.artifactGalleryCardVisual::after { content: ""; position: absolute; inset: 0; z-index: 2; border-radius: inherit; box-shadow: inset 0 1px rgba(255,255,255,.025); pointer-events: none; } +.artifactGalleryCardVisual img { display: block; width: 100%; height: 100%; object-fit: contain; background: repeating-conic-gradient(#111318 0 25%, #15171c 0 50%) 50% / 14px 14px; } +.artifactGalleryCardVisual iframe { width: 200%; height: 200%; border: 0; background: #fff; pointer-events: none; transform: scale(.5); } +.artifactGalleryCardVisual video { width: 100%; height: 100%; object-fit: cover; background: linear-gradient(135deg, #171923, #090a0f); pointer-events: none; } +.artifactGalleryCardVisual--html { background: #fff; } +.artifactGalleryCardVisual--markdown { display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; gap: 5px; padding: 14px; color: #c9cbd2; background: linear-gradient(145deg, #1a1c22, #101116); text-align: left; } +.artifactGalleryCardVisual--markdown strong { position: relative; z-index: 1; max-width: 100%; overflow: hidden; color: #f0f1f3; font: 650 12px/1.25 ui-sans-serif, system-ui, sans-serif; text-overflow: ellipsis; white-space: nowrap; } +.artifactGalleryCardVisual--markdown > span:not(.artifactGalleryMarkdownMark) { position: relative; z-index: 1; display: -webkit-box; overflow: hidden; color: #9da1ab; font: 9.5px/1.4 ui-sans-serif, system-ui, sans-serif; -webkit-box-orient: vertical; -webkit-line-clamp: 3; } +.artifactGalleryCardVisual--markdown i { display: block; width: 78%; height: 3px; border-radius: 9px; background: rgba(255,255,255,.09); } +.artifactGalleryCardVisual--markdown i:nth-last-child(1) { width: 46%; } +.artifactGalleryMarkdownMark { position: absolute; right: 9px; bottom: 6px; color: color-mix(in srgb, var(--accent) 52%, transparent); font: 700 20px/1 ui-monospace, monospace; } +.artifactGalleryPlay { position: absolute; z-index: 3; display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid rgba(255,255,255,.25); border-radius: 50%; color: #fff; background: rgba(0,0,0,.5); box-shadow: 0 7px 20px rgba(0,0,0,.28); backdrop-filter: blur(9px); } +.artifactGalleryPlay svg { width: 15px; height: 15px; margin-left: 2px; } +.artifactGalleryFileMark { display: grid; place-items: center; width: 51px; height: 62px; border: 1px solid color-mix(in srgb, var(--muted) 35%, var(--border)); border-radius: 5px 5px 8px 8px; color: color-mix(in srgb, var(--accent) 72%, white); background: linear-gradient(145deg, #23252c, #15161a); font: 700 9px/1 ui-monospace, monospace; box-shadow: 0 10px 24px rgba(0,0,0,.25); } +.artifactGalleryFolder { position: relative; display: grid; grid-template-columns: repeat(3, 22px); align-items: end; justify-content: center; gap: 4px; width: 112px; height: 73px; padding: 25px 10px 9px; border: 1px solid color-mix(in srgb, var(--accent) 20%, #4d4538); border-radius: 8px; color: color-mix(in srgb, var(--accent) 76%, white); background: linear-gradient(155deg, #29251d, #171612); box-shadow: 0 12px 28px rgba(0,0,0,.28), inset 0 1px rgba(255,255,255,.04); } +.artifactGalleryFolder svg { position: absolute; top: -9px; left: 9px; width: 42px; height: 24px; fill: #29251d; stroke-width: 1.3; } +.artifactGalleryFolder i { height: 28px; border: 1px solid rgba(255,255,255,.08); border-radius: 3px; background: linear-gradient(145deg, rgba(226,177,95,.16), rgba(255,255,255,.025)); } +.artifactGalleryFolder i:nth-of-type(2) { height: 34px; } +.artifactGalleryCardMeta { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding: 8px 10px 9px; } +.artifactGalleryCardMeta strong { overflow: hidden; color: color-mix(in srgb, var(--text) 93%, var(--muted)); font-size: 10.5px; font-weight: 650; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.artifactGalleryCardMeta span { overflow: hidden; color: color-mix(in srgb, var(--muted) 82%, transparent); font-size: 9px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } +.artifactGalleryCardOpen { position: absolute; inset: 0; z-index: 5; width: 100%; height: 100%; min-height: 0; padding: 0; border: 0; border-radius: 10px; background: transparent; color: transparent; } +.artifactGalleryCardOpen:hover { border-color: transparent; background: transparent; } +.artifactGalleryCardOpen:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 85%, white); outline-offset: -3px; } +.artifactGalleryState { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; width: min(330px, 100%); min-height: 210px; padding: 28px 18px; color: var(--muted); text-align: center; } +.artifactGalleryStateIcon { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--border)); border-radius: 12px; color: color-mix(in srgb, var(--accent) 88%, white); background: radial-gradient(circle at 35% 25%, color-mix(in srgb, var(--accent) 15%, transparent), transparent 72%); box-shadow: inset 0 1px rgba(255,255,255,.04), 0 11px 32px rgba(0,0,0,.2); } +.artifactGalleryStateIcon svg { width: 21px; height: 21px; stroke-width: 1.55; } +.artifactGalleryStateCopy { display: flex; flex-direction: column; gap: 3px; } +.artifactGalleryStateCopy strong { color: var(--text); font-size: 12px; font-weight: 680; } +.artifactGalleryStateCopy span { max-width: 280px; font-size: 10.5px; line-height: 1.45; } +.artifactGallerySpinner { width: 17px; height: 17px; border: 1.5px solid color-mix(in srgb, var(--muted) 28%, transparent); border-top-color: color-mix(in srgb, var(--accent) 84%, white); border-radius: 50%; animation: fileTreeSpin .7s linear infinite; } +.artifactGalleryState--loading .artifactGalleryStateIcon { width: 28px; height: 28px; border: 0; background: none; box-shadow: none; } +.artifactGalleryState--error .artifactGalleryStateIcon { border-color: color-mix(in srgb, #d8a099 28%, var(--border)); color: #d8a099; background: rgba(216,160,153,.04); } + +.artifactBrowserPreview { min-width: 0; min-height: 0; background: #0d0f13; } +.filesPanel[data-files-scope="artifacts"][data-artifact-view="preview"] .filesExplorer { display: none; } +.filesPanel[data-files-scope="artifacts"][data-artifact-view="preview"] .artifactBrowserPreview { display: grid; grid-template-rows: auto minmax(0, 1fr); } +.artifactBrowserPreviewHeader { display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 42px; padding: 5px 8px; border-bottom: 1px solid var(--border); background: linear-gradient(180deg, color-mix(in srgb, var(--panel-2) 86%, black), color-mix(in srgb, var(--panel) 96%, black)); } +.artifactBrowserPreviewBack { display: grid; place-items: center; flex: 0 0 30px; width: 30px; height: 30px; min-height: 30px; padding: 0; border: 1px solid transparent; border-radius: 7px; color: var(--muted); background: transparent; } +.artifactBrowserPreviewBack svg { width: 17px; height: 17px; stroke-width: 1.9; } +.artifactBrowserPreviewBack:hover { border-color: var(--border); color: var(--text); background: color-mix(in srgb, var(--text) 6%, transparent); } +.artifactBrowserPreviewIdentity { display: flex; flex: 1; flex-direction: column; min-width: 0; } +.artifactBrowserPreviewIdentity > span { color: color-mix(in srgb, var(--accent) 78%, var(--muted)); font-size: 8px; font-weight: 700; line-height: 1.2; letter-spacing: .09em; text-transform: uppercase; } +.artifactBrowserPreviewIdentity strong { overflow: hidden; color: var(--text); font-size: 11px; font-weight: 650; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +.artifactBrowserPreviewActions { display: flex; align-items: center; gap: 4px; } +.artifactBrowserPreviewActions a { display: inline-flex; align-items: center; height: 27px; padding: 0 8px; border: 1px solid transparent; border-radius: 6px; color: var(--muted); font-size: 9.5px; font-weight: 620; text-decoration: none; } +.artifactBrowserPreviewActions a:hover { border-color: color-mix(in srgb, var(--accent) 25%, var(--border)); color: var(--text); background: color-mix(in srgb, var(--accent) 6%, transparent); } +.artifactBrowserPreviewBody { position: relative; min-width: 0; min-height: 0; overflow: auto; background: #111318; } +.artifactBrowserPreviewBody--loading, .artifactBrowserPreviewBody--error { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 30px; color: var(--muted); font-size: 11px; text-align: center; } +.artifactBrowserPreviewBody--error strong { color: #d8a099; } +.artifactBrowserPreviewSpinner { width: 18px; height: 18px; border: 1.5px solid color-mix(in srgb, var(--muted) 28%, transparent); border-top-color: var(--accent); border-radius: 50%; animation: fileTreeSpin .7s linear infinite; } +.artifactBrowserPreviewBody--image { display: grid; place-items: center; padding: 20px; background: radial-gradient(circle at 50% 40%, #191c23, #0c0e12 70%); } +.artifactBrowserPreviewBody--image img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; box-shadow: 0 18px 52px rgba(0,0,0,.32); } +.artifactBrowserPreviewBody--html, .artifactBrowserPreviewBody--pdf { overflow: hidden; padding: 0; background: #fff; } +.artifactBrowserPreviewBody--html iframe, .artifactBrowserPreviewBody--pdf iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; } +.artifactBrowserPreviewBody--video { display: grid; place-items: center; overflow: hidden; padding: 18px; background: #050505; } +.artifactBrowserPreviewBody--video video { display: block; width: 100%; max-width: 1100px; max-height: 100%; background: #000; } +.artifactBrowserPreviewBody--markdown { padding: clamp(18px, 4vw, 42px); } +.artifactBrowserPreviewBody--markdown.markdownBody { width: 100%; } +.artifactBrowserPreviewBody--markdown > * { max-width: 900px; margin-right: auto; margin-left: auto; } +.artifactBrowserPreviewBody--file { padding: 20px; } +.artifactBrowserPreviewBody--file pre { min-width: max-content; margin: 0; color: #cdd1da; font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; white-space: pre-wrap; } + +@media (max-width: 640px) { + .filesPanel[data-files-scope="artifacts"] .filesExplorer, + .artifactBrowserPreview { position: absolute; inset: 0; } + .filesPanel[data-files-scope="artifacts"] .filesExplorer { padding: 0 6px 18px; } + .filesPanel[data-files-scope="artifacts"] .filesExplorerScopeBar { padding: 8px 0 6px; } + .filesPanel[data-files-scope="artifacts"] .artifactsGalleryToolbar { top: 41px; margin-right: 0; margin-left: 0; } + .filesPanel[data-files-scope="artifacts"] .filesTree.artifactGallery { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding-right: 0; padding-left: 0; } + .artifactGalleryCard { border-radius: 9px; } + .artifactGalleryCardMeta { padding: 7px 8px 8px; } + .artifactGalleryCardMeta strong { font-size: 10px; } + .artifactBrowserPreviewHeader { min-height: 44px; } + .artifactBrowserPreviewActions a { padding: 0 6px; } + .artifactBrowserPreviewBody--image { padding: 10px; } + .artifactBrowserPreviewBody--markdown { padding: 18px 15px 34px; } +} + +@media (max-width: 370px) { + .filesPanel[data-files-scope="artifacts"] .filesTree.artifactGallery { grid-template-columns: minmax(0, 1fr); } +} + +@media (prefers-reduced-motion: reduce) { + .artifactGalleryCard { transition: none; } + .artifactGalleryCard:hover, .artifactGalleryCard:focus-within { transform: none; } +} diff --git a/src/files/files.css b/src/files/files.css index a7f87d0..632c619 100644 --- a/src/files/files.css +++ b/src/files/files.css @@ -18,6 +18,9 @@ .filesPanelHeading { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; } .filesPanelHeadingIcon { display: grid; place-items: center; width: 27px; height: 27px; border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border)); border-radius: 7px; color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, transparent); } .filesPanelHeadingIcon svg { width: 16px; height: 16px; } +.filesPanelHeadingIconArtifact { display: none; } +.filesPanel[data-files-scope="artifacts"] .filesPanelHeadingIconWorkspace { display: none; } +.filesPanel[data-files-scope="artifacts"] .filesPanelHeadingIconArtifact { display: block; } .filesPanelHeading h2 { margin: 0; color: var(--muted); font-size: 12px; font-weight: 700; line-height: 1; letter-spacing: .05em; text-transform: uppercase; } .filesPanelBody { display: grid; grid-template-columns: var(--files-tree-width) 7px minmax(0, 1fr); min-height: 0; } .filesPanel--treeCollapsed .filesPanelBody { grid-template-columns: 0 7px minmax(0, 1fr); } @@ -27,8 +30,34 @@ .filesTreeResize:hover::before { background: color-mix(in srgb, var(--accent) 65%, var(--border)); } .filesTreeResize button { position: absolute; top: 50%; left: 50%; z-index: 2; display: grid; place-items: center; width: 16px; height: 34px; padding: 0; border: 1px solid var(--border); border-radius: 0 5px 5px 0; color: var(--muted); background: var(--panel); font-size: 16px; transform: translate(-50%, -50%); cursor: pointer; } .filesTreeResize button:hover { color: var(--accent); } -.filesExplorer { min-width: 0; overflow: auto; border-right: 1px solid var(--border); padding: 7px 5px 18px; background: color-mix(in srgb, var(--panel) 97%, black); scrollbar-width: thin; } +.filesExplorer { min-width: 0; overflow: auto; border-right: 1px solid var(--border); padding: 0 5px 18px; background: color-mix(in srgb, var(--panel) 97%, black); scrollbar-width: thin; } +.filesExplorerScopeBar { position: sticky; top: 0; left: 0; z-index: 4; min-width: 174px; padding: 7px 0 6px; background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 98%, black) 0 82%, transparent); } +.filesExplorerScope { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2px; padding: 2px; border: 1px solid color-mix(in srgb, var(--border) 86%, var(--text) 7%); border-radius: 9px; background: color-mix(in srgb, var(--bg) 78%, var(--panel)); box-shadow: inset 0 1px 0 rgba(255,255,255,.025), 0 5px 16px rgba(0,0,0,.16); } +.fileExplorerScopeButton { position: relative; display: flex; align-items: center; justify-content: center; gap: 5px; min-width: 0; height: 27px; min-height: 27px; padding: 0 6px; border: 1px solid transparent; border-radius: 6px; color: color-mix(in srgb, var(--muted) 90%, var(--text)); background: transparent; font-size: 10.5px; font-weight: 650; line-height: 1; letter-spacing: .01em; transition: color .14s ease, border-color .14s ease, background .14s ease, box-shadow .14s ease; } +.fileExplorerScopeButton > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fileExplorerScopeButton:hover { border-color: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); background: color-mix(in srgb, var(--text) 5%, transparent); } +.fileExplorerScopeButton:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 72%, white); outline-offset: 1px; } +.fileExplorerScopeButton[aria-pressed="true"] { border-color: color-mix(in srgb, var(--accent) 25%, var(--border)); color: var(--text); background: linear-gradient(180deg, color-mix(in srgb, var(--text) 8%, var(--panel)), color-mix(in srgb, var(--text) 3%, var(--panel))); box-shadow: inset 0 1px 0 rgba(255,255,255,.055), 0 2px 7px rgba(0,0,0,.24); } +.fileExplorerScopeIcon { display: grid; place-items: center; flex: 0 0 13px; width: 13px; height: 13px; color: color-mix(in srgb, var(--muted) 85%, var(--text)); } +.fileExplorerScopeIcon svg { width: 13px; height: 13px; stroke-width: 1.8; } +.fileExplorerScopeButton[aria-pressed="true"] .fileExplorerScopeIcon { color: color-mix(in srgb, var(--accent) 92%, white); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--accent) 22%, transparent)); } +.fileExplorerScopeButton--artifacts[aria-pressed="true"] { background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 9%, var(--panel)), color-mix(in srgb, var(--accent) 3%, var(--panel))); } .filesTree, .fileTreeChildren { display: flex; flex-direction: column; min-width: max-content; } +.filesPanel[data-files-scope="workspace"] #artifactsTree { display: none; } +.filesPanel[data-files-scope="artifacts"] #filesTree { display: none; } +.filesTree.fileTreeContainer--state { min-width: 0; } +.fileTreeState { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 6px; color: var(--muted); font-size: 10.5px; line-height: 1.35; } +.filesTree > .fileTreeState:not(.fileTreeState--loading) { flex-direction: column; justify-content: center; gap: 9px; min-height: 132px; padding: 24px 10px; text-align: center; } +.fileTreeState--nested { flex-direction: row !important; justify-content: flex-start !important; min-height: 0 !important; padding: 7px 5px !important; text-align: left !important; } +.fileTreeStateIcon { display: grid; place-items: center; flex: 0 0 31px; width: 31px; height: 31px; border: 1px solid color-mix(in srgb, var(--border) 85%, var(--text) 8%); border-radius: 9px; color: color-mix(in srgb, var(--muted) 78%, var(--text)); background: linear-gradient(145deg, color-mix(in srgb, var(--text) 6%, transparent), transparent); box-shadow: inset 0 1px rgba(255,255,255,.035), 0 8px 24px rgba(0,0,0,.18); } +.fileTreeStateIcon svg { width: 17px; height: 17px; stroke-width: 1.65; } +.fileTreeState--nested .fileTreeStateIcon, .fileTreeState--loading .fileTreeStateIcon { flex-basis: 15px; width: 15px; height: 15px; border: 0; border-radius: 0; background: none; box-shadow: none; } +.fileTreeState--error .fileTreeStateIcon { color: #d8a099; } +.fileTreeStateCopy { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.fileTreeStateCopy strong { color: color-mix(in srgb, var(--text) 88%, var(--muted)); font-size: 10.5px; font-weight: 650; } +.fileTreeStateCopy > span { max-width: 150px; color: color-mix(in srgb, var(--muted) 88%, transparent); overflow-wrap: anywhere; } +.fileTreeStateSpinner { width: 12px !important; height: 12px !important; margin-left: 1px; border: 1.5px solid color-mix(in srgb, var(--muted) 28%, transparent) !important; border-top-color: color-mix(in srgb, var(--accent) 82%, white) !important; border-radius: 50% !important; animation: fileTreeSpin .7s linear infinite; } +@keyframes fileTreeSpin { to { transform: rotate(360deg); } } .fileTreeChildren { padding-left: 15px; margin-left: 12px; } .fileTreeChildren > .fileTreeDirectory, .fileTreeChildren > .fileTreeFile { position: relative; } .fileTreeChildren > .fileTreeDirectory::before, .fileTreeChildren > .fileTreeFile::before { content: ""; position: absolute; z-index: 1; left: -11px; top: 0; width: 11px; height: 13px; border-left: 1px solid color-mix(in srgb, var(--muted) 58%, var(--border)); border-bottom: 1px solid color-mix(in srgb, var(--muted) 58%, var(--border)); pointer-events: none; } @@ -39,6 +68,7 @@ .fileTreeDirectory > summary::-webkit-details-marker { display: none; } .fileTreeDirectory > summary::before { content: ""; display: block; box-sizing: border-box; flex: 0 0 7px; width: 7px; height: 7px; margin: 0 10px 0 4px; border-right: 1.5px solid color-mix(in srgb, var(--accent) 82%, white); border-bottom: 1.5px solid color-mix(in srgb, var(--accent) 82%, white); transform: rotate(-45deg); transform-origin: center; transition: transform .12s ease; } .fileTreeDirectory[open] > summary::before { transform: rotate(45deg); } +.fileTreeDirectory > summary .fileTreeIcon { margin-right: 6px; color: color-mix(in srgb, var(--accent) 55%, var(--muted)); } .fileTreeFile { display: flex; align-items: center; width: 100%; gap: 6px; padding-left: 5px; } .fileTreeIcon { display: inline-flex; flex: 0 0 15px; width: 15px; height: 15px; color: var(--muted); } .fileTreeIcon svg { width: 15px; height: 15px; stroke-width: 1.8; } @@ -92,7 +122,8 @@ .filesPanelBody { display: block; position: relative; overflow: hidden; } .filesTreeResize { display: none; } .filesExplorer, .fileWorkspace { position: absolute; inset: 0; border: 0; } - .filesExplorer { padding-top: 8px; background: var(--panel); } + .filesExplorer { padding-top: 0; background: var(--panel); } + .filesExplorerScopeBar { padding-top: 8px; background: linear-gradient(180deg, var(--panel) 0 82%, transparent); } .fileWorkspace { background: var(--panel); } .filesPanel[data-mobile-view="tree"] .fileWorkspace { display: none; } .filesPanel[data-mobile-view="editor"] .filesExplorer { display: none; } diff --git a/src/files/panel.ts b/src/files/panel.ts index 88e346f..463f26e 100644 --- a/src/files/panel.ts +++ b/src/files/panel.ts @@ -6,11 +6,16 @@ import { searchKeymap, highlightSelectionMatches } from "@codemirror/search"; import { vscodeDark } from "@uiw/codemirror-theme-vscode/esm/dark.js"; import type { RightPanelManager } from "../layout/rightPanel.js"; import { iconElement } from "../app/icons.js"; +import { initArtifactBrowser } from "./artifactBrowser.js"; type FileEntry = { name: string; path: string; kind: "file" | "directory" | "symlink"; size?: number }; type TextDocumentState = { kind: "text"; path: string; revision: string; saved: string; view: EditorView; language: string; wrap: Compartment; host: HTMLElement }; type ImageDocumentState = { kind: "image"; path: string; host: HTMLElement; objectUrl: string }; type DocumentState = TextDocumentState | ImageDocumentState; +type ExplorerScope = "workspace" | "artifacts"; +type DirectoryLoadContext = { generation: number; root: boolean }; +type WorkspaceHistoryState = { view: "tree" | "editor" }; +const workspaceHistoryStateKey = "piWebWorkspaceView"; async function languageExtension(language: string) { switch (language) { @@ -35,12 +40,17 @@ export function initFilesPanel(options: { }): FilesPanelController { const { button, panel, rightPanels, apiHeaders, getSessionId, onError } = options; const tree = panel.querySelector("#filesTree")!; + const artifactsTree = panel.querySelector("#artifactsTree")!; const editor = panel.querySelector("#fileEditor")!; const tabs = panel.querySelector("#fileTabs")!; const saveButton = panel.querySelector("#fileSaveButton")!; const backButton = panel.querySelector("#fileBackButton")!; const refreshButton = panel.querySelector("#filesRefreshButton")!; const closeButton = panel.querySelector("#filesCloseButton")!; + const panelHeading = panel.querySelector("#filesPanelHeadingLabel")!; + const explorer = panel.querySelector(".filesExplorer")!; + const workspaceScopeButton = panel.querySelector("#filesWorkspaceScope")!; + const artifactsScopeButton = panel.querySelector("#filesArtifactsScope")!; backButton.textContent = ""; backButton.append(iconElement("arrow-left")); const status = panel.querySelector("#fileStatus")!; @@ -49,6 +59,7 @@ export function initFilesPanel(options: { const fontSlider = panel.querySelector("#fileFontSlider")!; const fontValue = panel.querySelector("#fileFontValue")!; const wrapToggle = panel.querySelector("#fileWrapToggle")!; + const artifactBrowser = initArtifactBrowser({ panel, tree: artifactsTree, apiHeaders, getSessionId }); const documents = new Map(); const editorFontStorageKey = "pi-web.files.editor-font-size"; const editorWrapStorageKey = "pi-web.files.editor-line-wrap"; @@ -56,6 +67,11 @@ export function initFilesPanel(options: { const activeTouchPointers = new Map(); let activePath = ""; let loadedSession = ""; + let treeScope: ExplorerScope = "workspace"; + let treeLoadGeneration = 0; + let workspaceMobileView: "tree" | "editor" = "tree"; + let scopeLoaded: Record = { workspace: false, artifacts: false }; + const scopeScrollPositions: Record = { workspace: 0, artifacts: 0 }; let errorHost: HTMLElement | undefined; let pinchStartDistance = 0; let pinchStartFontSize = 0; @@ -121,7 +137,7 @@ export function initFilesPanel(options: { const doc = documents.get(path); if (!doc) return; errorHost?.remove(); errorHost = undefined; for (const item of documents.values()) item.host.hidden = item !== doc; - activePath = path; panel.dataset.mobileView = "editor"; panel.classList.toggle("filesPanel--imageActive", doc.kind === "image"); saveButton.disabled = !dirty(doc); status.textContent = ""; + activePath = path; showWorkspaceEditor(); panel.classList.toggle("filesPanel--imageActive", doc.kind === "image"); saveButton.disabled = !dirty(doc); status.textContent = ""; renderTabs(); // Touch-first tablets (including unfolded foldables) should not summon the // software keyboard merely because a file was opened. @@ -160,7 +176,7 @@ export function initFilesPanel(options: { const image = document.createElement("img"); image.src = "/file-editor-error.png"; image.alt = ""; const message = document.createElement("p"); message.textContent = error instanceof Error ? error.message : String(error); errorHost.append(image, message); editor.append(errorHost); - activePath = ""; panel.dataset.mobileView = "editor"; panel.classList.remove("filesPanel--imageActive"); status.textContent = ""; saveButton.disabled = true; renderTabs(); + activePath = ""; showWorkspaceEditor(); panel.classList.remove("filesPanel--imageActive"); status.textContent = ""; saveButton.disabled = true; renderTabs(); } } async function save() { @@ -191,36 +207,152 @@ export function initFilesPanel(options: { : ''; return icon; } - async function loadDirectory(path: string, container: HTMLElement) { - container.textContent = "Loading…"; + function renderTreeState(container: HTMLElement, kind: "loading" | "empty" | "error", title: string, description = "") { + container.textContent = ""; + container.classList.add("fileTreeContainer--state"); + const state = document.createElement("div"); + state.className = `fileTreeState fileTreeState--${kind}${container === tree ? "" : " fileTreeState--nested"}`; + state.setAttribute("role", kind === "error" ? "alert" : "status"); + const icon = document.createElement("span"); + icon.className = "fileTreeStateIcon"; + icon.setAttribute("aria-hidden", "true"); + if (kind === "loading") { + icon.classList.add("fileTreeStateSpinner"); + } else { + icon.innerHTML = kind === "error" + ? '' + : ''; + } + const copy = document.createElement("span"); copy.className = "fileTreeStateCopy"; + const heading = document.createElement("strong"); heading.textContent = title; copy.append(heading); + if (description) { const detail = document.createElement("span"); detail.textContent = description; copy.append(detail); } + state.append(icon, copy); container.append(state); + } + async function loadDirectory(path: string, container: HTMLElement, context: DirectoryLoadContext) { + renderTreeState(container, "loading", "Loading files…"); + container.setAttribute("aria-busy", "true"); try { - const data = await responseJson(await fetch(`/api/files/tree?${query(path)}`, { headers: apiHeaders() })); container.textContent = ""; - for (const entry of data.entries as FileEntry[]) { + const data = await responseJson(await fetch(`/api/files/tree?${query(path)}`, { headers: apiHeaders() })); + if (context.generation !== treeLoadGeneration) return; + container.textContent = ""; + container.classList.remove("fileTreeContainer--state"); + const entries = data.entries as FileEntry[]; + for (const entry of entries) { if (entry.kind === "directory") { const details = document.createElement("details"); details.className = "fileTreeDirectory"; const summary = document.createElement("summary"); - summary.append(document.createTextNode(entry.name)); details.append(summary); + summary.append(treeIcon("folder"), document.createTextNode(entry.name)); details.append(summary); const children = document.createElement("div"); children.className = "fileTreeChildren"; details.append(children); - details.addEventListener("toggle", () => { if (details.open && !children.dataset.loaded) { children.dataset.loaded = "1"; void loadDirectory(entry.path, children); } }); container.append(details); + details.addEventListener("toggle", () => { + if (details.open && !children.dataset.loaded) { + children.dataset.loaded = "1"; + void loadDirectory(entry.path, children, { ...context, root: false }); + } + }); + container.append(details); } else { const item = document.createElement("button"); item.type = "button"; item.className = "fileTreeFile"; item.append(treeIcon("file", fileTypeClass(entry.path)), document.createTextNode(entry.name)); item.title = entry.path; item.addEventListener("click", () => void openFile(entry.path)); container.append(item); } } - if (!data.entries.length) container.textContent = "Empty folder"; - } catch (error) { container.textContent = error instanceof Error ? error.message : String(error); } + if (!entries.length) renderTreeState(container, "empty", context.root ? "Empty workspace" : "Empty folder"); + } catch (error) { + if (context.generation !== treeLoadGeneration) return; + renderTreeState(container, "error", "Couldn’t load files", error instanceof Error ? error.message : String(error)); + } finally { + if (context.generation === treeLoadGeneration) container.removeAttribute("aria-busy"); + } } function renderEditorEmpty() { errorHost = undefined; editor.innerHTML = '
'; } - function refresh() { loadedSession = getSessionId(); tree.textContent = ""; void loadDirectory("", tree); } + function historyRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + } + function workspaceHistoryState(value: unknown = window.history.state): WorkspaceHistoryState | undefined { + const candidate = historyRecord(value)[workspaceHistoryStateKey]; + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return undefined; + const view = (candidate as Record).view; + return view === "tree" || view === "editor" ? { view } : undefined; + } + function replaceWorkspaceHistory(view: WorkspaceHistoryState["view"]) { + window.history.replaceState({ ...historyRecord(window.history.state), [workspaceHistoryStateKey]: { view } satisfies WorkspaceHistoryState }, ""); + } + function setWorkspaceMobileView(view: WorkspaceHistoryState["view"]) { + workspaceMobileView = view; + if (treeScope === "workspace") panel.dataset.mobileView = view; + } + function showWorkspaceEditor() { + if (treeScope === "workspace" && workspaceMobileView !== "editor" && !panel.hidden) { + replaceWorkspaceHistory("tree"); + window.history.pushState({ ...historyRecord(window.history.state), [workspaceHistoryStateKey]: { view: "editor" } satisfies WorkspaceHistoryState }, ""); + } + setWorkspaceMobileView("editor"); + } + function showWorkspaceTree() { + setWorkspaceMobileView("tree"); + replaceWorkspaceHistory("tree"); + } + function updateTreeScope() { + const showingArtifacts = treeScope === "artifacts"; + panel.dataset.filesScope = treeScope; + panel.setAttribute("aria-label", showingArtifacts ? "Artifacts" : "Workspace files"); + panelHeading.textContent = showingArtifacts ? "Artifacts" : "Explorer"; + explorer.setAttribute("aria-label", showingArtifacts ? "Artifact gallery" : "File tree"); + refreshButton.setAttribute("aria-label", showingArtifacts ? "Refresh artifacts" : "Refresh files"); + refreshButton.title = showingArtifacts ? "Refresh artifacts" : "Refresh files"; + workspaceScopeButton.setAttribute("aria-pressed", String(!showingArtifacts)); + artifactsScopeButton.setAttribute("aria-pressed", String(showingArtifacts)); + } + function loadActiveScope(force = false) { + if (!force && scopeLoaded[treeScope]) return; + scopeLoaded[treeScope] = true; + if (treeScope === "artifacts") { + artifactBrowser.refresh(); + return; + } + const generation = ++treeLoadGeneration; + void loadDirectory("", tree, { generation, root: true }); + } + function refresh() { loadActiveScope(true); } + function restoreScopeScroll(scope: ExplorerScope) { + const scrollTop = scopeScrollPositions[scope]; + explorer.scrollTop = scrollTop; + requestAnimationFrame(() => { if (treeScope === scope) explorer.scrollTop = scrollTop; }); + } + function setTreeScope(next: ExplorerScope) { + if (treeScope === next) return; + scopeScrollPositions[treeScope] = explorer.scrollTop; + if (treeScope === "workspace") workspaceMobileView = panel.dataset.mobileView === "editor" ? "editor" : "tree"; + treeScope = next; + panel.dataset.mobileView = next === "workspace" ? workspaceMobileView : "tree"; + updateTreeScope(); + restoreScopeScroll(next); + loadActiveScope(); + } function sessionChanged() { - const next = getSessionId(); if (next === loadedSession) return; - if ([...documents.values()].some(dirty) && !confirm("Discard unsaved file changes from the previous session?")) return; - for (const doc of documents.values()) { if (doc.kind === "text") doc.view.destroy(); else URL.revokeObjectURL(doc.objectUrl); doc.host.remove(); } documents.clear(); renderEditorEmpty(); activePath = ""; renderTabs(); panel.classList.remove("filesPanel--imageActive"); panel.dataset.mobileView = "tree"; refresh(); + const next = getSessionId(); if (next === loadedSession) return "unchanged" as const; + if ([...documents.values()].some(dirty) && !confirm("Discard unsaved file changes from the previous session?")) return "cancelled" as const; + loadedSession = next; + scopeLoaded = { workspace: false, artifacts: false }; + scopeScrollPositions.workspace = 0; scopeScrollPositions.artifacts = 0; + ++treeLoadGeneration; + tree.className = "filesTree"; tree.textContent = ""; tree.removeAttribute("aria-busy"); + artifactBrowser.reset(); + for (const doc of documents.values()) { if (doc.kind === "text") doc.view.destroy(); else URL.revokeObjectURL(doc.objectUrl); doc.host.remove(); } documents.clear(); renderEditorEmpty(); activePath = ""; renderTabs(); panel.classList.remove("filesPanel--imageActive"); setWorkspaceMobileView("tree"); + if (!panel.hidden) loadActiveScope(); + return "changed" as const; } - const handle = rightPanels.register({ id: "files", side: "right", panel, trigger: button, closeButton, width: "760px", minWidth: 360, maxWidth: 10_000, onOpen: () => { sessionChanged(); if (!loadedSession) refresh(); } }); + updateTreeScope(); + const handle = rightPanels.register({ + id: "files", side: "right", panel, trigger: button, closeButton, width: "760px", minWidth: 360, maxWidth: 10_000, + onOpen: () => { + const sessionResult = sessionChanged(); + if (sessionResult === "cancelled") return; + loadActiveScope(); + }, + }); function applyEditorFontSize(value: number, persist = false) { editorFontSize = Math.round(Math.min(22, Math.max(11, value)) * 10) / 10; panel.style.setProperty("--file-editor-font-size", `${editorFontSize}px`); @@ -294,7 +426,15 @@ export function initFilesPanel(options: { try { localStorage.setItem(editorWrapStorageKey, editorLineWrap ? "on" : "off"); } catch { /* Ignore unavailable storage. */ } for (const doc of documents.values()) if (doc.kind === "text") doc.view.dispatch({ effects: doc.wrap.reconfigure(editorLineWrap ? EditorView.lineWrapping : []) }); }); - refreshButton.addEventListener("click", refresh); saveButton.addEventListener("click", () => void save()); backButton.addEventListener("click", () => { panel.dataset.mobileView = "tree"; }); + workspaceScopeButton.addEventListener("click", () => setTreeScope("workspace")); + artifactsScopeButton.addEventListener("click", () => setTreeScope("artifacts")); + refreshButton.addEventListener("click", refresh); saveButton.addEventListener("click", () => void save()); backButton.addEventListener("click", showWorkspaceTree); + window.addEventListener("popstate", (event) => { + if (panel.hidden || treeScope !== "workspace") return; + const view = workspaceHistoryState(event.state)?.view; + if (view === "editor" && (activePath || errorHost)) setWorkspaceMobileView("editor"); + else if (workspaceMobileView === "editor") setWorkspaceMobileView("tree"); + }); window.addEventListener("beforeunload", (event) => { if ([...documents.values()].some(dirty)) event.preventDefault(); }); return { isOpen: handle.isOpen, sessionChanged, openFile }; } diff --git a/src/main.ts b/src/main.ts index 9114a97..33f42f7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import "./style.css"; import "./components/diff.css"; import "./git/git.css"; import "./files/files.css"; +import "./files/artifacts.css"; import "./styles/appLayout.css"; import "highlight.js/styles/github-dark.css"; import { createApiClient } from "./app/api.js"; @@ -10,7 +11,7 @@ import { initSwAutoReload } from "./app/sw-update.js"; import { setIcon } from "./app/icons.js"; import { initKeyboardShortcuts } from "./app/shortcuts.js"; import { createRightPanelManager } from "./layout/rightPanel.js"; -import { createAppState, readActiveSessionIdFromUrl } from "./app/types.js"; +import { createAppState, readActiveSessionIdFromHistoryState, readActiveSessionIdFromUrl, syncActiveSessionIdHistoryState } from "./app/types.js"; import { activeSessionState, activeSessionStats, @@ -383,6 +384,7 @@ async function refreshState() { return; } sessionState.applySnapshot(data, { activate: true }); + syncActiveSessionIdHistoryState(state.currentSessionId); const [settingsResult, modelsResult, messagesResult] = await Promise.allSettled([ settings.refreshSettings(), modelSettings.refreshModels(), @@ -566,9 +568,10 @@ gitPanel = initGitPanel({ getSessionId: () => state.currentSessionId, onComposerContext: (context) => composer.addContextAttachment(context), }); -window.addEventListener("popstate", () => { - const nextSessionId = readActiveSessionIdFromUrl(); +window.addEventListener("popstate", (event) => { + const nextSessionId = readActiveSessionIdFromHistoryState(event.state) ?? readActiveSessionIdFromUrl(); if (nextSessionId === state.currentSessionId) return; + syncActiveSessionIdHistoryState(nextSessionId); sessionState.activate(nextSessionId); tools.clearActiveToolCards(); sessions.beginTranscriptLoading(); diff --git a/tests/api.test.ts b/tests/api.test.ts index 4a7eea8..9f0d4b1 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -621,6 +621,7 @@ describe("artifact serving", () => { await mkdir(artifactDir, { recursive: true }); await mkdir(legacyArtifactDir, { recursive: true }); await writeFile(join(artifactDir, "test.png"), Buffer.from("PNG")); + await writeFile(join(artifactDir, "test.webm"), Buffer.from("WEBM")); await mkdir(join(artifactDir, "image-edits", "run-1"), { recursive: true }); await writeFile(join(artifactDir, "image-edits", "run-1", "output.png"), Buffer.from("NESTED")); await writeFile(join(legacyArtifactDir, "legacy.png"), Buffer.from("LEGACY")); @@ -636,6 +637,7 @@ describe("artifact serving", () => { afterAll(async () => { child?.kill(); await rm(join(artifactDir, "test.png"), { force: true }); + await rm(join(artifactDir, "test.webm"), { force: true }); await rm(join(artifactDir, "e2e-test.png"), { force: true }); await rm(join(artifactDir, "image-edits"), { recursive: true, force: true }); await rm(join(legacyArtifactDir, "legacy.png"), { force: true }); @@ -655,6 +657,27 @@ describe("artifact serving", () => { expect(Buffer.from(await res.arrayBuffer()).toString()).toBe("NESTED"); }); + it("keeps session-scoped artifact URLs and their relative assets on the same session route", async () => { + const documentUrl = new URL(`${baseUrl}/api/session-artifacts/mock-current/image-edits/run-1/index.html`); + expect(new URL("./output.png", documentUrl).pathname).toBe("/api/session-artifacts/mock-current/image-edits/run-1/output.png"); + + const asset = await fetch(new URL("./output.png", documentUrl)); + expect(asset.status).toBe(200); + expect(Buffer.from(await asset.arrayBuffer()).toString()).toBe("NESTED"); + + const unknownSession = await fetch(`${baseUrl}/api/session-artifacts/unknown-session/test.png`); + expect(unknownSession.status).toBe(404); + }); + + it("serves preview media with the correct MIME type, byte ranges, and a session preference", async () => { + const video = await fetch(`${baseUrl}/api/artifacts/test.webm?sessionId=unknown-session`, { headers: { range: "bytes=1-2" } }); + expect(video.status).toBe(206); + expect(video.headers.get("content-type")).toBe("video/webm"); + expect(video.headers.get("accept-ranges")).toBe("bytes"); + expect(video.headers.get("content-range")).toBe("bytes 1-2/4"); + expect(Buffer.from(await video.arrayBuffer()).toString()).toBe("EB"); + }); + it("serves legacy artifacts as a read-only fallback", async () => { const res = await fetch(`${baseUrl}/api/artifacts/legacy.png`); expect(res.status).toBe(200); diff --git a/tests/e2e/files.spec.ts b/tests/e2e/files.spec.ts index 4344c29..75caf19 100644 --- a/tests/e2e/files.spec.ts +++ b/tests/e2e/files.spec.ts @@ -1,26 +1,51 @@ import { expect, test } from "@playwright/test"; import { openLauncherAction } from "./helpers/actionLauncher.js"; +const artifactRoot = ".pi/web/artifacts"; const files = { "README.md": { content: "# Test workspace\n", language: "markdown", revision: "readme-1" }, "src/app.ts": { content: "export const answer = 42;\n", language: "typescript", revision: "app-1" }, + ".pi/web/artifacts/report.md": { content: "# Artifact report\n\nA **rendered** project artifact.\n", language: "markdown", revision: "report-1" }, }; test.beforeEach(async ({ page }) => { await page.request.post("/api/mock/reset"); await page.route("**/api/files/tree**", async (route) => { const path = new URL(route.request().url()).searchParams.get("path") || ""; - await route.fulfill({ json: path === "src" ? { - ok: true, path, entries: [{ name: "app.ts", path: "src/app.ts", kind: "file", size: 26 }], - } : { - ok: true, path: "", entries: [ - { name: "src", path: "src", kind: "directory" }, - { name: "preview.png", path: "preview.png", kind: "file", size: 68 }, - { name: "README.md", path: "README.md", kind: "file", size: 17 }, - ], - } }); + const entries = path === "src" + ? [{ name: "app.ts", path: "src/app.ts", kind: "file", size: 26 }] + : path === artifactRoot + ? [ + { name: ".DS_Store", path: `${artifactRoot}/.DS_Store`, kind: "file", size: 12 }, + { name: "runs", path: `${artifactRoot}/runs`, kind: "directory" }, + { name: "brief.pdf", path: `${artifactRoot}/brief.pdf`, kind: "file", size: 512 }, + { name: "concept.html", path: `${artifactRoot}/concept.html`, kind: "file", size: 320 }, + { name: "latest.png", path: `${artifactRoot}/latest.png`, kind: "file", size: 68 }, + { name: "report.md", path: `${artifactRoot}/report.md`, kind: "file", size: 49 }, + { name: "walkthrough.webm", path: `${artifactRoot}/walkthrough.webm`, kind: "file", size: 640 }, + ] + : path === `${artifactRoot}/runs` + ? [{ name: "output.png", path: `${artifactRoot}/runs/output.png`, kind: "file", size: 68 }] + : [ + { name: "src", path: "src", kind: "directory" }, + { name: "preview.png", path: "preview.png", kind: "file", size: 68 }, + { name: "README.md", path: "README.md", kind: "file", size: 17 }, + ]; + await route.fulfill({ json: { ok: true, path, entries } }); }); await page.route("**/api/files/image**", (route) => route.fulfill({ contentType: "image/svg+xml", body: '' })); + await page.route(/\/api\/(?:session-)?artifacts\//, async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith(".html")) { + await route.fulfill({ contentType: "text/html", body: '' }); + } else if (path.endsWith(".webm")) { + await route.fulfill({ contentType: "video/webm", body: Buffer.from([]) }); + } else if (path.endsWith(".pdf")) { + await route.fulfill({ contentType: "application/pdf", body: Buffer.from("%PDF-1.4\n%%EOF") }); + } else { + await route.fulfill({ contentType: "image/svg+xml", body: '' }); + } + }); await page.route("**/api/files/read**", async (route) => { const path = new URL(route.request().url()).searchParams.get("path") as keyof typeof files; const file = files[path]; @@ -32,11 +57,148 @@ test("browser back closes an open panel", async ({ page }) => { await page.goto("/"); await page.locator("#filesButton").evaluate((button: HTMLButtonElement) => button.click()); await expect(page.locator("#filesPanel")).toBeVisible(); + await page.locator(".fileTreeDirectory summary", { hasText: "src" }).click(); + await expect(page.locator('.fileTreeFile[title="src/app.ts"]')).toBeVisible(); + + await page.goBack(); + await expect(page.locator("#filesPanel")).toBeHidden(); + await openLauncherAction(page, "File explorer"); + await expect(page.locator('.fileTreeFile[title="src/app.ts"]')).toBeVisible(); +}); +test("browser back returns an open file to the retained tree before closing the panel", async ({ page }) => { + await page.goto("/"); + await openLauncherAction(page, "File explorer"); + await page.locator(".fileTreeDirectory summary", { hasText: "src" }).click(); + await expect(page.locator('.fileTreeFile[title="src/app.ts"]')).toBeVisible(); + await page.locator('.fileTreeFile[title="README.md"]').click(); + await expect(page.locator("#filesPanel")).toHaveAttribute("data-mobile-view", "editor"); + + await page.goBack(); + await expect(page.locator("#filesPanel")).toBeVisible(); + await expect(page.locator("#filesPanel")).toHaveAttribute("data-mobile-view", "tree"); + await expect(page.locator('.fileTreeFile[title="src/app.ts"]')).toBeVisible(); await page.goBack(); await expect(page.locator("#filesPanel")).toBeHidden(); }); +test("artifacts scope browses a visual gallery, folders, and a large preview", async ({ page }) => { + await page.goto("/"); + await openLauncherAction(page, "File explorer"); + + const panel = page.locator("#filesPanel"); + const workspace = page.locator("#filesWorkspaceScope"); + const artifacts = page.locator("#filesArtifactsScope"); + await expect(workspace).toHaveAttribute("aria-pressed", "true"); + await page.locator(".fileTreeDirectory summary", { hasText: "src" }).click(); + await expect(page.locator('.fileTreeFile[title="src/app.ts"]')).toBeVisible(); + + await artifacts.click(); + await expect(panel).toHaveAttribute("data-files-scope", "artifacts"); + await expect(panel).toHaveAttribute("data-artifact-view", "gallery"); + await expect(panel).toHaveAttribute("aria-label", "Artifacts"); + await expect(page.locator("#filesPanelHeadingLabel")).toHaveText("Artifacts"); + await expect(artifacts).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator(`.artifactGalleryCard[data-artifact-path="${artifactRoot}/latest.png"] img`)).toBeVisible(); + await expect(page.locator(`.artifactGalleryCard[data-artifact-path="${artifactRoot}/.DS_Store"]`)).toHaveCount(0); + await expect(page.locator('.fileTreeFile[title="README.md"]')).not.toBeVisible(); + + await page.getByRole("button", { name: "Open folder runs" }).click(); + await expect(page.locator(`.artifactGalleryCard[data-artifact-path="${artifactRoot}/runs/output.png"]`)).toBeVisible(); + await expect(page.locator("#artifactsGalleryBreadcrumb")).toContainText("runs"); + + await workspace.click(); + await expect(page.locator('.fileTreeFile[title="src/app.ts"]')).toBeVisible(); + await artifacts.click(); + await expect(page.locator(`.artifactGalleryCard[data-artifact-path="${artifactRoot}/runs/output.png"]`)).toBeVisible(); + await expect(page.locator("#artifactsGalleryBreadcrumb")).toContainText("runs"); + await page.locator("#artifactsGalleryBreadcrumb").getByRole("button", { name: "Artifacts" }).click(); + + await page.getByRole("button", { name: "Preview latest.png" }).click(); + await expect(panel).toHaveAttribute("data-artifact-view", "preview"); + await expect(page.locator("#artifactBrowserPreviewBody > img")).toBeVisible(); + await expect(page.locator("#artifactBrowserPreviewOpen")).toHaveAttribute("href", "/api/session-artifacts/mock-current/latest.png"); + await page.locator("#filesCloseButton").click(); + await openLauncherAction(page, "File explorer"); + await expect(panel).toHaveAttribute("data-files-scope", "artifacts"); + await expect(panel).toHaveAttribute("data-artifact-view", "preview"); + await expect(page.locator("#artifactBrowserPreviewBody > img")).toBeVisible(); + await page.locator("#artifactBrowserPreviewBack").click(); + await expect(panel).toHaveAttribute("data-artifact-view", "gallery"); + + await workspace.click(); + await expect(panel).toHaveAttribute("data-files-scope", "workspace"); + await expect(panel).toHaveAttribute("aria-label", "Workspace files"); + await expect(page.locator("#filesPanelHeadingLabel")).toHaveText("Explorer"); + await expect(page.locator('.fileTreeFile[title="README.md"]')).toBeVisible(); +}); + +test("large artifact preview renders interactive HTML, Markdown, and video", async ({ page }) => { + await page.goto("/"); + await openLauncherAction(page, "File explorer"); + await page.locator("#filesArtifactsScope").click(); + + const thumbnail = page.locator(`.artifactGalleryCard[data-artifact-path="${artifactRoot}/concept.html"] iframe`); + await expect(thumbnail).toHaveAttribute("inert", ""); + await expect(thumbnail).toHaveAttribute("aria-hidden", "true"); + await page.getByRole("button", { name: "Preview concept.html" }).click(); + const htmlFrame = page.locator("#artifactBrowserPreviewBody iframe"); + await expect(htmlFrame).toHaveAttribute("sandbox", "allow-scripts"); + await expect(htmlFrame.contentFrame().locator("#interactive")).toHaveText("Ready"); + await htmlFrame.contentFrame().locator("#interactive").click(); + await expect(htmlFrame.contentFrame().locator("#interactive")).toHaveText("Clicked"); + await page.goBack(); + await expect(page.locator("#filesPanel")).toBeVisible(); + await expect(page.locator("#filesPanel")).toHaveAttribute("data-artifact-view", "gallery"); + await expect(page.getByRole("button", { name: "Preview concept.html" })).toBeVisible(); + await expect(htmlFrame).toHaveCount(0); + + await page.goForward(); + await expect(page.locator("#filesPanel")).toHaveAttribute("data-artifact-view", "preview"); + await expect(htmlFrame.contentFrame().locator("#interactive")).toHaveText("Ready"); + await page.goBack(); + await expect(page.locator("#filesPanel")).toHaveAttribute("data-artifact-view", "gallery"); + await page.goBack(); + await expect(page.locator("#filesPanel")).toBeHidden(); + await openLauncherAction(page, "File explorer"); + await expect(page.locator("#filesPanel")).toHaveAttribute("data-artifact-view", "gallery"); + await page.getByRole("button", { name: "Preview report.md" }).click(); + await expect(page.locator("#artifactBrowserPreviewBody h1")).toHaveText("Artifact report"); + await expect(page.locator("#artifactBrowserPreviewBody strong")).toContainText("rendered"); + + await page.locator("#artifactBrowserPreviewBack").click(); + await page.getByRole("button", { name: "Preview walkthrough.webm" }).click(); + await expect(page.locator("#artifactBrowserPreviewBody video")).toHaveAttribute("controls", ""); + await expect(page.locator("#artifactBrowserPreviewBody source")).toHaveAttribute("type", "video/webm"); + + await page.locator("#artifactBrowserPreviewBack").click(); + await page.getByRole("button", { name: "Preview brief.pdf" }).click(); + await expect(page.locator("#artifactBrowserPreviewBody")).toHaveClass(/artifactBrowserPreviewBody--pdf/); + await expect(page.locator("#artifactBrowserPreviewBody iframe")).toHaveAttribute("title", "Preview of brief.pdf"); + await page.keyboard.press("Escape"); + await expect(page.locator("#filesPanel")).toHaveAttribute("data-artifact-view", "gallery"); +}); + +test("artifacts scope treats a missing artifact directory as an empty collection", async ({ page }) => { + await page.unroute("**/api/files/tree**"); + await page.route("**/api/files/tree**", async (route) => { + const path = new URL(route.request().url()).searchParams.get("path") || ""; + if (path === artifactRoot) { + await route.fulfill({ status: 404, json: { ok: false, error: "File not found" } }); + return; + } + await route.fulfill({ json: { ok: true, path, entries: [{ name: "README.md", path: "README.md", kind: "file", size: 17 }] } }); + }); + + await page.goto("/"); + await openLauncherAction(page, "File explorer"); + await page.locator("#filesArtifactsScope").click(); + const empty = page.locator(".artifactGalleryState--empty"); + await expect(empty).toContainText("No artifacts yet"); + await expect(empty).toContainText("Generated images, pages, reports, and videos will appear here."); + await expect(page.locator("#artifactsTree")).not.toHaveAttribute("aria-busy", "true"); +}); + test("explorer opens, edits, saves, wraps, resizes text, and closes tabs", async ({ page }, testInfo) => { let savedBody: Record | undefined; await page.route("**/api/files/write", async (route) => { @@ -114,10 +276,13 @@ test("touch-first mobile file opening does not focus the editor", async ({ page test.skip(testInfo.project.name !== "mobile", "Mobile keyboard behavior"); await page.goto("/"); await openLauncherAction(page, "File explorer"); + await page.locator(".fileTreeDirectory summary", { hasText: "src" }).click(); + await expect(page.locator('.fileTreeFile[title="src/app.ts"]')).toBeVisible(); await page.locator('.fileTreeFile[title="README.md"]').click(); await expect(page.locator("#filesPanel")).toHaveAttribute("data-mobile-view", "editor"); await expect(page.locator(".cm-content")).not.toBeFocused(); await expect(page.locator("#fileBackButton")).toBeVisible(); await page.locator("#fileBackButton").click(); await expect(page.locator("#filesPanel")).toHaveAttribute("data-mobile-view", "tree"); + await expect(page.locator('.fileTreeFile[title="src/app.ts"]')).toBeVisible(); }); diff --git a/tests/e2e/pi-web.spec.ts b/tests/e2e/pi-web.spec.ts index a9ce2eb..ba75465 100644 --- a/tests/e2e/pi-web.spec.ts +++ b/tests/e2e/pi-web.spec.ts @@ -313,10 +313,25 @@ test.describe("composer layout", () => { await expect(other.locator("#statusTitle")).toHaveText("Current mock session"); await expect(page.locator("#statusTitle")).toHaveText("Older mock session"); + await page.evaluate(() => history.replaceState({ + ...history.state, + piWebArtifactView: { view: "preview", entry: { name: "older.html", path: ".pi/web/artifacts/older.html", kind: "file" } }, + piWebWorkspaceView: { view: "editor" }, + }, "")); await page.locator("#sessionButton").click(); await page.locator(".sessionItem", { hasText: "Current mock session" }).locator(".sessionItemNavBtn").click(); await expect(page).toHaveURL(/sessionId=mock-current/); await expect(page.locator("#statusTitle")).toHaveText("Current mock session"); + await expect.poll(() => page.evaluate(() => ({ artifact: history.state.piWebArtifactView, workspace: history.state.piWebWorkspaceView }))).toEqual({ artifact: undefined, workspace: undefined }); + + await page.goBack(); + await expect(page).toHaveURL(/sessionId=mock-older/); + await expect(page.locator("#statusTitle")).toHaveText("Older mock session"); + await expect.poll(() => page.evaluate(() => history.state.piWebArtifactView?.entry?.name)).toBe("older.html"); + await page.goForward(); + await expect(page).toHaveURL(/sessionId=mock-current/); + await expect(page.locator("#statusTitle")).toHaveText("Current mock session"); + await expect.poll(() => page.evaluate(() => history.state.piWebArtifactView)).toBeUndefined(); await expect(other.locator("#statusTitle")).toHaveText("Current mock session"); await other.close(); }); diff --git a/tests/e2e/visual.spec.ts b/tests/e2e/visual.spec.ts index 8ddbb8d..81e37d7 100644 --- a/tests/e2e/visual.spec.ts +++ b/tests/e2e/visual.spec.ts @@ -3,6 +3,8 @@ import { openLauncherAction } from "./helpers/actionLauncher.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +const visualArtifactRoot = ".pi/web/artifacts"; + async function sendPrompt(page: import("@playwright/test").Page, prompt: string) { await page.locator("#prompt").fill(prompt); await page.locator("#primaryButton").click(); @@ -87,30 +89,52 @@ async function mockConversationTreeApi(page: import("@playwright/test").Page) { async function mockFilesApi(page: import("@playwright/test").Page) { await page.route("**/api/files/tree**", async (route) => { const path = new URL(route.request().url()).searchParams.get("path") || ""; - await route.fulfill({ json: path === "src" ? { - ok: true, path, entries: [ - { name: "app", path: "src/app", kind: "directory" }, - { name: "main.ts", path: "src/main.ts", kind: "file", size: 152 }, - { name: "styles.css", path: "src/styles.css", kind: "file", size: 84 }, - ], - } : { - ok: true, path: "", entries: [ - { name: "src", path: "src", kind: "directory" }, - { name: "tests", path: "tests", kind: "directory" }, - { name: "package.json", path: "package.json", kind: "file", size: 418 }, - { name: "README.md", path: "README.md", kind: "file", size: 226 }, - ], - } }); + const entries = path === "src" + ? [ + { name: "app", path: "src/app", kind: "directory" }, + { name: "main.ts", path: "src/main.ts", kind: "file", size: 152 }, + { name: "styles.css", path: "src/styles.css", kind: "file", size: 84 }, + ] + : path === visualArtifactRoot + ? [ + { name: "image-edits", path: `${visualArtifactRoot}/image-edits`, kind: "directory" }, + { name: "showcase", path: `${visualArtifactRoot}/showcase`, kind: "directory" }, + { name: "concept.html", path: `${visualArtifactRoot}/concept.html`, kind: "file", size: 4_320 }, + { name: "launch-preview.png", path: `${visualArtifactRoot}/launch-preview.png`, kind: "file", size: 125_400 }, + { name: "notes.md", path: `${visualArtifactRoot}/notes.md`, kind: "file", size: 1_860 }, + { name: "walkthrough.webm", path: `${visualArtifactRoot}/walkthrough.webm`, kind: "file", size: 820_100 }, + ] + : path === `${visualArtifactRoot}/image-edits` + ? [ + { name: "final.png", path: `${visualArtifactRoot}/image-edits/final.png`, kind: "file", size: 96_500 }, + { name: "source.png", path: `${visualArtifactRoot}/image-edits/source.png`, kind: "file", size: 104_200 }, + ] + : [ + { name: "src", path: "src", kind: "directory" }, + { name: "tests", path: "tests", kind: "directory" }, + { name: "package.json", path: "package.json", kind: "file", size: 418 }, + { name: "README.md", path: "README.md", kind: "file", size: 226 }, + ]; + await route.fulfill({ json: { ok: true, path, entries } }); + }); + await page.route(/\/api\/(?:session-)?artifacts\//, async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith(".html")) { + await route.fulfill({ contentType: "text/html", body: `
Interactive artifact

Constellation

A tactile prototype with live controls, motion, and a warm editorial palette.

` }); + } else if (path.endsWith(".webm")) { + await route.fulfill({ contentType: "video/webm", body: Buffer.from([]) }); + } else { + await route.fulfill({ contentType: "image/svg+xml", body: '' }); + } + }); + await page.route("**/api/files/read**", async (route) => { + const path = new URL(route.request().url()).searchParams.get("path") || "README.md"; + const artifact = path === `${visualArtifactRoot}/notes.md`; + const content = artifact + ? "# Artifact field notes\n\nA rendered study of the new **gallery and interactive preview** experience.\n" + : "# pi-web\n\nA focused, responsive web UI for the pi coding agent.\n\n## Workspace Explorer\n\n- Browse the active session directory\n- Edit files with syntax highlighting\n- Save safely with revision conflict detection\n- Preview images without leaving the workspace\n"; + await route.fulfill({ json: { ok: true, path, size: content.length, readOnly: false, language: "markdown", revision: artifact ? "artifact-notes" : "showcase-readme", content } }); }); - await page.route("**/api/files/read**", (route) => route.fulfill({ json: { - ok: true, - path: "README.md", - size: 226, - readOnly: false, - language: "markdown", - revision: "showcase-readme", - content: "# pi-web\n\nA focused, responsive web UI for the pi coding agent.\n\n## Workspace Explorer\n\n- Browse the active session directory\n- Edit files with syntax highlighting\n- Save safely with revision conflict detection\n- Preview images without leaving the workspace\n", - } })); } async function mockGitApi(page: import("@playwright/test").Page) { @@ -335,6 +359,42 @@ test.describe("visual regression", () => { }); }); + test("artifacts explorer", async ({ page }, testInfo) => { + test.skip(testInfo.project.name === "tablet", "Covered by mobile and desktop visual snapshots"); + if (testInfo.project.name === "desktop") await page.setViewportSize({ width: 1600, height: 1000 }); + await mockFilesApi(page); + + await page.goto("/"); + await openLauncherAction(page, "File explorer"); + await page.locator("#filesArtifactsScope").click(); + await expect(page.locator(`.artifactGalleryCard[data-artifact-path="${visualArtifactRoot}/launch-preview.png"] img`)).toBeVisible(); + const thumbnail = page.locator(`.artifactGalleryCard[data-artifact-path="${visualArtifactRoot}/concept.html"] iframe`); + await expect(thumbnail.contentFrame().locator("h1")).toHaveText("Constellation"); + + await expect(page).toHaveScreenshot(`artifacts-explorer-${testInfo.project.name}.png`, { + fullPage: true, + animations: "disabled", + }); + }); + + test("large interactive artifact preview", async ({ page }, testInfo) => { + test.skip(testInfo.project.name === "tablet", "Covered by mobile and desktop visual snapshots"); + if (testInfo.project.name === "desktop") await page.setViewportSize({ width: 1600, height: 1000 }); + await mockFilesApi(page); + + await page.goto("/"); + await openLauncherAction(page, "File explorer"); + await page.locator("#filesArtifactsScope").click(); + await page.getByRole("button", { name: "Preview concept.html" }).click(); + const preview = page.locator("#artifactBrowserPreviewBody iframe"); + await expect(preview.contentFrame().locator("h1")).toHaveText("Constellation"); + + await expect(page).toHaveScreenshot(`artifact-preview-${testInfo.project.name}.png`, { + fullPage: true, + animations: "disabled", + }); + }); + test("diff review", async ({ page }, testInfo) => { test.skip(testInfo.project.name === "tablet", "Covered by mobile and desktop visual snapshots"); diff --git a/tests/e2e/visual.spec.ts-snapshots/artifact-preview-desktop.png b/tests/e2e/visual.spec.ts-snapshots/artifact-preview-desktop.png new file mode 100644 index 0000000..6af1fbe Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/artifact-preview-desktop.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/artifact-preview-mobile.png b/tests/e2e/visual.spec.ts-snapshots/artifact-preview-mobile.png new file mode 100644 index 0000000..e3fccad Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/artifact-preview-mobile.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/artifacts-explorer-desktop.png b/tests/e2e/visual.spec.ts-snapshots/artifacts-explorer-desktop.png new file mode 100644 index 0000000..e2c7b1c Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/artifacts-explorer-desktop.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/artifacts-explorer-mobile.png b/tests/e2e/visual.spec.ts-snapshots/artifacts-explorer-mobile.png new file mode 100644 index 0000000..0b71745 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/artifacts-explorer-mobile.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/workspace-explorer-desktop.png b/tests/e2e/visual.spec.ts-snapshots/workspace-explorer-desktop.png index 93beb27..2241d73 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/workspace-explorer-desktop.png and b/tests/e2e/visual.spec.ts-snapshots/workspace-explorer-desktop.png differ