Skip to content

Commit 0ba1371

Browse files
authored
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`
1 parent 27f9c76 commit 0ba1371

13 files changed

Lines changed: 298 additions & 294 deletions

File tree

package-lock.json

Lines changed: 39 additions & 278 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ui/src/components/file-viewer/monaco-file-viewer.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface MonacoFileViewerProps {
99
scopeKey: string
1010
path: string
1111
content: string
12+
wordWrap?: "on" | "off"
1213
onSave?: (content: string) => void
1314
onContentChange?: (content: string) => void
1415
}
@@ -84,6 +85,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
8485
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
8586
})
8687

88+
createEffect(() => {
89+
if (!ready() || !editor) return
90+
editor.updateOptions({ wordWrap: props.wordWrap === "on" ? "on" : "off" })
91+
})
92+
8793
createEffect(() => {
8894
if (!ready() || !monaco || !editor) return
8995
const languageId = inferMonacoLanguageId(monaco, props.path)

packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
4343
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
4444
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
45+
RIGHT_PANEL_FILES_WORD_WRAP_KEY,
4546
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
4647
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
4748
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
@@ -131,6 +132,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
131132
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
132133
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
133134
)
135+
const [filesWordWrapMode, setFilesWordWrapMode] = createSignal<DiffWordWrapMode>(
136+
readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off",
137+
)
134138

135139
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
136140
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
@@ -254,6 +258,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
254258
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
255259
})
256260

261+
createEffect(() => {
262+
if (typeof window === "undefined") return
263+
window.localStorage.setItem(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode())
264+
})
265+
257266
const clampSplitWidth = (value: number) => {
258267
const min = 200
259268
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
@@ -912,13 +921,15 @@ const RightPanel: Component<RightPanelProps> = (props) => {
912921
browserSelectedError={browserSelectedError}
913922
browserSelectedDirty={browserSelectedDirty}
914923
browserSelectedSaving={browserSelectedSaving}
924+
wordWrapMode={filesWordWrapMode}
915925
parentPath={browserParentPath}
916926
scopeKey={browserScopeKey}
917927
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
918928
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
919929
onRefresh={() => void refreshFilesTab()}
920930
onSave={(content: string) => void saveBrowserFile(content)}
921931
onContentChange={(content: string) => handleBrowserFileChange(content)}
932+
onWordWrapModeChange={setFilesWordWrapMode}
922933
listOpen={filesListOpen}
923934
onToggleList={toggleFilesList}
924935
splitWidth={filesSplitWidth}

packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js"
22
import type { FileNode } from "@opencode-ai/sdk/v2/client"
33

4-
import { Copy, RefreshCw, Save, Search } from "lucide-solid"
4+
import { Copy, RefreshCw, Save, Search, WrapText } from "lucide-solid"
55

66
import SplitFilePanel from "../components/SplitFilePanel"
7+
import { Markdown } from "../../../../markdown"
78
import { copyToClipboard } from "../../../../../lib/clipboard"
89
import { showToastNotification } from "../../../../../lib/notifications"
10+
import { useTheme } from "../../../../../lib/theme"
911

1012
const LazyMonacoFileViewer = lazy(() =>
1113
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
1214
)
1315

16+
function isMarkdownPath(path: string | null | undefined): boolean {
17+
if (!path) return false
18+
return /\.(md|markdown|mdown|mkdn)$/i.test(path)
19+
}
20+
1421
interface FilesTabProps {
1522
t: (key: string, vars?: Record<string, any>) => string
1623

@@ -25,6 +32,7 @@ interface FilesTabProps {
2532
browserSelectedError: Accessor<string | null>
2633
browserSelectedDirty: Accessor<boolean>
2734
browserSelectedSaving: Accessor<boolean>
35+
wordWrapMode: Accessor<"on" | "off">
2836

2937
parentPath: Accessor<string | null>
3038
scopeKey: Accessor<string>
@@ -34,6 +42,7 @@ interface FilesTabProps {
3442
onRefresh: () => void
3543
onSave: (content: string) => void
3644
onContentChange: (content: string) => void
45+
onWordWrapModeChange: (mode: "on" | "off") => void
3746

3847
listOpen: Accessor<boolean>
3948
onToggleList: () => void
@@ -45,6 +54,9 @@ interface FilesTabProps {
4554

4655
const FilesTab: Component<FilesTabProps> = (props) => {
4756
const [filterQuery, setFilterQuery] = createSignal("")
57+
const { isDark } = useTheme()
58+
const [markdownPreviewEnabled, setMarkdownPreviewEnabled] = createSignal(false)
59+
let markdownPreviewRef: HTMLDivElement | undefined
4860

4961
createEffect(() => {
5062
props.browserPath()
@@ -78,6 +90,14 @@ const FilesTab: Component<FilesTabProps> = (props) => {
7890
const listEmptyMessage = () =>
7991
normalizedQuery() ? props.t("instanceShell.filesShell.search.empty") : props.t("instanceShell.filesShell.listEmpty")
8092

93+
const selectedMarkdownFile = createMemo(() => isMarkdownPath(props.browserSelectedPath()))
94+
const showingMarkdownPreview = createMemo(() => selectedMarkdownFile() && markdownPreviewEnabled())
95+
96+
createEffect(() => {
97+
if (!selectedMarkdownFile()) {
98+
setMarkdownPreviewEnabled(false)
99+
}
100+
})
81101
const handleSave = () => {
82102
const content = props.browserSelectedContent()
83103
if (content !== undefined && content !== null) {
@@ -94,6 +114,11 @@ const FilesTab: Component<FilesTabProps> = (props) => {
94114
})
95115
}
96116

117+
createEffect(() => {
118+
if (!showingMarkdownPreview()) return
119+
requestAnimationFrame(() => markdownPreviewRef?.focus())
120+
})
121+
97122
const FileList: Component = () => (
98123
<>
99124
<div class="px-2 py-2 border-b border-base">
@@ -182,6 +207,13 @@ const FilesTab: Component<FilesTabProps> = (props) => {
182207
</>
183208
)
184209

210+
const handleMarkdownPreviewKeyDown = (event: KeyboardEvent) => {
211+
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return
212+
if (props.browserSelectedSaving() || !props.browserSelectedDirty()) return
213+
event.preventDefault()
214+
handleSave()
215+
}
216+
185217
const renderContent = (): JSX.Element => {
186218
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
187219

@@ -192,7 +224,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
192224

193225
const renderViewer = () => (
194226
<div class="file-viewer-panel flex-1">
195-
<div class="file-viewer-content file-viewer-content--monaco">
227+
<div class={showingMarkdownPreview() ? "file-viewer-content" : "file-viewer-content file-viewer-content--monaco"}>
196228
<Show
197229
when={props.browserSelectedLoading()}
198230
fallback={
@@ -212,21 +244,37 @@ const FilesTab: Component<FilesTabProps> = (props) => {
212244
}
213245
>
214246
{(payload) => (
215-
<Suspense
247+
<Show
248+
when={showingMarkdownPreview()}
216249
fallback={
217-
<div class="file-viewer-empty">
218-
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
219-
</div>
250+
<Suspense
251+
fallback={
252+
<div class="file-viewer-empty">
253+
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
254+
</div>
255+
}
256+
>
257+
<LazyMonacoFileViewer
258+
scopeKey={props.scopeKey()}
259+
path={payload().path}
260+
content={payload().content}
261+
wordWrap={props.wordWrapMode()}
262+
onSave={props.onSave}
263+
onContentChange={props.onContentChange}
264+
/>
265+
</Suspense>
220266
}
221267
>
222-
<LazyMonacoFileViewer
223-
scopeKey={props.scopeKey()}
224-
path={payload().path}
225-
content={payload().content}
226-
onSave={props.onSave}
227-
onContentChange={props.onContentChange}
228-
/>
229-
</Suspense>
268+
<div
269+
ref={markdownPreviewRef}
270+
class="h-full outline-none"
271+
tabIndex={0}
272+
onKeyDown={handleMarkdownPreviewKeyDown}
273+
onMouseDown={() => markdownPreviewRef?.focus()}
274+
>
275+
<Markdown part={{ type: "text", text: payload().content }} isDark={isDark()} escapeRawHtml />
276+
</div>
277+
</Show>
230278
)}
231279
</Show>
232280
}
@@ -262,13 +310,33 @@ const FilesTab: Component<FilesTabProps> = (props) => {
262310
</Show>
263311
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
264312
</div>
313+
<button
314+
type="button"
315+
class={`file-viewer-toolbar-button${showingMarkdownPreview() ? " active" : ""}`}
316+
disabled={!selectedMarkdownFile()}
317+
style={{ "margin-inline-start": "auto" }}
318+
onClick={() => selectedMarkdownFile() && setMarkdownPreviewEnabled((prev) => !prev)}
319+
>
320+
{showingMarkdownPreview()
321+
? props.t("instanceShell.filesShell.showSource")
322+
: props.t("instanceShell.filesShell.previewMarkdown")}
323+
</button>
324+
<button
325+
type="button"
326+
class={`file-viewer-toolbar-icon-button${props.wordWrapMode() === "on" ? " active" : ""}`}
327+
title={props.wordWrapMode() === "on" ? props.t("instanceShell.filesShell.disableWordWrap") : props.t("instanceShell.filesShell.enableWordWrap")}
328+
aria-label={props.wordWrapMode() === "on" ? props.t("instanceShell.filesShell.disableWordWrap") : props.t("instanceShell.filesShell.enableWordWrap")}
329+
disabled={showingMarkdownPreview()}
330+
onClick={() => props.onWordWrapModeChange(props.wordWrapMode() === "on" ? "off" : "on")}
331+
>
332+
<WrapText class="h-4 w-4" />
333+
</button>
265334
<button
266335
type="button"
267336
class="files-header-icon-button"
268337
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
269338
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
270339
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
271-
style={{ "margin-inline-start": "auto" }}
272340
onClick={handleSave}
273341
>
274342
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>

packages/ui/src/components/instance/shell/storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session
2828
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
2929
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
3030
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
31+
export const RIGHT_PANEL_FILES_WORD_WRAP_KEY = "opencode-session-right-panel-files-word-wrap-v1"
3132

3233
export const clampWidth = (value: number) =>
3334
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))

packages/ui/src/lib/i18n/messages/en/instance.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,10 @@ export const instanceMessages = {
158158
"instanceShell.filesShell.actions.copyPath": "Copy path",
159159
"instanceShell.filesShell.toast.copyPathSuccess": "Copied path",
160160
"instanceShell.filesShell.toast.copyPathError": "Failed to copy path",
161+
"instanceShell.filesShell.previewMarkdown": "Preview Markdown",
162+
"instanceShell.filesShell.showSource": "Show source",
163+
"instanceShell.filesShell.enableWordWrap": "Enable word wrap",
164+
"instanceShell.filesShell.disableWordWrap": "Disable word wrap",
161165
"instanceShell.diff.hideUnchanged": "Hide unchanged regions",
162166
"instanceShell.diff.showFull": "Show full file",
163167
"instanceShell.diff.switchToSplit": "Switch to split view",

packages/ui/src/lib/i18n/messages/es/instance.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ export const instanceMessages = {
155155
"instanceShell.filesShell.actions.copyPath": "Copiar ruta",
156156
"instanceShell.filesShell.toast.copyPathSuccess": "Ruta copiada",
157157
"instanceShell.filesShell.toast.copyPathError": "No se pudo copiar la ruta",
158+
"instanceShell.filesShell.previewMarkdown": "Vista previa Markdown",
159+
"instanceShell.filesShell.showSource": "Mostrar fuente",
160+
"instanceShell.filesShell.enableWordWrap": "Activar ajuste de línea",
161+
"instanceShell.filesShell.disableWordWrap": "Desactivar ajuste de línea",
158162

159163
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
160164
"instanceShell.plan.empty": "Aún no hay nada planificado.",

packages/ui/src/lib/i18n/messages/fr/instance.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ export const instanceMessages = {
155155
"instanceShell.filesShell.actions.copyPath": "Copier le chemin",
156156
"instanceShell.filesShell.toast.copyPathSuccess": "Chemin copié",
157157
"instanceShell.filesShell.toast.copyPathError": "Impossible de copier le chemin",
158+
"instanceShell.filesShell.previewMarkdown": "Aperçu Markdown",
159+
"instanceShell.filesShell.showSource": "Afficher la source",
160+
"instanceShell.filesShell.enableWordWrap": "Activer le retour à la ligne",
161+
"instanceShell.filesShell.disableWordWrap": "Désactiver le retour à la ligne",
158162

159163
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
160164
"instanceShell.plan.empty": "Aucun plan pour l'instant.",

packages/ui/src/lib/i18n/messages/he/instance.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ export const instanceMessages = {
142142
"instanceShell.filesShell.actions.copyPath": "העתק נתיב",
143143
"instanceShell.filesShell.toast.copyPathSuccess": "הנתיב הועתק",
144144
"instanceShell.filesShell.toast.copyPathError": "העתקת הנתיב נכשלה",
145+
"instanceShell.filesShell.previewMarkdown": "תצוגת Markdown",
146+
"instanceShell.filesShell.showSource": "הצג מקור",
147+
"instanceShell.filesShell.enableWordWrap": "הפעל גלישת מילים",
148+
"instanceShell.filesShell.disableWordWrap": "כבה גלישת מילים",
145149
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
146150
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
147151
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",

packages/ui/src/lib/i18n/messages/ja/instance.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ export const instanceMessages = {
155155
"instanceShell.filesShell.actions.copyPath": "パスをコピー",
156156
"instanceShell.filesShell.toast.copyPathSuccess": "パスをコピーしました",
157157
"instanceShell.filesShell.toast.copyPathError": "パスをコピーできませんでした",
158+
"instanceShell.filesShell.previewMarkdown": "Markdown プレビュー",
159+
"instanceShell.filesShell.showSource": "ソースを表示",
160+
"instanceShell.filesShell.enableWordWrap": "折り返しを有効化",
161+
"instanceShell.filesShell.disableWordWrap": "折り返しを無効化",
158162

159163
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
160164
"instanceShell.plan.empty": "まだ計画はありません。",

0 commit comments

Comments
 (0)