Skip to content

Preview — renderer dispatch (markdown, PDF, images, CodeMirror) #726

Closed as not planned

Description

@jeonghun-jj-lee

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
  • .md files render via the existing <Markdown> component with preprocessMarkdown (math block conversion — currently a module-private function in session-preview-tab.tsx:35-37, must be extracted and exported) and zoom controls
  • The <Markdown> component requires useMarked() from MarkedProvider. Confirmed: MarkedProvider is mounted at app.tsx:546 and wraps the entire app tree including SessionPreviewTab. No WorkerPoolProvider needed.
  • .md files have a Preview | Editor toggle — Preview shows rendered markdown, Editor opens CodeMirror
  • .pdf files render via PDFViewer (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)
  • .tex files 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
  • .log files 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 .md files, preprocessMarkdown at line 35-37, RawEditor textarea at lines 399-428
  • FileMedia in session-ui/src/components/file-media.tsx — <Switch>/<Match> dispatch on kind() (lines 186-289): image/audio at line 188, SVG at line 251, binary at line 274, fallback at line 288
  • SessionReviewDiffStyle — 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

  1. changed the title [-]Preview — renderer dispatch (markdown, source, images, source-to-PDF toggle)[/-] [+]Preview — renderer dispatch (markdown, PDF, images, CodeMirror)[/+] on Sep 3, 2026
  2. jeonghun-jj-lee commented on Sep 8, 2026

    @jeonghun-jj-lee
    ContributorAuthor

    Closing — approach didn't land. Will revisit the Preview tab with a fresh design.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

afkImplementable without human interactionarea:uienhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions