import { createSignal, createResource, createEffect, createMemo, onMount, onCleanup, type JSX, Show, Switch, Match, } from "solid-js" import { Portal } from "solid-js/web" import { Markdown } from "@synsci/ui/markdown" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { usePlatform } from "@/context/platform" import { FONT_MONO, FONT_SANS, FONT_CODE } from "@/styles/tokens" import { PdfViewer } from "@/science/renderers/documents/PdfViewer" import { toast } from "@/thesis/Toast" import { IconFile, IconX, IconCopy, IconDownload, IconBookOpen, IconBraces, IconRefresh } from "@/thesis/shared/Icon" /** * Slide-in SIDE PREVIEW pane for opening a file from the Files tree. * * A file's extension picks the renderer: * .md / .markdown → formatted markdown (@synsci/ui Markdown) * .pdf → PdfViewer (pdfjs page rasterizer) * .tex / .latex → highlighted LaTeX source (a .tex is a source FILE, not a * math expression — the KaTeX LatexView is reserved for * kind:"latex" math ARTIFACTS with a single math string) * images → inline * everything else → syntax-aware code/text view (with edit + save) * * It mounts as a right-anchored drawer over the session so md / pdf / latex * get room to breathe instead of the cramped 360px pane. Esc / backdrop * click / the header × all close it. */ const ext = (name: string): string => { const i = name.lastIndexOf(".") return i > 0 ? name.slice(i + 1).toLowerCase() : "" } // Extension → shiki/highlight.js language id for the code fallback. const LANG: Record = { py: "python", ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript", json: "json", jsonl: "json", yaml: "yaml", yml: "yaml", toml: "toml", ini: "ini", cfg: "ini", sh: "bash", bash: "bash", zsh: "bash", rs: "rust", go: "go", swift: "swift", java: "java", kt: "kotlin", rb: "ruby", php: "php", c: "c", h: "c", cpp: "cpp", cc: "cpp", hpp: "cpp", cu: "cpp", // .tex and friends are text/source files — highlight them as LaTeX source // (shiki has a `latex` grammar). A full \documentclass document must never // be fed to KaTeX (which only typesets a single math string → blank page). tex: "latex", latex: "latex", sty: "latex", cls: "latex", bib: "latex", css: "css", scss: "scss", html: "html", xml: "xml", svg: "xml", sql: "sql", r: "r", jl: "julia", lua: "lua", dockerfile: "docker", makefile: "makefile", csv: "csv", txt: "text", log: "text", } type Kind = "markdown" | "pdf" | "image" | "code" | "binary" type FileData = { content?: string; encoding?: string; mimeType?: string } /** * Inline file view — header (icon + name + subtitle + controls) over the * type-aware renderer body. This is the single source of truth for the * renderer dispatch; both the slide-in drawer (FilePreview, below) and the * center-pane document tabs mount it, so nothing about opening a file is * duplicated. */ export function FileView(props: { path: string directory?: string subtitle?: string onClose?: () => void }): JSX.Element { const sdk = useSDK() const sync = useSync() const platform = usePlatform() const directory = () => props.directory || sync.project?.worktree || sync.data.path.directory || sdk.directory const name = () => props.path.split("/").pop() || props.path const e = () => ext(name()) // `showSource` flips rendered docs (md / tex) to their raw text; for code // files it flips the read-only highlighted view into an editable textarea. const [showSource, setShowSource] = createSignal(false) const [draft, setDraft] = createSignal("") const [savedText, setSavedText] = createSignal("") const [saving, setSaving] = createSignal(false) const [refreshKey, setRefreshKey] = createSignal(0) const [file] = createResource( () => [directory(), props.path, refreshKey()] as const, async ([dir, path]) => { if (!dir || !path) return undefined // Pass the params FLAT — the generated client maps `directory`/`path` // into the query string; a `{ query: {...} }` wrapper is dropped and // sends nothing. `directory` re-roots the backend Instance so any host // file is readable by absolute directory + relative path (File.read). const res: any = await sdk.client.file.read({ directory: dir, path }) return (res?.data ?? res) as FileData }, ) const data = () => file() const isBinary = () => data()?.encoding === "base64" const mime = () => data()?.mimeType ?? "" const b64 = () => data()?.content ?? "" const dataUrl = () => `data:${mime() || "application/octet-stream"};base64,${b64()}` const text = () => (!data() || isBinary() ? "" : (data()!.content ?? "")) const dirty = () => draft() !== savedText() const kind = createMemo(() => { const x = e() if (isBinary()) { if (mime().startsWith("image/") || ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"].includes(x)) return "image" if (mime() === "application/pdf" || x === "pdf") return "pdf" return "binary" } if (x === "md" || x === "markdown" || x === "mdx") return "markdown" if (x === "pdf") return "pdf" // .tex / .latex / .sty / .cls are source files → highlighted "code" view // (LANG maps them to the shiki `latex` grammar). They are NEVER routed to // KaTeX, which blanks on a full \documentclass document. return "code" }) const badge = () => { const k = kind() if (k === "code") return LANG[e()] ?? e() ?? "text" return k } createEffect(() => { if (file.loading) return const next = text() setDraft(next) setSavedText(next) }) const save = async () => { if (saving() || isBinary() || !dirty()) return setSaving(true) try { // The generated SDK has no file.write; hit the real PUT /file/content // route directly. `directory` re-roots the backend Instance, `path` is // relative to it (see server middleware + File.write). const url = `${sdk.url.replace(/\/$/, "")}/file/content?directory=${encodeURIComponent(directory())}` const doFetch = platform.fetch ?? fetch const res = await doFetch(url, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: props.path, content: draft() }), }) if (!res.ok) throw new Error(`save failed (${res.status})`) const d: any = await res.json().catch(() => ({})) const next = typeof d?.content === "string" ? d.content : draft() setDraft(next) setSavedText(next) toast.success("saved", name()) } catch (err: any) { toast.error("save failed", err?.message ?? String(err)) } finally { setSaving(false) } } const copy = async () => { try { await navigator.clipboard?.writeText(isBinary() ? dataUrl() : draft()) toast.success("copied", name()) } catch {} } const toggleable = () => kind() === "markdown" || kind() === "code" return (
{/* header */}
{name()} {props.subtitle}
{badge()}
{/* body */} loading…
} >
couldn't open this file
{file.error instanceof Error ? file.error.message : String(file.error)}
} >
{/* markdown */}
{/* pdf */}
{/* image */}
{name()}
{/* binary */}
Binary file — no inline preview.
Use the download button above to open it.
{/* code / text — editable source, or highlighted read view */}