Repository navigation
Preview — renderer dispatch (markdown, PDF, images, CodeMirror) #726
Description
Preview — renderer dispatch (markdown, PDF, images, CodeMirror)
Important
Problem — Preview can only render markdown. Selecting any other file type does nothing or shows raw text in a bare textarea. There is no routing from file extension to an appropriate renderer.
Approach — Add a renderer dispatch that routes by file extension: .md → <Markdown> component, .pdf → PDFViewer (slice 3), .png/.jpg/.svg → <img>, and all text files (.tex, .bib, .tikz, .sty, .cls, .txt, .log) → CodeMirror editor (slice 4). The dispatch is a pure function from extension to renderer kind, consumed by the Preview panel's content area. File-type-specific toolbar controls (zoom for PDF/images, Build button for TeX) are wired per renderer kind. The tree (slice 1) and content area share a two-panel layout within the single-pane policy.
Scope — in: extension → renderer dispatch function, content area layout, toolbar controls per renderer kind, error state for failed loads. out: the renderers themselves (PDF in slice 3, CodeMirror in slice 4 — this slice wires slots, not implementations).
Acceptance Criteria
- A
rendererForExtension(ext: string)function maps file extensions to renderer kinds:"markdown","pdf","image","editor" - The Preview content area renders the correct component based on renderer kind when a file is selected from the tree
-
.mdfiles render via the existing<Markdown>component withpreprocessMarkdown(math block conversion — currently a module-private function insession-preview-tab.tsx:35-37, must be extracted and exported) and zoom controls - The
<Markdown>component requiresuseMarked()fromMarkedProvider. Confirmed:MarkedProvideris mounted atapp.tsx:546and wraps the entire app tree includingSessionPreviewTab. NoWorkerPoolProviderneeded. -
.mdfiles have a Preview | Editor toggle — Preview shows rendered markdown, Editor opens CodeMirror -
.pdffiles render viaPDFViewer(slot — actual implementation in slice 3) with zoom controls, read-only - Image files (
.png,.jpg,.svg) render via<img>with zoom controls, read-only - Text files (
.tex,.bib,.tikz,.sty,.cls,.txt,.log) open in CodeMirror (slot — actual implementation in slice 4) -
.texfiles show a Build button in the toolbar when TeX is available (slot — wired by slice 5) - When a file fails to load (deleted, permissions, read error), an inline error state shows "Could not load file" — no blank screen, no crash
- Toolbar controls are renderer-specific — zoom for PDF/images/markdown, save indicator for editor, Build for TeX
- The content area takes the remaining space after the file tree; layout is responsive within the panel width
Testing Decisions
- Unit test
rendererForExtension— verify every supported extension maps correctly, unknown extensions fall back to"editor"(plain text) - Unit test toolbar control visibility per renderer kind (zoom shown for pdf/image/markdown, save indicator for editor, Build for tex)
- Structural test: mount the dispatch component with each renderer kind, verify the correct slot is rendered (mock the actual renderers)
Key Decisions
Dispatch is a pure function. rendererForExtension is a side-effect-free mapping from extension string to renderer kind enum. Easy to test, easy to extend.
Slots, not implementations. This slice wires the routing and layout — the actual PDF viewer (slice 3) and CodeMirror editor (slice 4) are separate slices. Until those land, their slots show a placeholder or the existing fallback.
Unknown extensions → editor (plain text). Any file type not explicitly mapped to markdown/pdf/image opens in CodeMirror as plain text. This is a safe fallback — every text file gets line numbers and editing.
preprocessMarkdown must be extracted. The function is currently module-private in session-preview-tab.tsx:35-37. Extract it to a shared utility so the renderer dispatch can reuse it without duplicating the math-block conversion logic.
Follows FileMedia dispatch pattern. The existing FileMedia component (session-ui/src/components/file-media.tsx:186-289) uses a <Switch>/<Match> dispatch on kind() derived from mediaKindFromPath. The content area dispatch follows this exact same pattern.
Markdown gets a two-way toggle. .md files can switch between rendered preview and CodeMirror editor. This replaces the old Preview | Raw toggle (currently SegmentedControlV2 at session-preview-tab.tsx:282-289) but with a professional editor instead of a bare textarea.
Constraints & Invariants
- Every renderable file type has exactly one renderer kind
- Unknown extensions always fall back to
"editor", never to a blank state - PDF and image renderers are always read-only
.logfiles map to"editor"but are opened read-only- Toolbar controls never show capabilities the current renderer kind doesn't support
Prior Art
session-preview-tab.tsx— current preview/raw toggle for.mdfiles,preprocessMarkdownat line 35-37,RawEditortextarea at lines 399-428FileMediainsession-ui/src/components/file-media.tsx—<Switch>/<Match>dispatch onkind()(lines 186-289): image/audio at line 188, SVG at line 251, binary at line 274, fallback at line 288SessionReviewDiffStyle— already widened to"unified" | "split" | "preview"in feat(review): editable Files Changed panel with CM6 + in-place updates + sidebar fixes #774
Source
harmoniqs/opencode — new dispatch module + layout wiring in packages/app/src/components/session/
Implementation Plan
Files to create
| File | Repo | Purpose |
|---|---|---|
packages/app/src/utils/renderer-dispatch.ts |
opencode | Pure function rendererForExtension(ext) → RendererKind + toolbar config per kind |
packages/app/src/utils/preview-markdown.ts |
opencode | Extracted preprocessMarkdown (from session-preview-tab.tsx:35-37) |
packages/app/src/components/session/preview-content-area.tsx |
opencode | Content area component: Switch on RendererKind → Markdown / PDF / Image / Editor slots |
Files to modify
| File | Repo | Change |
|---|---|---|
packages/app/src/components/session/session-preview-tab.tsx |
opencode | Import extracted preprocessMarkdown; wire new content area when a file is selected from tree |
packages/app/src/components/session/preview-file-tree.tsx |
opencode | (from slice 1) Add content area beside the tree, passing selected file to renderer dispatch |
Integration points (exact)
1. Renderer dispatch — pure function, no dependencies:
// renderer-dispatch.ts
export type RendererKind = "markdown" | "pdf" | "image" | "editor";
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp"]);
export function rendererForExtension(ext: string): RendererKind {
const e = ext.toLowerCase();
if (e === ".md") return "markdown";
if (e === ".pdf") return "pdf";
if (IMAGE_EXTS.has(e)) return "image";
return "editor"; // .tex, .bib, .tikz, .sty, .cls, .txt, .log, and anything else
}
export function toolbarForRenderer(kind: RendererKind, texAvailable: boolean): {
zoom: boolean;
save: boolean;
build: boolean;
modeToggle: "preview-editor" | null;
} {
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 };
}
}2. Content area — uses SolidJS <Switch>/<Match> (same pattern as FileMedia at session-ui/src/components/file-media.tsx:186-289):
// preview-content-area.tsx
import { Switch, Match, Show } from "solid-js";
import { Markdown } from "@opencode-ai/session-ui/markdown";
import { rendererForExtension, type RendererKind } from "@/utils/renderer-dispatch";
import { preprocessMarkdown } from "@/utils/preview-markdown";
export function PreviewContentArea(props: {
filePath: string;
content: () => string | null;
binaryContent: () => ArrayBuffer | null;
rendererKind: () => RendererKind;
readOnly?: boolean;
onSave?: (content: string) => void;
}) {
return (
<Switch fallback={<div class="p-4 text-text-muted">Could not load file</div>}>
<Match when={props.rendererKind() === "markdown"}>
<Markdown text={preprocessMarkdown(props.content() ?? "")} />
</Match>
<Match when={props.rendererKind() === "pdf"}>
{/* PDFViewer slot — placeholder until slice 3 lands */}
<div class="p-4 text-text-muted">PDF viewer (slice 3)</div>
</Match>
<Match when={props.rendererKind() === "image"}>
<img src={`data:image/*;base64,${props.content()}`} class="max-w-full" />
</Match>
<Match when={props.rendererKind() === "editor"}>
{/* PlainEditor slot — placeholder until slice 4 lands */}
<div class="p-4 text-text-muted">Editor (slice 4)</div>
</Match>
</Switch>
);
}3. Extract preprocessMarkdown — currently module-private at session-preview-tab.tsx:35-37:
// preview-markdown.ts
export function preprocessMarkdown(text: string): string {
return text.replace(/```math\n([\s\S]*?)```/g, (_match, body: string) => `$$\n${body.trim()}\n$$`);
}Update session-preview-tab.tsx line 35 to import from the new location and remove the local definition.
4. File content loading — follow the existing pattern in session-preview-tab.tsx. The SDK call is sdk().client.file.read({ path }) (imported via useSDK from @/context/sdk), which returns FileContent with type: "text" | "binary". For binary files (PDF, images), content is base64-encoded and encoding is "base64".
const sdk = useSDK();
const [fileContent, setFileContent] = createSignal<FileContent | null>(null);
createEffect(on(selectedFile, async (path) => {
if (!path) return;
try {
const result = await sdk().client.file.read({ path });
setFileContent(result.data ?? null);
} catch {
setFileContent(null);
}
}));5. .log files read-only — rendererForExtension returns "editor" for .log. Pass readOnly={ext === ".log"} to the editor slot.
Verification
# Unit tests (opencode app uses bun test)
cd ~/harmoniqs/opencode/packages/app && bun test --conditions=solid --preload ./happydom.ts src/utils/renderer-dispatch.test.ts
# Test expectations:
# - rendererForExtension(".md") === "markdown"
# - rendererForExtension(".pdf") === "pdf"
# - rendererForExtension(".png") === "image"
# - rendererForExtension(".tex") === "editor"
# - rendererForExtension(".unknown") === "editor"
# - toolbarForRenderer("markdown", false).zoom === true
# - toolbarForRenderer("editor", true).build === true
# Visual check in Extension Dev Host (F5):
# 1. Open a project with mixed file types
# 2. Click a .md file → see rendered markdown
# 3. Click a .png → see inline image
# 4. Click a .tex → see editor placeholder (until slice 4)
# 5. Click a .pdf → see PDF placeholder (until slice 3)
# 6. Verify error state when clicking a deleted file
Activity
feat(ui): renderer dispatch — file-type to renderer mapping (#726)
[-]Preview — renderer dispatch (markdown, source, images, source-to-PDF toggle)[/-][+]Preview — renderer dispatch (markdown, PDF, images, CodeMirror)[/+]chore(overlay): sync renderer dispatch from opencode fork (#726)
feat: Preview tab — project-wide document browser (#721)
jeonghun-jj-lee commentedon Sep 8, 2026
Closing — approach didn't land. Will revisit the Preview tab with a fresh design.