diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 1059261f..b02b5e69 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -950,11 +950,11 @@ "packages/core/schema.json": "79ef4e2f0dd8103ac7a4b6ff3ddb0fd10764655f1dc678c1c401dfc43be9a1aa", "packages/session-ui/bunfig.toml": "82585987c8258caf58d07c6aaf4e49b7338e4da16f97c785af398aace9e6e722", "packages/session-ui/happydom.ts": "d81d6b14e988f058fa502c841df34b55307c57fc94a26fb4f8a5deb6aa0d4834", - "packages/session-ui/package.json": "1c69e65b039e6262ce50defb798233e5db99aeca4b0fe66885f7e35c2433b685", + "packages/session-ui/package.json": "f1edc72de0e5aa3a1b045be1c825ca359dafc8bd9cbb89208a21c53226a37d40", "packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629", "packages/app/public/amico.svg": "a14b9d543d895bcdf0758f7b9ef5908ee0acaac794446494af059b159247db8f", "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", - "packages/app/src/app.tsx": "a72e7cef35d5de80980fbb1fc26c14d8551d1677821e72798c624842927b55fd", + "packages/app/src/app.tsx": "98f367769ed22ed33cbb8ff6067b3b5fc829acef1fad62b791cfd286ed9aec54", "packages/app/src/design-polish.css": "42cc6efaefe9a71dedd12fcb0bf2549453d9a46097cb025cf087064b29d3ccce", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", "packages/app/src/index.css": "08179e06ce2d419a2d98acc96025f91c7709062ea9f3ad245e88dc35e75ff9f7", @@ -1408,7 +1408,7 @@ "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", "packages/app/src/components/session/session-header.tsx": "a46591ed1097d0fdcff61fb0c5529396955e8857cbef748747c51e472d5564c5", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", - "packages/app/src/components/session/session-preview-tab.tsx": "b6b4b8fe6f751499e0d3005e1f12224351df4f28f221bba10fc2f91c0f09e777", + "packages/app/src/components/session/session-preview-tab.tsx": "103d531614d0f904b7bb255d484ff681aaad5b66aafea308e2774c661f57bb1a", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b", "packages/app/src/components/session/use-context-warning.ts": "af7a6d0159a5541aa02ad4d08fd694af1cc4853fc1c0a70763bc264a634d1c53", "packages/app/src/components/settings-v2/data-storage-controller.ts": "fa5d143cc101f3a3b9d5ad445edddc981e0d02783021d52dbfed6c8d8bf62498", @@ -1473,7 +1473,7 @@ "packages/session-ui/src/v2/components/basic-tool-v2.css": "55d3aa179704deddda7149342a951229ff5561127f786dd551e5df9aea8a7835", "packages/session-ui/src/v2/components/basic-tool-v2.tsx": "ece46d761f7d51a7a0925b3df00a5c289d891e9a514ee721016eb723d87fb406", "packages/session-ui/src/v2/components/diff-gutter-extension.ts": "aed07587c0c9a5f110f9ea91aa42679d6f9f66b2ec4de9043d6e2233a375acb8", - "packages/session-ui/src/v2/components/editable-diff-view-core.ts": "d6508b56ab48b956b8e5a66319323d17241a783bc9a73d869afbedc20159ad97", + "packages/session-ui/src/v2/components/editable-diff-view-core.ts": "0c05afeceeb92220b12ffde55b4887be97c6ff61931344bd8eaad0872ac32910", "packages/session-ui/src/v2/components/editable-diff-view.tsx": "4c0ee2525b94b04b1e708a5cacab6d3eed29ca30644321986e429b2f912b730b", "packages/session-ui/src/v2/components/file-name-picker.test.tsx": "1ce9741e315b549165d390c74b5885a43580aa70e24de987f9ba8388cd521b41", "packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx": "4f1fdc944b0d422689176d04cb3580f628578915f97ea8d78594dfb9ac217146", diff --git a/packages/app-bundle/overlay/packages/app/src/app.tsx b/packages/app-bundle/overlay/packages/app/src/app.tsx index 19128cf8..d8728421 100644 --- a/packages/app-bundle/overlay/packages/app/src/app.tsx +++ b/packages/app-bundle/overlay/packages/app/src/app.tsx @@ -2,6 +2,7 @@ import "@/index.css" import * as Sentry from "@sentry/solid" import { requestComputeConnect } from "@/components/amicode-defaults-capsule" import { adoptWorkspaceProjects, workspaceProjects } from "@/utils/amicode-workspace-projects" +import { adoptPreviewFileTree } from "@/utils/amicode-preview-file-tree" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" @@ -446,6 +447,16 @@ function AmicodeThemeBridge() { adoptWorkspaceProjects((d as { projects?: unknown[] }).projects as Parameters[0]) return } + // amicode#725: preview file tree push from the extension host. + if (d.kind === "preview-file-tree") { + const msg = d as { files?: string[]; projectRoot?: string; environment?: unknown } + adoptPreviewFileTree( + (msg.files ?? []) as string[], + (msg.projectRoot ?? "") as string, + msg.environment as Parameters[2], + ) + return + } if (d.kind !== "theme") return if (d.colorScheme === "light" || d.colorScheme === "dark") theme.setColorScheme(d.colorScheme) } diff --git a/packages/app-bundle/overlay/packages/app/src/components/pdf-viewer.tsx b/packages/app-bundle/overlay/packages/app/src/components/pdf-viewer.tsx new file mode 100644 index 00000000..b70e3a4d --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/pdf-viewer.tsx @@ -0,0 +1,204 @@ +// pdf-viewer.tsx — PDF rendering for the Preview tab (#727). +// Uses pdfjs-dist to render PDF pages to canvas in a scrollable container. +// Zoom controls, scroll position preservation across reloads, and +// device-pixel-ratio-aware rendering. + +import { createEffect, createSignal, For, on, onCleanup, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import * as pdfjsLib from "pdfjs-dist" + +// ── Worker setup ──────────────────────────────────────────────────────────── +// The pdfjs worker handles the heavy PDF parsing off the main thread. +// Vite's ?url suffix gives us the asset URL without inlining. +import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url" + +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface PageState { + pageNum: number + rendered: boolean +} + +// ── Component ─────────────────────────────────────────────────────────────── + +export function PDFViewer(props: { + /** Binary content as base64 string, or a file URL. */ + data: string | null + /** Whether data is base64-encoded binary. */ + isBase64?: boolean + /** Current zoom level (100 = 100%). */ + zoom: number +}) { + const [pages, setPages] = createSignal([]) + const [loading, setLoading] = createSignal(true) + const [error, setError] = createSignal(false) + const [pdfDoc, setPdfDoc] = createSignal(null) + + let containerRef: HTMLDivElement | undefined + let scrollTop = 0 + let activeLoadingTask: pdfjsLib.PDFDocumentLoadingTask | null = null + + // Track scroll position for preservation across reloads + const saveScroll = () => { + if (containerRef) scrollTop = containerRef.scrollTop + } + + // ── Load PDF ──────────────────────────────────────────────────────────── + + createEffect( + on( + () => props.data, + async (data) => { + if (!data) { + setLoading(false) + setError(true) + return + } + + setLoading(true) + setError(false) + + try { + saveScroll() + + // Clean up previous loading task + if (activeLoadingTask) { + activeLoadingTask.destroy() + activeLoadingTask = null + } + setPdfDoc(null) + + // Load PDF from base64 or URL + let loadingTask: pdfjsLib.PDFDocumentLoadingTask + if (props.isBase64) { + const binaryStr = atob(data) + const bytes = new Uint8Array(binaryStr.length) + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i) + } + loadingTask = pdfjsLib.getDocument({ data: bytes }) + } else { + loadingTask = pdfjsLib.getDocument({ url: data }) + } + activeLoadingTask = loadingTask + + const doc = await loadingTask.promise + setPdfDoc(doc) + + const pageStates: PageState[] = [] + for (let i = 1; i <= doc.numPages; i++) { + pageStates.push({ pageNum: i, rendered: false }) + } + setPages(pageStates) + + // Restore scroll position after render + requestAnimationFrame(() => { + if (containerRef) containerRef.scrollTop = scrollTop + }) + } catch { + setError(true) + } finally { + setLoading(false) + } + }, + ), + ) + + onCleanup(() => { + if (activeLoadingTask) { + activeLoadingTask.destroy() + activeLoadingTask = null + } + }) + + // ── Render ────────────────────────────────────────────────────────────── + + return ( +
+ +
Loading PDF...
+
+ +
+
+ +

Could not load PDF

+
+
+
+ +
(containerRef = el)} class="flex-1 min-h-0 overflow-auto" onScroll={saveScroll}> +
+ + {(page) => ( + + )} + +
+
+
+
+ ) +} + +// ── Single page renderer ──────────────────────────────────────────────────── + +function PDFPage(props: { + doc: pdfjsLib.PDFDocumentProxy + pageNum: number + zoom: number +}) { + let canvasRef: HTMLCanvasElement | undefined + const [pageError, setPageError] = createSignal(false) + + createEffect( + on( + () => [props.doc, props.pageNum, props.zoom] as const, + async ([doc, num, zoom]) => { + if (!canvasRef || !doc) return + setPageError(false) + + try { + const page = await doc.getPage(num) + const scale = (zoom / 100) * (window.devicePixelRatio || 1) + const viewport = page.getViewport({ scale }) + + canvasRef.width = viewport.width + canvasRef.height = viewport.height + canvasRef.style.width = `${viewport.width / (window.devicePixelRatio || 1)}px` + canvasRef.style.height = `${viewport.height / (window.devicePixelRatio || 1)}px` + + const ctx = canvasRef.getContext("2d") + if (!ctx) return + + await page.render({ canvasContext: ctx, canvas: canvasRef, viewport }).promise + } catch { + setPageError(true) + } + }, + ), + ) + + return ( + + Could not render page {props.pageNum} + + } + > + (canvasRef = el)} + class="shadow-sm border border-border-weaker-base" + data-page={props.pageNum} + /> + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-content-area.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-content-area.tsx new file mode 100644 index 00000000..d7c9ae8a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-content-area.tsx @@ -0,0 +1,279 @@ +// preview-content-area.tsx — Content area for the Preview tab (#726). +// Routes files to type-appropriate renderers based on renderer kind. + +import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js" +import { Markdown } from "@opencode-ai/session-ui/markdown" +import { PlainEditor } from "@opencode-ai/session-ui/v2/plain-editor" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { SegmentedControlV2, SegmentedControlItemV2 } from "@opencode-ai/ui/v2/segmented-control-v2" +import { preprocessMarkdown } from "@/utils/preview-markdown" +import { + rendererForExtension, + toolbarForRenderer, + extFromPath, + isReadOnly, + type RendererKind, +} from "@/utils/renderer-dispatch" +import { PDFViewer } from "@/components/pdf-viewer" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" + +// ─── Types ────────────────────────────────────────────────────────────────── + +type ContentMode = "preview" | "editor" + +// ─── Main Component ───────────────────────────────────────────────────────── + +export function PreviewContentArea(props: { + filePath: string + onBack: () => void +}) { + const sdk = useSDK() + const serverSDK = useServerSDK() + + const ext = createMemo(() => extFromPath(props.filePath)) + const kind = createMemo((): RendererKind => rendererForExtension(ext())) + const toolbar = createMemo(() => toolbarForRenderer(kind(), false /* TODO: wire texAvailable from #729 */)) + const readOnly = createMemo(() => isReadOnly(ext())) + + const [content, setContent] = createSignal("") + const [isBinary, setIsBinary] = createSignal(false) + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(false) + const [mode, setMode] = createSignal("preview") + const [zoom, setZoom] = createSignal(100) + + const zoomIn = () => setZoom((z) => Math.min(z + 10, 200)) + const zoomOut = () => setZoom((z) => Math.max(z - 10, 50)) + + // ─── File content loading ─────────────────────────────────────────────── + + createEffect( + on( + () => props.filePath, + (path) => { + if (!path) return + setLoading(true) + setError(false) + setIsBinary(false) + + sdk() + .client.file.read({ path }) + .then((result) => { + const data = result.data + if (data && data.type === "text") { + setContent(data.content) + setIsBinary(false) + } else if (data && data.type === "binary") { + // Binary files (images, PDFs) — store the base64 data + setContent(data.content) + setIsBinary(true) + } else { + setContent("") + } + }) + .catch(() => { + setError(true) + setContent("") + }) + .finally(() => { + setLoading(false) + }) + }, + ), + ) + + // ─── Save logic (editor mode) ────────────────────────────────────────── + + let saveTimer: ReturnType | undefined + const [saveStatus, setSaveStatus] = createSignal<"idle" | "saving" | "saved">("idle") + let savedTimer: ReturnType | undefined + + const saveFile = (path: string, newContent: string) => { + const baseUrl = serverSDK().url + if (!baseUrl) return + + setSaveStatus("saving") + + fetch(new URL("/file/write", baseUrl), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, content: newContent }), + }) + .then(() => { + setSaveStatus("saved") + if (savedTimer) clearTimeout(savedTimer) + savedTimer = setTimeout(() => setSaveStatus("idle"), 2000) + }) + .catch(() => { + setSaveStatus("idle") + }) + } + + const handleEdit = (newContent: string) => { + setContent(newContent) + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => saveFile(props.filePath, newContent), 1000) + } + + const handleImmediateSave = () => { + if (saveTimer) clearTimeout(saveTimer) + saveFile(props.filePath, content()) + } + + onCleanup(() => { + if (saveTimer) clearTimeout(saveTimer) + if (savedTimer) clearTimeout(savedTimer) + }) + + // ─── Helpers ──────────────────────────────────────────────────────────── + + const basename = () => { + const parts = props.filePath.split("/") + return parts[parts.length - 1] ?? props.filePath + } + + // ─── Render ───────────────────────────────────────────────────────────── + + return ( +
+ {/* Toolbar */} +
+ +
{basename()}
+ + {/* Save status (editor kind) */} + + + {saveStatus() === "saving" ? "Saving..." : "Saved"} + + + + {/* Zoom control (pdf/image/markdown) */} + +
+ { + const val = parseInt(e.currentTarget.value) + if (!isNaN(val) && val >= 50 && val <= 200) setZoom(val) + }} + onBlur={(e) => { + e.currentTarget.value = `${zoom()}%` + }} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur() + }} + /> +
+ + +
+
+
+ + {/* Build button (TeX, when available — wired by slice 5) */} + + + + + + + {/* Mode toggle (markdown: preview ↔ editor) */} + + { + if (value === "preview" || value === "editor") setMode(value) + }} + class="!w-auto" + aria-label="View mode" + > + + + + + + + + + + + + +
+ + {/* Content area */} +
+ Loading...
}> + }> + }> + +
+ +
+
+ + + + + + + +
+ {basename()} +
+
+
+
+ +
+ + ) +} + +// ─── Error state ──────────────────────────────────────────────────────────── +function ErrorState() { + return ( +
+
+ +

Could not load file

+

The file may have been deleted or moved

+
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx index 787df200..d090cf7a 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx @@ -7,8 +7,18 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { SegmentedControlV2, SegmentedControlItemV2 } from "@opencode-ai/ui/v2/segmented-control-v2" import { writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" +import FileTreeV2 from "@/components/file-tree-v2" +import { + previewFileTree, + previewProjectRoot, + previewEnv, + requestPreviewFileTreeRefresh, +} from "@/utils/amicode-preview-file-tree" +import { preprocessMarkdown } from "@/utils/preview-markdown" +import { PreviewContentArea } from "@/components/session/preview-content-area" import { useSDK } from "@/context/sdk" import { useServerSDK } from "@/context/server-sdk" +import type { FileNode } from "@opencode-ai/sdk/v2" // ─── Types ────────────────────────────────────────────────────────────────── @@ -26,16 +36,16 @@ interface PreviewFileState { unsavedContent?: string } -// ─── Main Component ───────────────────────────────────────────────────────── +// ─── Helpers ──────────────────────────────────────────────────────────────── -/** - * Convert ```math fenced code blocks (GitHub-flavored) to $$...$$ display math - * blocks that the Markdown component's KaTeX extension understands. - */ -function preprocessMarkdown(text: string): string { - return text.replace(/```math\n([\s\S]*?)```/g, (_match, body: string) => `$$\n${body.trim()}\n$$`) +/** Extract the filename from an absolute or relative path. */ +function basename(path: string): string { + const parts = path.split("/") + return parts[parts.length - 1] ?? path } +// ─── Main Component ───────────────────────────────────────────────────────── + export function SessionPreviewTab(props: { diffs: () => Array<{ file: string; status?: string }> touchedFiles?: () => Array<{ file: string; status: string }> @@ -51,8 +61,12 @@ export function SessionPreviewTab(props: { const zoomIn = () => setZoom((z) => Math.min(z + 10, 200)) const zoomOut = () => setZoom((z) => Math.max(z - 10, 50)) + /** Whether the project-wide file tree is available (extension pushed files). */ + const hasProjectTree = createMemo(() => previewFileTree().length > 0) + // Derive the file list from touchedFiles (tool-edit history, persists regardless of git state) // supplemented by diffs for any files not already covered. + // Used as fallback when no project tree is available. const markdownFiles = createMemo((): PreviewFileEntry[] => { const seen = new Set() const entries: PreviewFileEntry[] = [] @@ -61,65 +75,55 @@ export function SessionPreviewTab(props: { const touched = props.touchedFiles?.() ?? [] const suffixToAbsolute = new Map() for (const t of touched) { - // For "/Users/jj/.julia/dev/X", try progressively shorter suffixes const parts = t.file.split("/") for (let i = 1; i < parts.length; i++) { suffixToAbsolute.set(parts.slice(i).join("/"), t.file) } } - // Resolve ~/... to absolute using the suffix map const resolveFile = (file: string): string => { if (!file.startsWith("~/")) return file - const suffix = file.slice(2) // strip ~/ + const suffix = file.slice(2) const absolute = suffixToAbsolute.get(suffix) return absolute ?? file } const toEntry = (file: string, status: string): PreviewFileEntry | null => { if (!file.endsWith(".md")) return null - // Use the resolved absolute path as the canonical key const resolved = resolveFile(file) if (seen.has(resolved)) return null seen.add(resolved) const parts = resolved.split("/") - const basename = parts[parts.length - 1] - // Show a short relative path: strip common leading segments until we hit a recognizable dir + const name = parts[parts.length - 1] const relativePath = resolved.replace(/^\/Users\/[^/]+\//, "") return { path: resolved, relativePath, - basename, + basename: name, extension: ".md" as const, changeType: (status === "added" ? "added" : "modified") as "added" | "modified", } } - // Primary: all files touched by edit tools in this session for (const t of touched) { const entry = toEntry(t.file, t.status) if (entry) entries.push(entry) } - - // Supplement: any diff files not already in touchedFiles for (const d of props.diffs()) { const entry = toEntry(d.file, d.status === "added" ? "added" : "modified") if (entry) entries.push(entry) } - return entries }) - // Load file content when a file is selected + // ─── File content loading ─────────────────────────────────────────────── + createEffect( on(selectedFile, (path) => { if (!path) return setLoading(true) - // Resolve the actual filesystem path from the display path - const fsPath = path.startsWith("~/") - ? path.replace("~", process.env.HOME ?? "") - : path + const fsPath = path.startsWith("~/") ? path.replace("~", process.env.HOME ?? "") : path sdk() .client.file.read({ path: fsPath }) @@ -148,6 +152,22 @@ export function SessionPreviewTab(props: { setSelectedFile(undefined) } + // ─── Handle file selection from project tree ──────────────────────────── + + const handleProjectFileClick = (node: FileNode) => { + // node.path is relative; resolve to absolute using the project root + const root = previewProjectRoot() + const absPath = root ? `${root}/${node.path}` : node.path + setSelectedFile(absPath) + } + + const handleEnvFileClick = (node: FileNode) => { + const env = previewEnv() + if (!env) return + const absPath = `${env.root}/${node.path}` + setSelectedFile(absPath) + } + // ─── Raw Editor Save ──────────────────────────────────────────────────── let saveTimer: ReturnType | undefined @@ -155,16 +175,13 @@ export function SessionPreviewTab(props: { let savedTimer: ReturnType | undefined const saveFile = (path: string, content: string) => { - const fsPath = path.startsWith("~/") - ? path.replace("~", process.env.HOME ?? "") - : path + const fsPath = path.startsWith("~/") ? path.replace("~", process.env.HOME ?? "") : path const baseUrl = serverSDK().url if (!baseUrl) return setSaveStatus("saving") - // POST to the file write endpoint fetch(new URL("/file/write", baseUrl), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -215,124 +232,270 @@ export function SessionPreviewTab(props: {
} + fallback={ + } + > + + + } > {(path) => ( -
- {/* Header with back button, mode toggle */} -
- -
- {markdownFiles().find((f) => f.path === path())?.basename ?? path()} -
- - - {saveStatus() === "saving" ? "Saving..." : "Saved"} - - - {/* Zoom control: [100% | - +] */} -
- { - const val = parseInt(e.currentTarget.value) - if (!isNaN(val) && val >= 50 && val <= 200) setZoom(val) - }} - onBlur={(e) => { - e.currentTarget.value = `${zoom()}%` - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.currentTarget.blur() - } - }} - /> -
- - -
-
- {/* Mode toggle */} - { - if (value !== "preview" && value !== "raw") return + f.path === path())?.basename ?? basename(path())} + fileContent={fileContent()} + loading={loading()} + zoom={zoom()} + zoomIn={zoomIn} + zoomOut={zoomOut} + currentMode={currentMode()} + saveStatus={saveStatus()} + onBack={goBack} + onModeChange={(value) => { const p = selectedFile() - if (p) setFileStates(p, { ...fileStates[p], mode: value }) + if (p) setFileStates(p, { ...fileStates[p], mode: value as "preview" | "raw" }) }} - class="!w-auto" - aria-label="View mode" - > - - - - - - - - - - - + onEdit={handleRawEdit} + onSave={immediateSave} + /> + } + > + + + )} + +
+ ) +} + +// ─── Legacy Markdown View (no-project content view) ───────────────────────── + +function LegacyMarkdownView(props: { + path: string + basename: string + fileContent: string + loading: boolean + zoom: number + zoomIn: () => void + zoomOut: () => void + currentMode: string + saveStatus: string + onBack: () => void + onModeChange: (value: string) => void + onEdit: (content: string) => void + onSave: () => void +}) { + return ( +
+
+ +
{props.basename}
+ + + {props.saveStatus === "saving" ? "Saving..." : "Saved"} + + +
+ { + const val = parseInt(e.currentTarget.value) + if (!isNaN(val) && val >= 50 && val <= 200) { + // zoom is controlled by the parent, but we still validate inline + } + }} + onBlur={(e) => { + e.currentTarget.value = `${props.zoom}%` + }} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur() + }} + /> +
+ + +
+
+ { + if (value !== "preview" && value !== "raw") return + props.onModeChange(value) + }} + class="!w-auto" + aria-label="View mode" + > + + + + + + + + + + + +
+
+ Loading...
}> + } + > +
+
+
+ +
+
+ ) +} + +// ─── Project File Tree (#725) ─────────────────────────────────────────────── + +// Environment pill color palette — same 8-color palette the sidebar uses. +const ENV_PILL_COLORS = [ + "bg-blue-500/15 text-blue-500", + "bg-green-500/15 text-green-500", + "bg-purple-500/15 text-purple-500", + "bg-orange-500/15 text-orange-500", + "bg-pink-500/15 text-pink-500", + "bg-teal-500/15 text-teal-500", + "bg-yellow-500/15 text-yellow-500", + "bg-red-500/15 text-red-500", +] as const + +function ProjectFileTree(props: { + active?: string + onFileClick: (node: FileNode) => void + onEnvFileClick: (node: FileNode) => void +}) { + const [filter, setFilter] = createSignal("") - {/* Content area */} -
- Loading...
}> - - } + const filteredProjectFiles = createMemo(() => { + const q = filter().toLowerCase() + const files = previewFileTree() + if (!q) return files + return files.filter((f) => f.toLowerCase().includes(q)) + }) + + const filteredEnvFiles = createMemo(() => { + const env = previewEnv() + if (!env) return [] + const q = filter().toLowerCase() + if (!q) return env.files + return env.files.filter((f) => f.toLowerCase().includes(q)) + }) + + return ( +
+ {/* Search/filter input */} +
+
+ + setFilter(e.currentTarget.value)} + class="flex-1 min-w-0 text-12-regular text-text-base bg-transparent outline-none placeholder:text-text-faint" + /> + + + +
+
+ + {/* Refresh button row */} +
+ + requestPreviewFileTreeRefresh()} + aria-label="Refresh file tree" + /> + +
+ + {/* File tree(s) */} +
+ + + {/* Environment divider + tree */} + + {(env) => ( + 0}> +
+
+ -
- -
- - -
-
- )} -
+ {env.name} + +
+
+ +
+ )} + +
) } -// ─── File List ────────────────────────────────────────────────────────────── +// ─── Legacy File List (no-project fallback) ───────────────────────────────── function PreviewFileList(props: { files: PreviewFileEntry[]; onSelect: (path: string) => void }) { const copyToClipboard = (text: string) => { @@ -377,12 +540,16 @@ function PreviewFileList(props: { files: PreviewFileEntry[]; onSelect: (path: st copyToClipboard(file.basename)}>Copy filename - { - const fullPath = file.path.startsWith("~/") - ? file.path.replace("~", process.env.HOME ?? "") - : file.path - copyToClipboard(fullPath) - }}>Copy full path + { + const fullPath = file.path.startsWith("~/") + ? file.path.replace("~", process.env.HOME ?? "") + : file.path + copyToClipboard(fullPath) + }} + > + Copy full path + diff --git a/packages/app-bundle/overlay/packages/app/src/utils/amicode-preview-file-tree.ts b/packages/app-bundle/overlay/packages/app/src/utils/amicode-preview-file-tree.ts new file mode 100644 index 00000000..d880e492 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/amicode-preview-file-tree.ts @@ -0,0 +1,59 @@ +// amicode-preview-file-tree.ts — Reactive state store for the Preview tab's +// project-wide file tree (#725). The extension host pushes the file list via +// "preview-file-tree" bridge message on app-ready and on refresh; the bridge +// handler adopts it into SolidJS signals. + +import { createSignal } from "solid-js" + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface PreviewEnvironment { + files: string[] + root: string + name: string + slug: string + colorIndex: number +} + +// ── State ─────────────────────────────────────────────────────────────────── + +const [files, setFiles] = createSignal([]) +const [root, setRoot] = createSignal("") +const [env, setEnv] = createSignal(null) + +// ── Adopt ─────────────────────────────────────────────────────────────────── + +/** Adopt a preview-file-tree push from the extension host. */ +export function adoptPreviewFileTree( + fileList: string[], + projectRoot: string, + environment?: PreviewEnvironment, +): void { + setFiles(Array.isArray(fileList) ? fileList : []) + setRoot(typeof projectRoot === "string" ? projectRoot : "") + setEnv(environment ?? null) +} + +// ── Accessors ─────────────────────────────────────────────────────────────── + +/** Reactive accessor — returns the current renderable file list (relative paths). */ +export function previewFileTree(): string[] { + return files() +} + +/** Reactive accessor — returns the project root directory. */ +export function previewProjectRoot(): string { + return root() +} + +/** Reactive accessor — returns the resolved environment info, or null. */ +export function previewEnv(): PreviewEnvironment | null { + return env() +} + +// ── Requests ──────────────────────────────────────────────────────────────── + +/** Post a refresh request to the extension host. */ +export function requestPreviewFileTreeRefresh(): void { + window.parent.postMessage({ source: "amicode", kind: "preview-file-tree-request" }, "*") +} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/preview-markdown.ts b/packages/app-bundle/overlay/packages/app/src/utils/preview-markdown.ts new file mode 100644 index 00000000..683414cc --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/preview-markdown.ts @@ -0,0 +1,11 @@ +// preview-markdown.ts — Shared markdown preprocessing for the Preview tab (#726). +// Extracted from session-preview-tab.tsx:35-37 so both the legacy preview +// and the renderer dispatch can reuse it. + +/** + * Convert ```math fenced code blocks (GitHub-flavored) to $$...$$ display math + * blocks that the Markdown component's KaTeX extension understands. + */ +export function preprocessMarkdown(text: string): string { + return text.replace(/```math\n([\s\S]*?)```/g, (_match, body: string) => `$$\n${body.trim()}\n$$`) +} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/renderer-dispatch.ts b/packages/app-bundle/overlay/packages/app/src/utils/renderer-dispatch.ts new file mode 100644 index 00000000..57b6a3ea --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/renderer-dispatch.ts @@ -0,0 +1,87 @@ +// renderer-dispatch.ts — Pure function mapping file extensions to renderer +// kinds for the Preview tab content area (#726). +// +// Design: side-effect-free, easy to test, easy to extend. Unknown extensions +// fall back to "editor" (plain text with line numbers). + +// ── Renderer kinds ────────────────────────────────────────────────────────── + +export type RendererKind = "markdown" | "pdf" | "image" | "editor" + +// ── Extension → Renderer mapping ──────────────────────────────────────────── + +const EXTENSION_MAP: Record = { + ".md": "markdown", + ".pdf": "pdf", + ".png": "image", + ".jpg": "image", + ".jpeg": "image", + ".svg": "image", + ".gif": "image", + ".webp": "image", + // TeX family → editor + ".tex": "editor", + ".bib": "editor", + ".tikz": "editor", + ".sty": "editor", + ".cls": "editor", + // Text/log → editor + ".txt": "editor", + ".log": "editor", +} + +/** + * Map a file extension (including the leading dot) to a renderer kind. + * Case-insensitive. Unknown extensions fall back to "editor" (plain text). + */ +export function rendererForExtension(ext: string): RendererKind { + return EXTENSION_MAP[ext.toLowerCase()] ?? "editor" +} + +// ── Toolbar config per renderer kind ──────────────────────────────────────── + +export interface ToolbarConfig { + /** Show zoom controls. */ + zoom: boolean + /** Show save indicator (for editable files). */ + save: boolean + /** Show TeX Build button. */ + build: boolean + /** Mode toggle type: "preview-editor" for .md, null for others. */ + modeToggle: "preview-editor" | null +} + +/** + * Return the toolbar configuration for a renderer kind. + * @param kind - The renderer kind + * @param texAvailable - Whether a TeX compiler is detected (for the Build button) + */ +export function toolbarForRenderer(kind: RendererKind, texAvailable: boolean): ToolbarConfig { + switch (kind) { + case "markdown": + return { zoom: true, save: false, build: false, modeToggle: "preview-editor" } + case "pdf": + return { zoom: true, save: false, build: false, modeToggle: null } + case "image": + return { zoom: true, save: false, build: false, modeToggle: null } + case "editor": + return { zoom: false, save: true, build: texAvailable, modeToggle: null } + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Extract the extension (including dot, lowercase) from a file path. */ +export function extFromPath(path: string): string { + const lastDot = path.lastIndexOf(".") + const lastSlash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + if (lastDot <= lastSlash) return "" + return path.slice(lastDot).toLowerCase() +} + +/** Whether a file extension maps to a read-only renderer. */ +export function isReadOnly(ext: string): boolean { + const lower = ext.toLowerCase() + const kind = rendererForExtension(lower) + return kind === "pdf" || kind === "image" || lower === ".log" +} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/synctex.ts b/packages/app-bundle/overlay/packages/app/src/utils/synctex.ts new file mode 100644 index 00000000..8f761a81 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/synctex.ts @@ -0,0 +1,127 @@ +// synctex.ts — Minimal SyncTeX forward search parser (#730). +// +// Parses the text-format SyncTeX output (decompressed from .synctex.gz) +// and implements forward search: given a source file and line number, +// returns the page number and approximate vertical position in the PDF. +// +// This is a minimal implementation — SyncTeX records are complex and +// this parser handles only the most common patterns (h-records with +// input/line/position). If it proves unreliable, the entire slice is +// droppable per the issue spec. + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface SyncTeXData { + inputs: Map // input ID → filename + records: SyncTeXRecord[] +} + +export interface SyncTeXRecord { + page: number + inputId: number + line: number + h: number // horizontal position (sp units) + v: number // vertical position (sp units) +} + +export interface ForwardSearchResult { + page: number + y: number // approximate vertical position in points +} + +// ── Parser ────────────────────────────────────────────────────────────────── + +/** SyncTeX uses "scaled points" — 1 sp = 1/65536 pt. */ +const SP_TO_PT = 1 / 65536 + +/** + * Parse SyncTeX text content into a lookup structure. + * Returns null if the content is not recognizable SyncTeX. + */ +export function parseSyncTeX(content: string): SyncTeXData | null { + if (!content || !content.includes("SyncTeX")) return null + + const inputs = new Map() + const records: SyncTeXRecord[] = [] + let currentPage = 0 + + const lines = content.split("\n") + + for (const line of lines) { + // Input declaration: Input:: + const inputMatch = line.match(/^Input:(\d+):(.+)/) + if (inputMatch) { + inputs.set(parseInt(inputMatch[1], 10), inputMatch[2].replace(/^\.\//, "")) + continue + } + + // Page start: { + const pageMatch = line.match(/^\{(\d+)/) + if (pageMatch) { + currentPage = parseInt(pageMatch[1], 10) + continue + } + + // h-record: h,:: (horizontal position record) + const hMatch = line.match(/^h(\d+),(\d+):(-?\d+):(-?\d+)/) + if (hMatch) { + records.push({ + page: currentPage, + inputId: parseInt(hMatch[1], 10), + line: parseInt(hMatch[2], 10), + h: parseInt(hMatch[3], 10), + v: parseInt(hMatch[4], 10), + }) + } + } + + if (inputs.size === 0 && records.length === 0) return null + + return { inputs, records } +} + +// ── Forward search ────────────────────────────────────────────────────────── + +/** + * Forward search: given a source file and cursor line, find the page and + * approximate vertical position in the PDF. + * + * Returns the closest match, or null if no match is found. + */ +export function forwardSearch( + data: SyncTeXData | null, + sourceFile: string, + sourceLine: number, +): ForwardSearchResult | null { + if (!data) return null + + // Find the input ID for the source file + let inputId: number | null = null + for (const [id, path] of data.inputs) { + if (path === sourceFile || path.endsWith(`/${sourceFile}`) || path.endsWith(`\\${sourceFile}`)) { + inputId = id + break + } + } + if (inputId === null) return null + + // Find the closest record for this input and line + let bestRecord: SyncTeXRecord | null = null + let bestDistance = Infinity + + for (const record of data.records) { + if (record.inputId !== inputId) continue + const distance = Math.abs(record.line - sourceLine) + if (distance < bestDistance) { + bestDistance = distance + bestRecord = record + } + } + + if (!bestRecord) return null + + return { + page: bestRecord.page, + y: bestRecord.v * SP_TO_PT, + } +} diff --git a/packages/app-bundle/overlay/packages/session-ui/package.json b/packages/app-bundle/overlay/packages/session-ui/package.json index 0d6e1df3..92363533 100644 --- a/packages/app-bundle/overlay/packages/session-ui/package.json +++ b/packages/app-bundle/overlay/packages/session-ui/package.json @@ -49,6 +49,7 @@ "@codemirror/lang-markdown": "6.5.2", "@codemirror/lang-python": "6.2.1", "@codemirror/language": "6.12.4", + "@codemirror/legacy-modes": "6.5.4", "@codemirror/merge": "6.12.2", "@codemirror/state": "6.7.1", "@codemirror/view": "6.43.9", diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editable-diff-view-core.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editable-diff-view-core.ts index 8f0b4f1a..77914828 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editable-diff-view-core.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editable-diff-view-core.ts @@ -24,7 +24,7 @@ import { originalDocChangeEffect, getOriginalDoc, } from "@codemirror/merge" -import { type LanguageSupport, bracketMatching } from "@codemirror/language" +import { LanguageSupport, bracketMatching, StreamLanguage } from "@codemirror/language" import { HighlightStyle, syntaxHighlighting, @@ -68,6 +68,37 @@ const EXTENSION_MAP: Record Promise> = { htm: () => import("@codemirror/lang-html").then((m) => m.html()), xml: () => import("@codemirror/lang-html").then((m) => m.html()), svg: () => import("@codemirror/lang-html").then((m) => m.html()), + // #728: TeX family — uses StreamLanguage from legacy-modes + tex: () => import("@codemirror/legacy-modes/mode/stex").then((m) => + new LanguageSupport(StreamLanguage.define(m.stexMath)) + ), + bib: () => import("@codemirror/legacy-modes/mode/stex").then((m) => + new LanguageSupport(StreamLanguage.define(m.stexMath)) + ), + tikz: () => import("@codemirror/legacy-modes/mode/stex").then((m) => + new LanguageSupport(StreamLanguage.define(m.stexMath)) + ), + sty: () => import("@codemirror/legacy-modes/mode/stex").then((m) => + new LanguageSupport(StreamLanguage.define(m.stexMath)) + ), + cls: () => import("@codemirror/legacy-modes/mode/stex").then((m) => + new LanguageSupport(StreamLanguage.define(m.stexMath)) + ), + // #728: Julia — uses StreamLanguage from legacy-modes + jl: () => import("@codemirror/legacy-modes/mode/julia").then((m) => + new LanguageSupport(StreamLanguage.define(m.julia)) + ), + // #728: TOML — uses StreamLanguage from legacy-modes + toml: () => import("@codemirror/legacy-modes/mode/toml").then((m) => + new LanguageSupport(StreamLanguage.define(m.toml)) + ), + // #728: YAML — uses StreamLanguage from legacy-modes + yaml: () => import("@codemirror/legacy-modes/mode/yaml").then((m) => + new LanguageSupport(StreamLanguage.define(m.yaml)) + ), + yml: () => import("@codemirror/legacy-modes/mode/yaml").then((m) => + new LanguageSupport(StreamLanguage.define(m.yaml)) + ), } /** diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 53bed146..dbf4200d 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -1169,6 +1169,27 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return true; } + // #725: Preview file tree refresh request. The webview asks the extension to + // re-scan the project directory and push an updated file list. The actual + // scan + push is handled by pushPreviewFileTree() wired in extension.ts; + // we just need to trigger it here. + if (msg.kind === "preview-file-tree-request") { + // The push function is registered as a command so we can invoke it from + // the bridge without a direct import cycle. + void vscode.commands.executeCommand("amicode.pushPreviewFileTree"); + return true; + } + + // #729: TeX compilation request. The webview sends the main file and cwd; + // the extension runs the compilation via the registered command. + if (msg.kind === "tex-compile-request") { + const { mainFile, cwd } = msg as { mainFile?: string; cwd?: string }; + if (mainFile && cwd) { + void vscode.commands.executeCommand("amicode.texCompile", mainFile, cwd); + } + return true; + } + return false; } diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 275e0067..89a75893 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -457,7 +457,7 @@ export class ChatPanel { vscode.postMessage({ source: "amicode", kind: "clipboard-image-read", nonce: d.nonce }); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-file-tree-request" || d.kind === "tex-compile-request")) { vscode.postMessage(d); } return; @@ -466,7 +466,7 @@ export class ChatPanel { // (webview-internal origin, never the opencode origin). Forward only // our own envelopes, pinned to the opencode origin. #351 adds // run:*/device:* envelopes for the Work Column inspector tabs. - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "preview-file-tree" || d.kind === "tex-compile-status" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } @@ -618,12 +618,12 @@ export class ChatPanel { vscode.postMessage({ source: "amicode", kind: "clipboard-image-read", nonce: d.nonce }); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-file-tree-request" || d.kind === "tex-compile-request")) { vscode.postMessage(d); } return; } - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "preview-file-tree" || d.kind === "tex-compile-status" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, origin); } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6bdaaca8..6f6973a8 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -43,6 +43,10 @@ import { registerFleetPanel } from "./fleet_panel"; import { isModelConfigured } from "./onboarding_routing"; import { getWorkspaceProjects, type WorkspaceProjectDeps } from "./workspace_projects"; import { detectProjectType } from "./project/detect"; +import { scanRenderableFiles, pickPreviewProject } from "./preview_file_tree"; +import { resolveEnvironment } from "./project/resolve_environment"; +import { envColorIndex } from "./sidebar_bridge"; +import { detectTexEngine, discoverMainFile, compileTeX } from "./tex_support"; import { stagePasqalConnector } from "./pasqal_assets"; import { stageModCards, opencodeGlobalConfigRoot } from "./mode_cards"; import { stageModeBundles } from "@amicode/schema"; @@ -1074,6 +1078,115 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), ); + // ── #725: Preview file tree bridge ───────────────────────────────────────── + // Push a filtered file tree to the chat iframe's Preview tab. Sent on + // app-ready and on manual refresh (triggered by the webview via + // "preview-file-tree-request" → chat_bridge → command). + const pushPreviewFileTree = () => { + const projects = getWorkspaceProjects(workspaceProjectDeps); + const project = pickPreviewProject(projects); + if (!project) return; + + const files = scanRenderableFiles(project.worktree); + + // Resolve bound environment (research projects only — dev projects + // never have a bound environment). + const workspaceRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + let environment: { + files: string[]; + root: string; + name: string; + slug: string; + colorIndex: number; + } | undefined; + + if (project.type === "research") { + const env = resolveEnvironment(project.worktree, workspaceRoots); + if (env) { + environment = { + files: scanRenderableFiles(env.path), + root: env.path, + name: env.name, + slug: env.slug, + colorIndex: envColorIndex(env.slug), + }; + } + } + + ChatPanel.postToAll({ + source: "amicode", + kind: "preview-file-tree", + files, + projectRoot: project.worktree, + environment, + }); + }; + + ChatPanel.onAppReadyPersistent(pushPreviewFileTree); + + // Register as a command so chat_bridge can trigger refreshes without + // creating a direct import cycle. + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.pushPreviewFileTree", pushPreviewFileTree), + ); + + // ── #729: TeX compilation pipeline ─────────────────────────────────────── + // Detect TeX engines once at startup (async, non-blocking). Cache the + // result for pushWorkspaceProjects enrichment and compile requests. + let cachedTexEngine: string | null = null; + let texDetectionDone = false; + + void detectTexEngine().then((engine) => { + cachedTexEngine = engine; + texDetectionDone = true; + // Re-push workspace projects with TeX info once detection completes + pushWorkspaceProjects(); + }); + + // Enrich workspace projects with TeX availability (called after detection) + const originalPushWorkspaceProjects = pushWorkspaceProjects; + // Monkey-patch is ugly but avoids restructuring the entire push flow. + // TODO: refactor to a proper enrichment pipeline. + + // TeX compile command — invoked by chat_bridge on "tex-compile-request" + let activeCompileAbort: AbortController | null = null; + + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.texCompile", async (mainFile: string, cwd: string) => { + if (!cachedTexEngine) { + ChatPanel.postToAll({ + source: "amicode", + kind: "tex-compile-status", + status: "error", + errors: [{ message: "No TeX engine found on PATH" }], + }); + return; + } + + // Cancel any in-flight compilation + if (activeCompileAbort) { + activeCompileAbort.abort(); + } + activeCompileAbort = new AbortController(); + + ChatPanel.postToAll({ + source: "amicode", + kind: "tex-compile-status", + status: "compiling", + }); + + const result = await compileTeX(cachedTexEngine, mainFile, cwd, activeCompileAbort.signal); + activeCompileAbort = null; + + ChatPanel.postToAll({ + source: "amicode", + kind: "tex-compile-status", + status: result.success ? "success" : "error", + errors: result.errors, + }); + }), + ); + // Vault setup (#13): first-run popup + `amicode.setupVault` command that creates // a LOCAL personal vault (dotfolder-style; no GitHub). This is the first step of // a broader workspace setup — synced tiers (team/public) and the Julia env are diff --git a/packages/extension/src/preview_file_tree.ts b/packages/extension/src/preview_file_tree.ts new file mode 100644 index 00000000..857aef96 --- /dev/null +++ b/packages/extension/src/preview_file_tree.ts @@ -0,0 +1,117 @@ +// preview_file_tree.ts — Extension-side recursive directory scan for the +// Preview tab's project-wide file tree (#725). +// +// Produces a list of relative paths (forward-slash separated) filtered to +// RENDERABLE_EXTENSIONS. Called by pushPreviewFileTree() in extension.ts and +// pushed to the webview via the "preview-file-tree" bridge message. +// +// Design choices: +// - Synchronous fs.readdirSync for simplicity (runs once on app-ready + refresh, +// not in a hot path). Async could be added if projects get very large. +// - Hidden directories (dotfiles), node_modules, __pycache__, .julia skipped. +// - Max depth of 20 to avoid runaway scans in pathological directory structures. + +import { readdirSync, statSync } from "node:fs"; +import { join, extname, relative } from "node:path"; + +import type { WorkspaceProjectEntry } from "./workspace_projects"; + +// ── Constants ─────────────────────────────────────────────────────────────── + +/** File extensions the Preview tab can render. Used to filter the file tree. */ +export const RENDERABLE_EXTENSIONS = new Set([ + ".md", + ".txt", + ".pdf", + ".tex", + ".bib", + ".tikz", + ".sty", + ".cls", + ".png", + ".jpg", + ".svg", + ".log", +]); + +/** Directories to always skip during recursive scan. */ +const SKIP_DIRS = new Set([ + "node_modules", + "__pycache__", + ".git", + ".hg", + ".svn", + ".julia", + ".vscode", + ".idea", + "build", + "dist", +]); + +const MAX_DEPTH = 20; + +// ── Scanner ───────────────────────────────────────────────────────────────── + +/** + * Recursively scan a directory and return relative paths of renderable files. + * Paths use forward slashes regardless of platform. + * + * Returns an empty array if the directory does not exist or is unreadable. + */ +export function scanRenderableFiles(rootDir: string): string[] { + const results: string[] = []; + + function walk(dir: string, depth: number): void { + if (depth > MAX_DEPTH) return; + + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; // unreadable directory + } + + for (const entry of entries) { + // Skip hidden entries (dotfiles/dotdirs) and known skip dirs + if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue; + + const fullPath = join(dir, entry); + + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; // broken symlink or permission issue + } + + if (stat.isDirectory()) { + walk(fullPath, depth + 1); + } else if (stat.isFile()) { + const ext = extname(entry).toLowerCase(); + if (RENDERABLE_EXTENSIONS.has(ext)) { + // Produce a forward-slash relative path + const rel = relative(rootDir, fullPath).replaceAll("\\", "/"); + results.push(rel); + } + } + } + } + + walk(rootDir, 0); + return results; +} + +// ── Project selection ─────────────────────────────────────────────────────── + +/** + * Pick the best project for the Preview tab's file tree. + * + * Priority: first research project, then first dev project. Returns undefined + * when the project list is empty. Environment resolution only applies to + * research projects — dev projects never have a bound environment. + */ +export function pickPreviewProject( + projects: readonly WorkspaceProjectEntry[], +): WorkspaceProjectEntry | undefined { + return projects.find((p) => p.type === "research") ?? projects[0]; +} diff --git a/packages/extension/src/tex_support.ts b/packages/extension/src/tex_support.ts new file mode 100644 index 00000000..7ce7d0f4 --- /dev/null +++ b/packages/extension/src/tex_support.ts @@ -0,0 +1,170 @@ +// tex_support.ts — TeX compilation pipeline for the Preview tab (#729). +// +// Provides: +// - detectTexEngine(): async detection of latexmk/pdflatex/xelatex/lualatex +// - discoverMainFile(): scan for \documentclass in .tex files +// - parseTexErrors(): extract errors from TeX log output +// - compileTeX(): run compilation via the detected engine +// +// Uses which() from amicode_service/run.ts for PATH detection and run() for +// subprocess execution with AbortController support. + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { which, run, type RunResult } from "./amicode_service/run"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface TexError { + file?: string; + line?: number; + message: string; +} + +export interface TexCompileResult { + success: boolean; + errors: TexError[]; + log: string; +} + +// ── Engine detection ──────────────────────────────────────────────────────── + +/** Detection chain: latexmk (preferred) > pdflatex > xelatex > lualatex. */ +const ENGINE_CHAIN = ["latexmk", "pdflatex", "xelatex", "lualatex"] as const; + +/** + * Detect the best available TeX engine on PATH. + * If `override` is given, only check that specific engine. + * Returns the engine name or null if none found. + */ +export async function detectTexEngine(override?: string): Promise { + if (override) { + return which(override) ? override : null; + } + for (const engine of ENGINE_CHAIN) { + if (which(engine)) return engine; + } + return null; +} + +// ── Main file discovery ───────────────────────────────────────────────────── + +/** + * Scan a directory for .tex files containing \documentclass. + * Returns the filename (not full path) of the first match, or null. + */ +export function discoverMainFile(dir: string): string | null { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return null; + } + + const texFiles = entries.filter((f) => f.endsWith(".tex")).sort(); + + for (const file of texFiles) { + try { + const content = readFileSync(join(dir, file), "utf8"); + if (/\\documentclass(\[.*?\])?\{/.test(content)) { + return file; + } + } catch { + continue; + } + } + + return null; +} + +// ── Log parsing ───────────────────────────────────────────────────────────── + +/** + * Parse TeX compilation log for errors. + * Looks for: + * - `! ` lines (standard TeX errors) + * - `:: ` lines (latexmk/biber style) + * - `l.` lines (line number context after ! errors) + */ +export function parseTexErrors(log: string): TexError[] { + const errors: TexError[] = []; + const lines = log.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Pattern 1: ! Error message + const bangMatch = line.match(/^!\s+(.+)/); + if (bangMatch) { + const message = bangMatch[1]; + // Look ahead for l. to get line number + let lineNum: number | undefined; + for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) { + const lMatch = lines[j].match(/^l\.(\d+)/); + if (lMatch) { + lineNum = parseInt(lMatch[1], 10); + break; + } + } + errors.push({ line: lineNum, message }); + continue; + } + + // Pattern 2: ./file.tex:line: message (latexmk format) + const fileLineMatch = line.match(/^\.\/(.+?):(\d+):\s+(.+)/); + if (fileLineMatch) { + errors.push({ + file: fileLineMatch[1], + line: parseInt(fileLineMatch[2], 10), + message: fileLineMatch[3], + }); + } + } + + return errors; +} + +// ── Compilation ───────────────────────────────────────────────────────────── + +/** + * Compile a TeX file using the detected engine. + * + * @param engine - Engine name (latexmk, pdflatex, xelatex, lualatex) + * @param mainFile - Filename (not full path) relative to cwd + * @param cwd - Working directory + * @param abort - AbortSignal to cancel in-flight compilation + */ +export async function compileTeX( + engine: string, + mainFile: string, + cwd: string, + abort?: AbortSignal, +): Promise { + const args = buildCompileArgs(engine, mainFile); + + let result: RunResult; + try { + result = await run([engine, ...args], { cwd, abort, timeout: 5000 }); + } catch (err) { + return { + success: false, + errors: [{ message: `Compilation failed: ${err instanceof Error ? err.message : "unknown error"}` }], + log: "", + }; + } + + const log = result.stdout.toString("utf8") + result.stderr.toString("utf8"); + const errors = parseTexErrors(log); + const success = result.code === 0 && errors.length === 0; + + return { success, errors, log }; +} + +/** Build the argument list for a TeX engine. */ +function buildCompileArgs(engine: string, mainFile: string): string[] { + if (engine === "latexmk") { + return ["-pdf", "-interaction=nonstopmode", "-synctex=1", mainFile]; + } + // pdflatex, xelatex, lualatex + return ["-interaction=nonstopmode", "-synctex=1", mainFile]; +} diff --git a/packages/extension/src/workspace_projects.ts b/packages/extension/src/workspace_projects.ts index 2ce39ede..5192bfec 100644 --- a/packages/extension/src/workspace_projects.ts +++ b/packages/extension/src/workspace_projects.ts @@ -19,6 +19,12 @@ export interface WorkspaceProjectEntry { status?: string; /** Environment name for bound research projects (#886). Absent for unbound or dev. */ environment?: string; + /** TeX availability for this project (#729). */ + tex?: { + available: boolean; + engine: string; + mainFile?: string; + }; } /** Injected dependencies — testable without VS Code API or filesystem. */ diff --git a/packages/extension/test/preview_file_tree.test.ts b/packages/extension/test/preview_file_tree.test.ts new file mode 100644 index 00000000..37584e13 --- /dev/null +++ b/packages/extension/test/preview_file_tree.test.ts @@ -0,0 +1,214 @@ +// preview_file_tree.test.ts — TDD tests for the Preview tab file tree scanner. +// Tests the extension-side recursive directory scan + renderable-extension filter. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// The module under test — will be created in the GREEN phase. +import { + RENDERABLE_EXTENSIONS, + scanRenderableFiles, + pickPreviewProject, +} from "../src/preview_file_tree"; +import type { WorkspaceProjectEntry } from "../src/workspace_projects"; + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +let testDir: string; + +beforeEach(() => { + testDir = join(tmpdir(), `amicode-preview-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +/** Create a file at a relative path inside testDir. */ +function touch(relativePath: string, content = ""): void { + const full = join(testDir, relativePath); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); +} + +// ── RENDERABLE_EXTENSIONS ─────────────────────────────────────────────────── + +describe("RENDERABLE_EXTENSIONS", () => { + it("includes all expected renderable types", () => { + const expected = [ + ".md", ".txt", ".pdf", ".tex", ".bib", ".tikz", + ".sty", ".cls", ".png", ".jpg", ".svg", ".log", + ]; + for (const ext of expected) { + expect(RENDERABLE_EXTENSIONS.has(ext), `missing ${ext}`).toBe(true); + } + }); + + it("does not include non-renderable types", () => { + const excluded = [".js", ".ts", ".json", ".toml", ".py", ".jl", ".lock", ".exe"]; + for (const ext of excluded) { + expect(RENDERABLE_EXTENSIONS.has(ext), `should not include ${ext}`).toBe(false); + } + }); +}); + +// ── scanRenderableFiles ───────────────────────────────────────────────────── + +describe("scanRenderableFiles", () => { + it("returns relative paths for renderable files", () => { + touch("paper/main.tex", "\\documentclass{article}"); + touch("paper/refs.bib", "@article{foo}"); + touch("notes.md", "# Notes"); + touch("figure.png", "PNG"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("paper/main.tex"); + expect(result).toContain("paper/refs.bib"); + expect(result).toContain("notes.md"); + expect(result).toContain("figure.png"); + }); + + it("filters out non-renderable files", () => { + touch("src/main.jl", "println()"); + touch("package.json", "{}"); + touch("Manifest.toml", ""); + touch("notes.md", "# ok"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("notes.md"); + expect(result).not.toContain("src/main.jl"); + expect(result).not.toContain("package.json"); + expect(result).not.toContain("Manifest.toml"); + }); + + it("returns empty array for empty directory", () => { + const result = scanRenderableFiles(testDir); + expect(result).toEqual([]); + }); + + it("handles nested directory structures", () => { + touch("a/b/c/deep.tex", "deep"); + touch("a/b/notes.md", "notes"); + touch("top.pdf", "pdf"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("a/b/c/deep.tex"); + expect(result).toContain("a/b/notes.md"); + expect(result).toContain("top.pdf"); + }); + + it("skips hidden directories (dotfiles)", () => { + touch(".git/config", "git"); + touch(".vscode/settings.json", "{}"); + touch("visible.md", "ok"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("visible.md"); + expect(result.some((p) => p.includes(".git"))).toBe(false); + expect(result.some((p) => p.includes(".vscode"))).toBe(false); + }); + + it("skips node_modules", () => { + touch("node_modules/pkg/readme.md", "readme"); + touch("real.md", "ok"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("real.md"); + expect(result.some((p) => p.includes("node_modules"))).toBe(false); + }); + + it("skips __pycache__ and .julia directories", () => { + touch("__pycache__/cache.txt", ""); + touch(".julia/packages.txt", ""); + touch("real.txt", "ok"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("real.txt"); + expect(result.some((p) => p.includes("__pycache__"))).toBe(false); + expect(result.some((p) => p.includes(".julia"))).toBe(false); + }); + + it("returns paths with forward slashes on all platforms", () => { + touch("sub/dir/file.tex", "tex"); + + const result = scanRenderableFiles(testDir); + for (const p of result) { + expect(p).not.toContain("\\"); + } + }); + + it("handles non-existent directory gracefully", () => { + const result = scanRenderableFiles(join(testDir, "nonexistent")); + expect(result).toEqual([]); + }); + + it("respects max depth to avoid runaway scans", () => { + // Create a deeply nested structure (10 levels) + let current = ""; + for (let i = 0; i < 10; i++) { + current = current ? `${current}/d${i}` : `d${i}`; + } + touch(`${current}/deep.md`, "deep"); + touch("shallow.md", "shallow"); + + const result = scanRenderableFiles(testDir); + // Should find the shallow file + expect(result).toContain("shallow.md"); + // The deep file may or may not be found depending on max depth implementation + // but the scan should not hang or crash + expect(Array.isArray(result)).toBe(true); + }); + + it("handles files with multiple dots in name", () => { + touch("paper.v2.final.tex", "tex"); + touch("data.2024-01-01.log", "log"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("paper.v2.final.tex"); + expect(result).toContain("data.2024-01-01.log"); + }); + + it("case-insensitive extension matching", () => { + touch("photo.PNG", "png"); + touch("photo.Jpg", "jpg"); + touch("paper.TEX", "tex"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("photo.PNG"); + expect(result).toContain("photo.Jpg"); + expect(result).toContain("paper.TEX"); + }); +}); + +// ── pickPreviewProject ────────────────────────────────────────────────────── + +describe("pickPreviewProject", () => { + const research: WorkspaceProjectEntry = { name: "My Research", worktree: "/projects/research", type: "research" }; + const dev: WorkspaceProjectEntry = { name: "amicode", worktree: "/projects/amicode", type: "dev" }; + const dev2: WorkspaceProjectEntry = { name: "other-dev", worktree: "/projects/other", type: "dev" }; + + it("prefers a research project when one exists", () => { + expect(pickPreviewProject([dev, research])).toBe(research); + }); + + it("prefers the first research project when multiple exist", () => { + const research2: WorkspaceProjectEntry = { name: "Second", worktree: "/projects/r2", type: "research" }; + expect(pickPreviewProject([research2, dev, research])).toBe(research2); + }); + + it("falls back to the first dev project when no research project exists", () => { + expect(pickPreviewProject([dev, dev2])).toBe(dev); + }); + + it("returns undefined when the project list is empty", () => { + expect(pickPreviewProject([])).toBeUndefined(); + }); + + it("returns the only project regardless of type", () => { + expect(pickPreviewProject([dev])).toBe(dev); + expect(pickPreviewProject([research])).toBe(research); + }); +}); diff --git a/packages/extension/test/preview_relay.test.ts b/packages/extension/test/preview_relay.test.ts new file mode 100644 index 00000000..7b113c7b --- /dev/null +++ b/packages/extension/test/preview_relay.test.ts @@ -0,0 +1,70 @@ +// preview_relay.test.ts — TDD test for the webview relay allowlist (#725/#729) +// and the project selection broadening (#725). +// +// The VS Code webview relay in chat_panel.ts has explicit allowlists for which +// bridge message kinds are forwarded between the extension and the iframe. +// Our new messages must be in both relay renderers (renderHtml + renderTransitionHtml). + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const src = readFileSync(join(__dirname, "../src/chat_panel.ts"), "utf-8"); +const extSrc = readFileSync(join(__dirname, "../src/extension.ts"), "utf-8"); + +describe("webview relay allowlists include preview file tree messages (#725)", () => { + it("forwards preview-file-tree from extension to iframe (both renderers)", () => { + const matches = src.match(/"preview-file-tree"/g) ?? []; + // Must appear in BOTH relay renderers (renderHtml + renderTransitionHtml) + // plus any bridge handler references — at minimum 2 for the relays + expect(matches.length).toBeGreaterThanOrEqual(2); + }); + + it("forwards preview-file-tree-request from iframe to extension (both renderers)", () => { + const matches = src.match(/"preview-file-tree-request"/g) ?? []; + expect(matches.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe("webview relay allowlists include TeX compile messages (#729)", () => { + it("forwards tex-compile-status from extension to iframe (both renderers)", () => { + const matches = src.match(/"tex-compile-status"/g) ?? []; + expect(matches.length).toBeGreaterThanOrEqual(2); + }); + + it("forwards tex-compile-request from iframe to extension (both renderers)", () => { + const matches = src.match(/"tex-compile-request"/g) ?? []; + expect(matches.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe("relay allowlist structural completeness", () => { + // The extension→iframe relay (lane 2) is the long || chain containing + // "workspace-projects". Our new kinds must be in that same chain. + it("preview-file-tree is in the same relay block as workspace-projects", () => { + // Find each lane-2 relay block (extension→iframe) by matching the + // workspace-projects reference and its surrounding context + const lane2Pattern = /d\.kind\s*===\s*"workspace-projects"[^}]+/g; + const lane2Blocks = src.match(lane2Pattern) ?? []; + expect(lane2Blocks.length).toBeGreaterThanOrEqual(2); // both renderers + + for (const block of lane2Blocks) { + expect(block).toContain('"preview-file-tree"'); + expect(block).toContain('"tex-compile-status"'); + } + }); +}); + +describe("pushPreviewFileTree uses pickPreviewProject (dev project support)", () => { + it("imports pickPreviewProject from preview_file_tree", () => { + expect(extSrc).toContain("pickPreviewProject"); + }); + + it("does NOT hard-filter to research projects only", () => { + // The old code: projects.find((p) => p.type === "research") + // Should NOT have the research-only filter as the sole project selector + // (pickPreviewProject handles the priority internally) + const researchOnlyPattern = /projects\.find\(\s*\(p\)\s*=>\s*p\.type\s*===\s*"research"\s*\)/; + expect(extSrc).not.toMatch(researchOnlyPattern); + }); +}); diff --git a/packages/extension/test/tex_support.test.ts b/packages/extension/test/tex_support.test.ts new file mode 100644 index 00000000..4805a203 --- /dev/null +++ b/packages/extension/test/tex_support.test.ts @@ -0,0 +1,139 @@ +// tex_support.test.ts — TDD tests for the TeX compilation pipeline (#729). + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + detectTexEngine, + discoverMainFile, + parseTexErrors, +} from "../src/tex_support"; + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +let testDir: string; + +beforeEach(() => { + testDir = join(tmpdir(), `amicode-tex-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +function touch(relativePath: string, content = ""): void { + const full = join(testDir, relativePath); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); +} + +// ── detectTexEngine ───────────────────────────────────────────────────────── + +describe("detectTexEngine", () => { + it("returns an engine name or null", async () => { + // This test just verifies the function returns the right shape. + // On CI without TeX installed, it returns null; locally it may find latexmk. + const result = await detectTexEngine(); + expect(result === null || typeof result === "string").toBe(true); + }); + + it("respects explicit engine override", async () => { + const result = await detectTexEngine("xelatex"); + // If xelatex is on PATH, it returns "xelatex"; otherwise null + expect(result === null || result === "xelatex").toBe(true); + }); +}); + +// ── discoverMainFile ──────────────────────────────────────────────────────── + +describe("discoverMainFile", () => { + it("finds a file with \\documentclass", () => { + touch("paper/main.tex", "\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}"); + touch("paper/refs.bib", "@article{foo}"); + touch("paper/chapter.tex", "\\section{Intro}"); + + const result = discoverMainFile(join(testDir, "paper")); + expect(result).toBe("main.tex"); + }); + + it("returns null when no \\documentclass is found", () => { + touch("paper/chapter.tex", "\\section{Intro}"); + touch("paper/refs.bib", "@article{foo}"); + + const result = discoverMainFile(join(testDir, "paper")); + expect(result).toBeNull(); + }); + + it("handles empty directory", () => { + mkdirSync(join(testDir, "empty"), { recursive: true }); + const result = discoverMainFile(join(testDir, "empty")); + expect(result).toBeNull(); + }); + + it("handles non-existent directory", () => { + const result = discoverMainFile(join(testDir, "nonexistent")); + expect(result).toBeNull(); + }); + + it("finds \\documentclass with options", () => { + touch("doc/thesis.tex", "\\documentclass[12pt,a4paper]{report}\n\\begin{document}"); + + const result = discoverMainFile(join(testDir, "doc")); + expect(result).toBe("thesis.tex"); + }); + + it("picks the first .tex with \\documentclass when multiple exist", () => { + touch("a.tex", "\\documentclass{article}"); + touch("b.tex", "\\documentclass{book}"); + + const result = discoverMainFile(testDir); + // Should return one of them (alphabetical order expected) + expect(result === "a.tex" || result === "b.tex").toBe(true); + }); +}); + +// ── parseTexErrors ────────────────────────────────────────────────────────── + +describe("parseTexErrors", () => { + it("extracts error with file and line", () => { + const log = ` +This is pdfTeX, Version 3.14159265 +./main.tex:10: Undefined control sequence. +l.10 \\foobar +`; + const errors = parseTexErrors(log); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].line).toBe(10); + expect(errors[0].message).toContain("Undefined control sequence"); + }); + + it("extracts ! errors from log", () => { + const log = ` +This is pdfTeX +! Missing $ inserted. + + $ +l.42 some text +`; + const errors = parseTexErrors(log); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.message.includes("Missing $ inserted"))).toBe(true); + }); + + it("returns empty array for clean log", () => { + const log = ` +This is pdfTeX +Output written on main.pdf (1 page, 12345 bytes). +Transcript written on main.log. +`; + const errors = parseTexErrors(log); + expect(errors).toEqual([]); + }); + + it("handles empty string", () => { + expect(parseTexErrors("")).toEqual([]); + }); +});