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()} setDraft(savedText())} style={ctlBtn()}> reset void save()} style={ctlBtn(true)}> {saving() ? "saving…" : "save"} setShowSource((v) => !v)} title={showSource() ? "rendered view" : kind() === "code" ? "edit source" : "raw source"} style={iconBtn(showSource())} > }> void copy()} title="copy contents" style={iconBtn()}> setRefreshKey((k) => k + 1)} title="refresh" style={iconBtn()}> props.onClose!()} title="close" style={iconBtn()}> {/* body */} loading… } > couldn't open this file {file.error instanceof Error ? file.error.message : String(file.error)} setRefreshKey((k) => k + 1)} style={retryBtn()}> retry } > {/* markdown */} {/* pdf */} {/* image */} {/* binary */} Binary file — no inline preview. Use the download button above to open it. {/* code / text — editable source, or highlighted read view */} setDraft(ev.currentTarget.value)} class="thesis-scroll" style={{ all: "unset", "box-sizing": "border-box", display: "block", width: "100%", "min-height": "100%", padding: "16px 18px", "font-family": FONT_CODE, "font-size": "12px", "line-height": 1.65, color: "var(--color-text)", "white-space": "pre", "tab-size": 2, }} /> ) } /** * Slide-in drawer wrapper around FileView — kept for the legacy right-pane * preview path. Backdrop / Esc / the header × all close it. */ export function FilePreview(props: { path: string; onClose: () => void }): JSX.Element { const [mounted, setMounted] = createSignal(false) onMount(() => { requestAnimationFrame(() => setMounted(true)) const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") props.onClose() } window.addEventListener("keydown", onKey) onCleanup(() => window.removeEventListener("keydown", onKey)) }) return ( ) } function langFor(k: Kind, x: string): string { if (k === "markdown") return "markdown" return LANG[x] ?? "text" } // Wrap raw file text in a fenced code block so the shared Markdown renderer // (marked + shiki) syntax-highlights it. Guards against content that already // contains a triple backtick by widening the fence. function fence(lang: string, body: string): string { let ticks = "```" while (body.includes(ticks)) ticks += "`" return `${ticks}${lang}\n${body}\n${ticks}` } function iconBtn(active = false): JSX.CSSProperties { return { all: "unset", cursor: "pointer", display: "inline-flex", "align-items": "center", "justify-content": "center", width: "28px", height: "28px", "border-radius": "4px", color: active ? "var(--color-text)" : "var(--color-text-faint)", background: active ? "var(--color-accent-subtle)" : "transparent", "flex-shrink": 0, transition: "background 120ms ease, color 120ms ease", } as JSX.CSSProperties } function retryBtn(): JSX.CSSProperties { return { all: "unset", cursor: "pointer", "margin-top": "2px", padding: "5px 12px", "border-radius": "4px", border: "1px solid var(--color-border)", "font-family": FONT_MONO, "font-size": "11px", color: "var(--color-text)", } as JSX.CSSProperties } function ctlBtn(primary = false): JSX.CSSProperties { return { all: "unset", cursor: "pointer", display: "inline-flex", "align-items": "center", padding: "5px 11px", "border-radius": "4px", border: primary ? "1px solid var(--color-text)" : "1px solid var(--color-border)", background: primary ? "var(--color-text)" : "var(--color-bg-subtle)", color: primary ? "var(--color-bg)" : "var(--color-text-muted)", "font-family": FONT_MONO, "font-size": "11px", "font-weight": primary ? 600 : 500, "flex-shrink": 0, } as JSX.CSSProperties } export default FilePreview