feat(ui): add markdown preview to file viewer (#352)

Fixes #331

## Summary
- add an optional Markdown preview toggle for markdown files in the
Files tab
- add a word-wrap toggle for the source editor
- escape raw HTML in preview mode and limit preview to plain Markdown
file extensions

## Why
The Files tab only showed raw source, which makes Markdown files harder
to read quickly.

This change adds a lightweight preview/source switch without introducing
a larger viewer registry.

## What Changed
-
`packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx`
  - added `Preview Markdown` / `Show source` toggle for markdown files
  - added a word-wrap toggle for the Monaco source viewer
  - restricted preview mode to plain Markdown extensions
  - escaped raw HTML in markdown preview mode
- `packages/ui/src/components/file-viewer/monaco-file-viewer.tsx`
  - added configurable word-wrap support
- `packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx`
- moved file-viewer word-wrap state up so it persists across tab
switches
- `packages/ui/src/components/instance/shell/storage.ts`
  - added storage key for file-viewer word wrap
- `packages/ui/src/lib/i18n/messages/*/instance.ts`
  - added strings for preview/source and word-wrap controls

## Validation
- `npm run build --workspace @codenomad/ui`
This commit is contained in:
Pascal André
2026-04-26 22:24:19 +02:00
committed by GitHub
parent 27f9c76a94
commit 0ba1371348
13 changed files with 298 additions and 294 deletions

View File

@@ -9,6 +9,7 @@ interface MonacoFileViewerProps {
scopeKey: string
path: string
content: string
wordWrap?: "on" | "off"
onSave?: (content: string) => void
onContentChange?: (content: string) => void
}
@@ -84,6 +85,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
})
createEffect(() => {
if (!ready() || !editor) return
editor.updateOptions({ wordWrap: props.wordWrap === "on" ? "on" : "off" })
})
createEffect(() => {
if (!ready() || !monaco || !editor) return
const languageId = inferMonacoLanguageId(monaco, props.path)

View File

@@ -42,6 +42,7 @@ import {
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_FILES_WORD_WRAP_KEY,
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
@@ -131,6 +132,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
)
const [filesWordWrapMode, setFilesWordWrapMode] = createSignal<DiffWordWrapMode>(
readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
@@ -254,6 +258,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode())
})
const clampSplitWidth = (value: number) => {
const min = 200
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
@@ -912,6 +921,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
browserSelectedError={browserSelectedError}
browserSelectedDirty={browserSelectedDirty}
browserSelectedSaving={browserSelectedSaving}
wordWrapMode={filesWordWrapMode}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
@@ -919,6 +929,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onRefresh={() => void refreshFilesTab()}
onSave={(content: string) => void saveBrowserFile(content)}
onContentChange={(content: string) => handleBrowserFileChange(content)}
onWordWrapModeChange={setFilesWordWrapMode}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}

View File

@@ -1,16 +1,23 @@
import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { Copy, RefreshCw, Save, Search } from "lucide-solid"
import { Copy, RefreshCw, Save, Search, WrapText } from "lucide-solid"
import SplitFilePanel from "../components/SplitFilePanel"
import { Markdown } from "../../../../markdown"
import { copyToClipboard } from "../../../../../lib/clipboard"
import { showToastNotification } from "../../../../../lib/notifications"
import { useTheme } from "../../../../../lib/theme"
const LazyMonacoFileViewer = lazy(() =>
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
)
function isMarkdownPath(path: string | null | undefined): boolean {
if (!path) return false
return /\.(md|markdown|mdown|mkdn)$/i.test(path)
}
interface FilesTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -25,6 +32,7 @@ interface FilesTabProps {
browserSelectedError: Accessor<string | null>
browserSelectedDirty: Accessor<boolean>
browserSelectedSaving: Accessor<boolean>
wordWrapMode: Accessor<"on" | "off">
parentPath: Accessor<string | null>
scopeKey: Accessor<string>
@@ -34,6 +42,7 @@ interface FilesTabProps {
onRefresh: () => void
onSave: (content: string) => void
onContentChange: (content: string) => void
onWordWrapModeChange: (mode: "on" | "off") => void
listOpen: Accessor<boolean>
onToggleList: () => void
@@ -45,6 +54,9 @@ interface FilesTabProps {
const FilesTab: Component<FilesTabProps> = (props) => {
const [filterQuery, setFilterQuery] = createSignal("")
const { isDark } = useTheme()
const [markdownPreviewEnabled, setMarkdownPreviewEnabled] = createSignal(false)
let markdownPreviewRef: HTMLDivElement | undefined
createEffect(() => {
props.browserPath()
@@ -78,6 +90,14 @@ const FilesTab: Component<FilesTabProps> = (props) => {
const listEmptyMessage = () =>
normalizedQuery() ? props.t("instanceShell.filesShell.search.empty") : props.t("instanceShell.filesShell.listEmpty")
const selectedMarkdownFile = createMemo(() => isMarkdownPath(props.browserSelectedPath()))
const showingMarkdownPreview = createMemo(() => selectedMarkdownFile() && markdownPreviewEnabled())
createEffect(() => {
if (!selectedMarkdownFile()) {
setMarkdownPreviewEnabled(false)
}
})
const handleSave = () => {
const content = props.browserSelectedContent()
if (content !== undefined && content !== null) {
@@ -94,6 +114,11 @@ const FilesTab: Component<FilesTabProps> = (props) => {
})
}
createEffect(() => {
if (!showingMarkdownPreview()) return
requestAnimationFrame(() => markdownPreviewRef?.focus())
})
const FileList: Component = () => (
<>
<div class="px-2 py-2 border-b border-base">
@@ -182,6 +207,13 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</>
)
const handleMarkdownPreviewKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return
if (props.browserSelectedSaving() || !props.browserSelectedDirty()) return
event.preventDefault()
handleSave()
}
const renderContent = (): JSX.Element => {
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
@@ -192,7 +224,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco">
<div class={showingMarkdownPreview() ? "file-viewer-content" : "file-viewer-content file-viewer-content--monaco"}>
<Show
when={props.browserSelectedLoading()}
fallback={
@@ -212,21 +244,37 @@ const FilesTab: Component<FilesTabProps> = (props) => {
}
>
{(payload) => (
<Suspense
<Show
when={showingMarkdownPreview()}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoFileViewer
scopeKey={props.scopeKey()}
path={payload().path}
content={payload().content}
wordWrap={props.wordWrapMode()}
onSave={props.onSave}
onContentChange={props.onContentChange}
/>
</Suspense>
}
>
<LazyMonacoFileViewer
scopeKey={props.scopeKey()}
path={payload().path}
content={payload().content}
onSave={props.onSave}
onContentChange={props.onContentChange}
/>
</Suspense>
<div
ref={markdownPreviewRef}
class="h-full outline-none"
tabIndex={0}
onKeyDown={handleMarkdownPreviewKeyDown}
onMouseDown={() => markdownPreviewRef?.focus()}
>
<Markdown part={{ type: "text", text: payload().content }} isDark={isDark()} escapeRawHtml />
</div>
</Show>
)}
</Show>
}
@@ -262,13 +310,33 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class={`file-viewer-toolbar-button${showingMarkdownPreview() ? " active" : ""}`}
disabled={!selectedMarkdownFile()}
style={{ "margin-inline-start": "auto" }}
onClick={() => selectedMarkdownFile() && setMarkdownPreviewEnabled((prev) => !prev)}
>
{showingMarkdownPreview()
? props.t("instanceShell.filesShell.showSource")
: props.t("instanceShell.filesShell.previewMarkdown")}
</button>
<button
type="button"
class={`file-viewer-toolbar-icon-button${props.wordWrapMode() === "on" ? " active" : ""}`}
title={props.wordWrapMode() === "on" ? props.t("instanceShell.filesShell.disableWordWrap") : props.t("instanceShell.filesShell.enableWordWrap")}
aria-label={props.wordWrapMode() === "on" ? props.t("instanceShell.filesShell.disableWordWrap") : props.t("instanceShell.filesShell.enableWordWrap")}
disabled={showingMarkdownPreview()}
onClick={() => props.onWordWrapModeChange(props.wordWrapMode() === "on" ? "off" : "on")}
>
<WrapText class="h-4 w-4" />
</button>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
style={{ "margin-inline-start": "auto" }}
onClick={handleSave}
>
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>

View File

@@ -28,6 +28,7 @@ export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
export const RIGHT_PANEL_FILES_WORD_WRAP_KEY = "opencode-session-right-panel-files-word-wrap-v1"
export const clampWidth = (value: number) =>
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))

View File

@@ -158,6 +158,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "Copy path",
"instanceShell.filesShell.toast.copyPathSuccess": "Copied path",
"instanceShell.filesShell.toast.copyPathError": "Failed to copy path",
"instanceShell.filesShell.previewMarkdown": "Preview Markdown",
"instanceShell.filesShell.showSource": "Show source",
"instanceShell.filesShell.enableWordWrap": "Enable word wrap",
"instanceShell.filesShell.disableWordWrap": "Disable word wrap",
"instanceShell.diff.hideUnchanged": "Hide unchanged regions",
"instanceShell.diff.showFull": "Show full file",
"instanceShell.diff.switchToSplit": "Switch to split view",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "Copiar ruta",
"instanceShell.filesShell.toast.copyPathSuccess": "Ruta copiada",
"instanceShell.filesShell.toast.copyPathError": "No se pudo copiar la ruta",
"instanceShell.filesShell.previewMarkdown": "Vista previa Markdown",
"instanceShell.filesShell.showSource": "Mostrar fuente",
"instanceShell.filesShell.enableWordWrap": "Activar ajuste de línea",
"instanceShell.filesShell.disableWordWrap": "Desactivar ajuste de línea",
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
"instanceShell.plan.empty": "Aún no hay nada planificado.",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "Copier le chemin",
"instanceShell.filesShell.toast.copyPathSuccess": "Chemin copié",
"instanceShell.filesShell.toast.copyPathError": "Impossible de copier le chemin",
"instanceShell.filesShell.previewMarkdown": "Aperçu Markdown",
"instanceShell.filesShell.showSource": "Afficher la source",
"instanceShell.filesShell.enableWordWrap": "Activer le retour à la ligne",
"instanceShell.filesShell.disableWordWrap": "Désactiver le retour à la ligne",
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
"instanceShell.plan.empty": "Aucun plan pour l'instant.",

View File

@@ -142,6 +142,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "העתק נתיב",
"instanceShell.filesShell.toast.copyPathSuccess": "הנתיב הועתק",
"instanceShell.filesShell.toast.copyPathError": "העתקת הנתיב נכשלה",
"instanceShell.filesShell.previewMarkdown": "תצוגת Markdown",
"instanceShell.filesShell.showSource": "הצג מקור",
"instanceShell.filesShell.enableWordWrap": "הפעל גלישת מילים",
"instanceShell.filesShell.disableWordWrap": "כבה גלישת מילים",
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "パスをコピー",
"instanceShell.filesShell.toast.copyPathSuccess": "パスをコピーしました",
"instanceShell.filesShell.toast.copyPathError": "パスをコピーできませんでした",
"instanceShell.filesShell.previewMarkdown": "Markdown プレビュー",
"instanceShell.filesShell.showSource": "ソースを表示",
"instanceShell.filesShell.enableWordWrap": "折り返しを有効化",
"instanceShell.filesShell.disableWordWrap": "折り返しを無効化",
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
"instanceShell.plan.empty": "まだ計画はありません。",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "Скопировать путь",
"instanceShell.filesShell.toast.copyPathSuccess": "Путь скопирован",
"instanceShell.filesShell.toast.copyPathError": "Не удалось скопировать путь",
"instanceShell.filesShell.previewMarkdown": "Предпросмотр Markdown",
"instanceShell.filesShell.showSource": "Показать исходник",
"instanceShell.filesShell.enableWordWrap": "Включить перенос строк",
"instanceShell.filesShell.disableWordWrap": "Отключить перенос строк",
"instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.",
"instanceShell.plan.empty": "Пока ничего не запланировано.",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "复制路径",
"instanceShell.filesShell.toast.copyPathSuccess": "路径已复制",
"instanceShell.filesShell.toast.copyPathError": "无法复制路径",
"instanceShell.filesShell.previewMarkdown": "Markdown 预览",
"instanceShell.filesShell.showSource": "显示源码",
"instanceShell.filesShell.enableWordWrap": "启用自动换行",
"instanceShell.filesShell.disableWordWrap": "禁用自动换行",
"instanceShell.plan.noSessionSelected": "选择会话以查看计划。",
"instanceShell.plan.empty": "暂无计划。",

View File

@@ -16,6 +16,135 @@ let rendererSetup = false
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
const ALLOWED_RAW_HTML_TAGS = new Set([
"a",
"blockquote",
"br",
"code",
"del",
"details",
"div",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
"kbd",
"li",
"ol",
"p",
"pre",
"span",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
])
const DROP_RAW_HTML_TAGS = new Set(["script", "style", "iframe", "object", "embed", "meta", "link"])
function sanitizeUrlAttribute(tagName: string, attrName: string, value: string): string | null {
const trimmed = value.trim()
if (!trimmed) return null
if (attrName === "src" && tagName === "img") {
if (/^(https?:|data:image\/|\/|\.\/|\.\.\/|#)/i.test(trimmed)) return trimmed
return null
}
if (attrName === "href" && tagName === "a") {
if (/^(https?:|mailto:|\/|\.\/|\.\.\/|#)/i.test(trimmed)) return trimmed
return null
}
return null
}
function sanitizeRawHtmlFragment(html: string): string {
const decoded = decodeHtmlEntities(html)
if (typeof document === "undefined") {
return escapeHtml(decoded)
}
const template = document.createElement("template")
template.innerHTML = decoded
const sanitizeElement = (element: Element) => {
const tagName = element.tagName.toLowerCase()
if (DROP_RAW_HTML_TAGS.has(tagName)) {
element.remove()
return
}
if (!ALLOWED_RAW_HTML_TAGS.has(tagName)) {
element.replaceWith(...Array.from(element.childNodes))
return
}
for (const attr of Array.from(element.attributes)) {
const attrName = attr.name.toLowerCase()
if (attrName.startsWith("on") || attrName === "style") {
element.removeAttribute(attr.name)
continue
}
if (attrName === "href" || attrName === "src") {
const sanitized = sanitizeUrlAttribute(tagName, attrName, attr.value)
if (sanitized) {
element.setAttribute(attr.name, sanitized)
continue
}
element.removeAttribute(attr.name)
continue
}
if (
attrName === "alt" ||
attrName === "title" ||
attrName === "width" ||
attrName === "height" ||
attrName === "open" ||
attrName === "id" ||
attrName === "class" ||
attrName === "name" ||
attrName.startsWith("aria-") ||
attrName.startsWith("data-")
) {
continue
}
element.removeAttribute(attr.name)
}
if (tagName === "a") {
element.setAttribute("target", "_blank")
element.setAttribute("rel", "noopener noreferrer")
}
}
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT)
const elements: Element[] = []
while (walker.nextNode()) {
elements.push(walker.currentNode as Element)
}
for (const element of elements.reverse()) {
sanitizeElement(element)
}
return template.innerHTML
}
// Track loaded languages and queue for on-demand loading
const loadedLanguages = new Set<string>()
const queuedLanguages = new Set<string>()
@@ -318,7 +447,7 @@ function setupRenderer(isDark: boolean) {
return html
}
return escapeHtml(decodeHtmlEntities(html))
return sanitizeRawHtmlFragment(html)
}
marked.use({ renderer })