diff --git a/package-lock.json b/package-lock.json
index 84f11fc4..9ee26ffd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8008,6 +8008,12 @@
"obliterator": "^2.0.1"
}
},
+ "node_modules/monaco-editor": {
+ "version": "0.52.2",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
+ "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
@@ -12064,6 +12070,7 @@
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
+ "monaco-editor": "^0.52.2",
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
diff --git a/packages/ui/.gitignore b/packages/ui/.gitignore
index 1761634c..4e2f7f13 100644
--- a/packages/ui/.gitignore
+++ b/packages/ui/.gitignore
@@ -2,3 +2,4 @@ node_modules/
dist/
.vite/
src/renderer/public/logo.png
+src/renderer/public/monaco/
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 9101436f..f84eb5c4 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -25,6 +25,7 @@
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
+ "monaco-editor": "^0.52.2",
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
new file mode 100644
index 00000000..83684db8
--- /dev/null
+++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
@@ -0,0 +1,110 @@
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { loadMonaco } from "../../lib/monaco/setup"
+import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
+import { inferMonacoLanguageId } from "../../lib/monaco/language"
+import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
+import { useTheme } from "../../lib/theme"
+
+interface MonacoDiffViewerProps {
+ scopeKey: string
+ path: string
+ before: string
+ after: string
+ viewMode?: "split" | "unified"
+ contextMode?: "expanded" | "collapsed"
+}
+
+export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
+ const { isDark } = useTheme()
+ let host: HTMLDivElement | undefined
+
+ let diffEditor: any = null
+ let monaco: any = null
+ const [ready, setReady] = createSignal(false)
+
+ const disposeEditor = () => {
+ try {
+ diffEditor?.setModel(null as any)
+ } catch {
+ // ignore
+ }
+ try {
+ diffEditor?.dispose()
+ } catch {
+ // ignore
+ }
+ diffEditor = null
+ }
+
+ onMount(() => {
+ let cancelled = false
+ void (async () => {
+ monaco = await loadMonaco()
+ if (cancelled) return
+ if (!host || !monaco) return
+
+ monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
+ diffEditor = monaco.editor.createDiffEditor(host, {
+ readOnly: true,
+ automaticLayout: true,
+ renderSideBySide: true,
+ renderSideBySideInlineBreakpoint: 0,
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ renderWhitespace: "selection",
+ fontSize: 13,
+ wordWrap: "off",
+ })
+
+ setReady(true)
+ })()
+
+ onCleanup(() => {
+ cancelled = true
+ setReady(false)
+ disposeEditor()
+ })
+ })
+
+ createEffect(() => {
+ if (!ready() || !monaco || !diffEditor) return
+ monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
+ })
+
+ createEffect(() => {
+ if (!ready() || !monaco || !diffEditor) return
+ const viewMode = props.viewMode === "unified" ? "unified" : "split"
+ const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
+
+ diffEditor.updateOptions({
+ renderSideBySide: viewMode === "split",
+ renderSideBySideInlineBreakpoint: 0,
+ hideUnchangedRegions:
+ contextMode === "collapsed"
+ ? { enabled: true }
+ : { enabled: false },
+ })
+ })
+
+ createEffect(() => {
+ if (!ready() || !monaco || !diffEditor) return
+ const languageId = inferMonacoLanguageId(monaco, props.path)
+ const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
+ const afterKey = `${props.scopeKey}:diff:${props.path}:after`
+
+ const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
+ const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
+ diffEditor.setModel({ original, modified })
+
+ void ensureMonacoLanguageLoaded(languageId).then(() => {
+ try {
+ monaco.editor.setModelLanguage(original, languageId)
+ monaco.editor.setModelLanguage(modified, languageId)
+ } catch {
+ // ignore
+ }
+ })
+ })
+
+ return
+}
diff --git a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx
new file mode 100644
index 00000000..cffecf2d
--- /dev/null
+++ b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx
@@ -0,0 +1,89 @@
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { loadMonaco } from "../../lib/monaco/setup"
+import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
+import { inferMonacoLanguageId } from "../../lib/monaco/language"
+import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
+import { useTheme } from "../../lib/theme"
+
+interface MonacoFileViewerProps {
+ scopeKey: string
+ path: string
+ content: string
+}
+
+export function MonacoFileViewer(props: MonacoFileViewerProps) {
+ const { isDark } = useTheme()
+ let host: HTMLDivElement | undefined
+
+ let editor: any = null
+ let monaco: any = null
+ const [ready, setReady] = createSignal(false)
+
+ const disposeEditor = () => {
+ try {
+ editor?.setModel(null)
+ } catch {
+ // ignore
+ }
+ try {
+ editor?.dispose()
+ } catch {
+ // ignore
+ }
+ editor = null
+ }
+
+ onMount(() => {
+ let cancelled = false
+ void (async () => {
+ monaco = await loadMonaco()
+ if (cancelled) return
+ if (!host || !monaco) return
+
+ monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
+ editor = monaco.editor.create(host, {
+ value: "",
+ language: "plaintext",
+ readOnly: true,
+ automaticLayout: true,
+ lineNumbers: "on",
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ wordWrap: "off",
+ renderWhitespace: "selection",
+ fontSize: 13,
+ })
+
+ setReady(true)
+ })()
+
+ onCleanup(() => {
+ cancelled = true
+ setReady(false)
+ disposeEditor()
+ })
+ })
+
+ createEffect(() => {
+ if (!ready() || !monaco || !editor) return
+ monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
+ })
+
+ createEffect(() => {
+ if (!ready() || !monaco || !editor) return
+ const languageId = inferMonacoLanguageId(monaco, props.path)
+ const cacheKey = `${props.scopeKey}:file:${props.path}`
+ const model = getOrCreateTextModel({ monaco, cacheKey, value: props.content, languageId })
+ editor.setModel(model)
+
+ void ensureMonacoLanguageLoaded(languageId).then(() => {
+ try {
+ monaco.editor.setModelLanguage(model, languageId)
+ } catch {
+ // ignore
+ }
+ })
+ })
+
+ return
+}
diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx
index 9b0f44ae..f04ccc12 100644
--- a/packages/ui/src/components/instance/instance-shell2.tsx
+++ b/packages/ui/src/components/instance/instance-shell2.tsx
@@ -11,6 +11,7 @@ import {
type Component,
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
+import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
import { Accordion } from "@kobalte/core"
import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import AppBar from "@suid/material/AppBar"
@@ -64,10 +65,14 @@ import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client"
+import { requestData } from "../../lib/opencode-api"
import WorktreeSelector from "../worktree-selector"
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n"
+import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../stores/worktrees"
+import { MonacoDiffViewer } from "../file-viewer/monaco-diff-viewer"
+import { MonacoFileViewer } from "../file-viewer/monaco-file-viewer"
import {
SESSION_SIDEBAR_EVENT,
type SessionSidebarRequestAction,
@@ -105,7 +110,7 @@ const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1"
type LayoutMode = "desktop" | "tablet" | "phone"
-type RightPanelTab = "files" | "status"
+type RightPanelTab = "files" | "browser" | "status"
const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
const clampRightWidth = (value: number) => {
@@ -129,7 +134,9 @@ function persistPinState(side: "left" | "right", value: boolean) {
function readStoredRightPanelTab(defaultValue: RightPanelTab): RightPanelTab {
if (typeof window === "undefined") return defaultValue
const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY)
- return stored === "status" ? "status" : defaultValue
+ if (stored === "status") return "status"
+ if (stored === "browser") return "browser"
+ return defaultValue
}
const InstanceShell2: Component = (props) => {
@@ -164,6 +171,19 @@ const InstanceShell2: Component = (props) => {
"plugins",
])
const [selectedFile, setSelectedFile] = createSignal(null)
+
+ const [browserPath, setBrowserPath] = createSignal(".")
+ const [browserEntries, setBrowserEntries] = createSignal(null)
+ const [browserLoading, setBrowserLoading] = createSignal(false)
+ const [browserError, setBrowserError] = createSignal(null)
+ const [browserSelectedPath, setBrowserSelectedPath] = createSignal(null)
+ const [browserSelectedContent, setBrowserSelectedContent] = createSignal(null)
+ const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
+ const [browserSelectedError, setBrowserSelectedError] = createSignal(null)
+
+ const [diffViewMode, setDiffViewMode] = createSignal<"split" | "unified">("split")
+ const [diffContextMode, setDiffContextMode] = createSignal<"expanded" | "collapsed">("collapsed")
+
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
@@ -993,6 +1013,93 @@ const InstanceShell2: Component = (props) => {
)
const RightDrawerContent = () => {
+ const worktreeSlugForViewer = createMemo(() => {
+ const sessionId = activeSessionIdForInstance()
+ if (sessionId && sessionId !== "info") {
+ return getWorktreeSlugForSession(props.instance.id, sessionId)
+ }
+ return getDefaultWorktreeSlug(props.instance.id)
+ })
+
+ createEffect(() => {
+ // Reset browser state when worktree context changes.
+ worktreeSlugForViewer()
+ setBrowserPath(".")
+ setBrowserEntries(null)
+ setBrowserError(null)
+ setBrowserSelectedPath(null)
+ setBrowserSelectedContent(null)
+ setBrowserSelectedError(null)
+ setBrowserSelectedLoading(false)
+ })
+
+ const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instance.id, worktreeSlugForViewer()))
+
+ const normalizeBrowserPath = (input: string) => {
+ const raw = String(input || ".").trim()
+ if (!raw || raw === "./") return "."
+ const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
+ return cleaned === "" ? "." : cleaned
+ }
+
+ const getParentPath = (path: string): string | null => {
+ const current = normalizeBrowserPath(path)
+ if (current === ".") return null
+ const parts = current.split("/").filter(Boolean)
+ parts.pop()
+ return parts.length ? parts.join("/") : "."
+ }
+
+ const loadBrowserEntries = async (path: string) => {
+ const normalized = normalizeBrowserPath(path)
+ setBrowserLoading(true)
+ setBrowserError(null)
+ try {
+ const nodes = await requestData(browserClient().file.list({ path: normalized }), "file.list")
+ setBrowserPath(normalized)
+ setBrowserEntries(Array.isArray(nodes) ? nodes : [])
+ } catch (error) {
+ setBrowserError(error instanceof Error ? error.message : "Failed to load files")
+ setBrowserEntries([])
+ } finally {
+ setBrowserLoading(false)
+ }
+ }
+
+ const openBrowserFile = async (path: string) => {
+ setBrowserSelectedPath(path)
+ setBrowserSelectedLoading(true)
+ setBrowserSelectedError(null)
+ setBrowserSelectedContent(null)
+ try {
+ const content = await requestData(browserClient().file.read({ path }), "file.read")
+ const type = (content as any)?.type
+ const encoding = (content as any)?.encoding
+ if (type && type !== "text") {
+ throw new Error("Binary file cannot be displayed")
+ }
+ if (encoding === "base64") {
+ throw new Error("Binary file cannot be displayed")
+ }
+ const text = (content as any)?.content
+ if (typeof text !== "string") {
+ throw new Error("Unsupported file type")
+ }
+ setBrowserSelectedContent(text)
+ } catch (error) {
+ setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
+ } finally {
+ setBrowserSelectedLoading(false)
+ }
+ }
+
+ createEffect(() => {
+ if (rightPanelTab() !== "browser") return
+ if (browserLoading()) return
+ if (browserEntries() !== null) return
+ void loadBrowserEntries(browserPath())
+ })
+
const renderFilesTabContent = () => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") {
@@ -1034,25 +1141,91 @@ const InstanceShell2: Component = (props) => {
const currentSelected = selectedFile()
const selectedFileData = sorted.find((f) => f.file === currentSelected) || sorted[0]
+ const scopeKey = `${props.instance.id}:${sessionId}`
+
+ const isBinaryDiff = (item: any) => {
+ const before = typeof item?.before === "string" ? item.before : ""
+ const after = typeof item?.after === "string" ? item.after : ""
+ if (before.length === 0 && after.length === 0) {
+ // OpenCode stores empty before/after for binaries.
+ return true
+ }
+ return false
+ }
+
if (isPhoneLayout()) {
return (
-
-
{t("instanceShell.filesShell.mobileSelectorLabel")}
-
+
+
+
+ {(item) => (
+
+ )}
+
+
-
+
+
-
+
= (props) => {
}
>
{(file) => (
-
-
{file().file}
-
{t("instanceShell.filesShell.viewerPlaceholder")}
-
+
+ Binary file cannot be displayed
+
+ }
+ >
+
+
)}
@@ -1123,8 +1309,44 @@ const InstanceShell2: Component
= (props) => {
-
+
= (props) => {
}
>
{(file) => (
-
-
{file().file}
-
{t("instanceShell.filesShell.viewerPlaceholder")}
+
+ Binary file cannot be displayed
+
+ }
+ >
+
+
+ )}
+
+
+
+
+
+ )
+ }
+
+ const renderBrowserTabContent = () => {
+ if (browserLoading() && browserEntries() === null) {
+ return (
+
+ Loading files...
+
+ )
+ }
+
+ const entries = browserEntries() || []
+ const sorted = [...entries].sort((a, b) => {
+ const aDir = a.type === "directory" ? 0 : 1
+ const bDir = b.type === "directory" ? 0 : 1
+ if (aDir !== bDir) return aDir - bDir
+ return String(a.name || "").localeCompare(String(b.name || ""))
+ })
+
+ const parent = getParentPath(browserPath())
+ const scopeKey = `${props.instance.id}:${worktreeSlugForViewer()}`
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Select a file to preview
+
+ }
+ >
+ {(payload) => (
+
+ )}
+
+ }
+ >
+ {(err) => (
+
+ {err()}
+
+ )}
+
+ }
+ >
+
+ Loading…
+
+
@@ -1470,6 +1837,15 @@ const InstanceShell2: Component
= (props) => {
>
{t("instanceShell.rightPanel.tabs.changes")}
+
diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts
index b50ca49c..7cd2ef5b 100644
--- a/packages/ui/src/lib/i18n/messages/en/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/en/instance.ts
@@ -87,6 +87,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Status Panel",
"instanceShell.rightPanel.tabs.changes": "Changes",
+ "instanceShell.rightPanel.tabs.files": "Files",
"instanceShell.rightPanel.tabs.status": "Status",
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts
index c9617774..b88d99bf 100644
--- a/packages/ui/src/lib/i18n/messages/es/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/es/instance.ts
@@ -87,6 +87,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panel de estado",
"instanceShell.rightPanel.tabs.changes": "Cambios",
+ "instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesion",
diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts
index 45be98b0..09f2ee28 100644
--- a/packages/ui/src/lib/i18n/messages/fr/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts
@@ -87,6 +87,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panneau d'état",
"instanceShell.rightPanel.tabs.changes": "Modifications",
+ "instanceShell.rightPanel.tabs.files": "Fichiers",
"instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts
index 2f70bf2a..04a48938 100644
--- a/packages/ui/src/lib/i18n/messages/ja/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts
@@ -87,6 +87,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "ステータスパネル",
"instanceShell.rightPanel.tabs.changes": "変更",
+ "instanceShell.rightPanel.tabs.files": "ファイル",
"instanceShell.rightPanel.tabs.status": "ステータス",
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts
index e1f33b67..17d4ec36 100644
--- a/packages/ui/src/lib/i18n/messages/ru/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts
@@ -87,6 +87,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Панель состояния",
"instanceShell.rightPanel.tabs.changes": "Изменения",
+ "instanceShell.rightPanel.tabs.files": "Файлы",
"instanceShell.rightPanel.tabs.status": "Статус",
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
index a37f5861..a978e220 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
@@ -87,6 +87,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "状态面板",
"instanceShell.rightPanel.tabs.changes": "更改",
+ "instanceShell.rightPanel.tabs.files": "文件",
"instanceShell.rightPanel.tabs.status": "状态",
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
"instanceShell.rightPanel.sections.sessionChanges": "会话更改",
diff --git a/packages/ui/src/lib/monaco/language.ts b/packages/ui/src/lib/monaco/language.ts
new file mode 100644
index 00000000..98d1b908
--- /dev/null
+++ b/packages/ui/src/lib/monaco/language.ts
@@ -0,0 +1,70 @@
+type MonacoApi = any
+
+let cachedLanguageMaps:
+ | {
+ fileNameToId: Map
+ extToId: Map
+ }
+ | null = null
+
+function buildLanguageMaps(monaco: MonacoApi) {
+ if (cachedLanguageMaps) return cachedLanguageMaps
+
+ const fileNameToId = new Map()
+ const extToId = new Map()
+
+ const languages = typeof monaco?.languages?.getLanguages === "function" ? monaco.languages.getLanguages() : []
+ if (Array.isArray(languages)) {
+ for (const lang of languages) {
+ const id = typeof lang?.id === "string" ? lang.id : null
+ if (!id) continue
+
+ const filenames = Array.isArray(lang?.filenames) ? lang.filenames : []
+ for (const name of filenames) {
+ if (typeof name !== "string") continue
+ if (!fileNameToId.has(name)) fileNameToId.set(name, id)
+ }
+
+ const extensions = Array.isArray(lang?.extensions) ? lang.extensions : []
+ for (const ext of extensions) {
+ if (typeof ext !== "string") continue
+ // Monaco uses leading dots for extensions (e.g. ".ts").
+ if (!extToId.has(ext)) extToId.set(ext, id)
+ }
+ }
+ }
+
+ cachedLanguageMaps = { fileNameToId, extToId }
+ return cachedLanguageMaps
+}
+
+function overrideLanguageId(fileName: string): string | null {
+ // Git-style ignore/config files: treat as shell-like.
+ if (fileName === ".gitignore" || fileName === ".gitattributes" || fileName === ".gitmodules") return "shell"
+
+ // Monaco doesn't ship a dedicated Makefile tokenizer in our baseline.
+ if (fileName === "Makefile" || fileName.startsWith("Makefile.")) return "shell"
+
+ return null
+}
+
+export function inferMonacoLanguageId(monaco: MonacoApi, path: string | undefined | null): string {
+ const raw = String(path || "").trim()
+ const fileName = raw.split("/").pop() || raw
+
+ const override = overrideLanguageId(fileName)
+ if (override) return override
+
+ const maps = buildLanguageMaps(monaco)
+ const byName = maps.fileNameToId.get(fileName)
+ if (byName) return byName
+
+ const dot = fileName.lastIndexOf(".")
+ if (dot > 0) {
+ const ext = fileName.slice(dot)
+ const byExt = maps.extToId.get(ext)
+ if (byExt) return byExt
+ }
+
+ return "plaintext"
+}
diff --git a/packages/ui/src/lib/monaco/model-cache.ts b/packages/ui/src/lib/monaco/model-cache.ts
new file mode 100644
index 00000000..f90d75fd
--- /dev/null
+++ b/packages/ui/src/lib/monaco/model-cache.ts
@@ -0,0 +1,53 @@
+type MonacoApi = any
+
+type CachedModel = {
+ model: any
+}
+
+const MAX_MODELS = 5
+
+// LRU map: newest at the end.
+const models = new Map()
+
+function touch(key: string, entry: CachedModel) {
+ models.delete(key)
+ models.set(key, entry)
+}
+
+function evictIfNeeded() {
+ while (models.size > MAX_MODELS) {
+ const oldestKey = models.keys().next().value as string | undefined
+ if (!oldestKey) return
+ const entry = models.get(oldestKey)
+ models.delete(oldestKey)
+ try {
+ entry?.model.dispose()
+ } catch {
+ // ignore
+ }
+ }
+}
+
+export function getOrCreateTextModel(params: {
+ monaco: MonacoApi
+ cacheKey: string
+ value: string
+ languageId: string
+}): any {
+ const existing = models.get(params.cacheKey)
+ if (existing) {
+ touch(params.cacheKey, existing)
+ if (existing.model.getValue() !== params.value) {
+ existing.model.setValue(params.value)
+ }
+ return existing.model
+ }
+
+ const uri = params.monaco.Uri.parse(`opencode://model/${encodeURIComponent(params.cacheKey)}`)
+ // Create as plaintext. We'll set the final language after its contribution is loaded.
+ const model = params.monaco.editor.createModel(params.value, "plaintext", uri)
+ const entry = { model }
+ models.set(params.cacheKey, entry)
+ evictIfNeeded()
+ return model
+}
diff --git a/packages/ui/src/lib/monaco/setup.ts b/packages/ui/src/lib/monaco/setup.ts
new file mode 100644
index 00000000..6411dfba
--- /dev/null
+++ b/packages/ui/src/lib/monaco/setup.ts
@@ -0,0 +1,180 @@
+type RequireFn = (deps: string[], callback: (...args: any[]) => void, errback?: (err: any) => void) => void
+type MonacoApi = any
+
+const MONACO_VERSION = "0.52.2"
+const CDN_VS_ROOT = `https://cdn.jsdelivr.net/npm/monaco-editor@${MONACO_VERSION}/min/vs`
+const LOCAL_VS_ROOT = "/monaco/vs"
+
+let monacoPromise: Promise | null = null
+
+function withTimeout(promise: Promise, ms: number): Promise {
+ return new Promise((resolve, reject) => {
+ const id = setTimeout(() => reject(new Error("timeout")), ms)
+ promise
+ .then((value) => {
+ clearTimeout(id)
+ resolve(value)
+ })
+ .catch((err) => {
+ clearTimeout(id)
+ reject(err)
+ })
+ })
+}
+
+async function canReachCdn(): Promise {
+ if (typeof fetch === "undefined") return false
+ try {
+ const controller = new AbortController()
+ const task = fetch(`${CDN_VS_ROOT}/loader.js`, { method: "HEAD", signal: controller.signal })
+ const response = await withTimeout(task, 1200)
+ controller.abort()
+ return response.ok
+ } catch {
+ return false
+ }
+}
+
+function ensureLoaderScript(): Promise {
+ if (typeof document === "undefined") return Promise.resolve()
+ const existing = document.querySelector('script[data-monaco-loader="true"]')
+ if (existing) return Promise.resolve()
+
+ return new Promise((resolve, reject) => {
+ const script = document.createElement("script")
+ script.dataset.monacoLoader = "true"
+ script.src = `${LOCAL_VS_ROOT}/loader.js`
+ script.async = true
+ script.onload = () => resolve()
+ script.onerror = () => reject(new Error("Failed to load Monaco AMD loader"))
+ document.head.appendChild(script)
+ })
+}
+
+function configureWorkers() {
+ const globalAny = globalThis as any
+ if (globalAny.MonacoEnvironment?.getWorkerUrl) return
+
+ globalAny.MonacoEnvironment = {
+ getWorkerUrl(_moduleId: string, label: string) {
+ if (label === "json") return `${LOCAL_VS_ROOT}/language/json/json.worker.js`
+ if (label === "css" || label === "scss" || label === "less") return `${LOCAL_VS_ROOT}/language/css/css.worker.js`
+ if (label === "html" || label === "handlebars" || label === "razor") return `${LOCAL_VS_ROOT}/language/html/html.worker.js`
+ if (label === "typescript" || label === "javascript") return `${LOCAL_VS_ROOT}/language/typescript/ts.worker.js`
+ return `${LOCAL_VS_ROOT}/editor/editor.worker.js`
+ },
+ }
+}
+
+function getRequire(): RequireFn {
+ const req = (globalThis as any).require as RequireFn | undefined
+ if (!req) throw new Error("Monaco AMD loader is not available")
+ return req
+}
+
+function getRequireConfig(): ((config: any) => void) {
+ const req = getRequire() as any
+ const cfg = req.config as ((config: any) => void) | undefined
+ if (!cfg) throw new Error("require.config is not available")
+ return cfg
+}
+
+function requireAsync(deps: string[]): Promise {
+ const req = getRequire()
+ return new Promise((resolve, reject) => {
+ req(deps, (...args: any[]) => resolve(args), (err: any) => reject(err))
+ })
+}
+
+function getContributionModuleId(languageId: string): string | null {
+ const id = String(languageId || "plaintext")
+ if (!id || id === "plaintext") return null
+
+ // Rich contributions
+ if (id === "typescript" || id === "javascript") return "vs/language/typescript/monaco.contribution"
+ if (id === "json") return "vs/language/json/monaco.contribution"
+ if (id === "css" || id === "scss" || id === "less") return "vs/language/css/monaco.contribution"
+ if (id === "html") return "vs/language/html/monaco.contribution"
+
+ // Basic tokenizers
+ if (id === "toml") return "vs/basic-languages/toml/toml.contribution"
+ return `vs/basic-languages/${id}/${id}.contribution`
+}
+
+const loadedContributions = new Set()
+const pendingContributions = new Map>()
+
+export async function ensureMonacoLanguageLoaded(languageId: string): Promise {
+ const moduleId = getContributionModuleId(languageId)
+ if (!moduleId) return
+
+ if (loadedContributions.has(moduleId)) return
+ const pending = pendingContributions.get(moduleId)
+ if (pending) return pending
+
+ const task = (async () => {
+ try {
+ await requireAsync([moduleId])
+ } catch {
+ // ignore
+ } finally {
+ loadedContributions.add(moduleId)
+ pendingContributions.delete(moduleId)
+ }
+ })()
+
+ pendingContributions.set(moduleId, task)
+ return task
+}
+
+export async function loadMonaco(): Promise {
+ if (monacoPromise) return monacoPromise
+
+ monacoPromise = (async () => {
+ await ensureLoaderScript()
+ configureWorkers()
+
+ const online = await canReachCdn()
+ const requireConfig = getRequireConfig()
+
+ const paths: Record = {
+ vs: LOCAL_VS_ROOT,
+ }
+
+ if (online) {
+ paths["vs/basic-languages"] = `${CDN_VS_ROOT}/basic-languages`
+ paths["vs/language"] = `${CDN_VS_ROOT}/language`
+
+ // Keep Monaco's language metadata available offline.
+ paths["vs/basic-languages/monaco.contribution"] = `${LOCAL_VS_ROOT}/basic-languages/monaco.contribution`
+ paths["vs/basic-languages/_.contribution"] = `${LOCAL_VS_ROOT}/basic-languages/_.contribution`
+
+ // Baseline languages should remain available offline too.
+ paths["vs/basic-languages/python"] = `${LOCAL_VS_ROOT}/basic-languages/python`
+ paths["vs/basic-languages/markdown"] = `${LOCAL_VS_ROOT}/basic-languages/markdown`
+ paths["vs/basic-languages/cpp"] = `${LOCAL_VS_ROOT}/basic-languages/cpp`
+ paths["vs/basic-languages/kotlin"] = `${LOCAL_VS_ROOT}/basic-languages/kotlin`
+
+ paths["vs/language/typescript"] = `${LOCAL_VS_ROOT}/language/typescript`
+ paths["vs/language/html"] = `${LOCAL_VS_ROOT}/language/html`
+ paths["vs/language/json"] = `${LOCAL_VS_ROOT}/language/json`
+ paths["vs/language/css"] = `${LOCAL_VS_ROOT}/language/css`
+ }
+
+ requireConfig({
+ paths,
+ ignoreDuplicateModules: ["vs/editor/editor.main"],
+ })
+
+ // Load editor core.
+ const [monaco] = await requireAsync(["vs/editor/editor.main"])
+
+ // Load language metadata so we can infer language IDs from paths.
+ // (This is small and should remain local for offline support.)
+ await requireAsync(["vs/basic-languages/monaco.contribution", "vs/basic-languages/_.contribution"]).catch(() => [])
+
+ return (globalThis as any).monaco ?? monaco
+ })()
+
+ return monacoPromise
+}
diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css
index c51a1ff5..391b6738 100644
--- a/packages/ui/src/styles/panels/right-panel.css
+++ b/packages/ui/src/styles/panels/right-panel.css
@@ -184,6 +184,26 @@
background-color: var(--surface-secondary);
}
+.file-viewer-toolbar {
+ @apply ml-auto flex items-center gap-1;
+}
+
+.file-viewer-toolbar-button {
+ @apply text-[11px] px-2 py-1 rounded border border-base transition-colors;
+ background-color: var(--surface-base);
+ color: var(--text-secondary);
+}
+
+.file-viewer-toolbar-button:hover {
+ background-color: var(--surface-hover);
+ color: var(--text-primary);
+}
+
+.file-viewer-toolbar-button.active {
+ color: var(--text-primary);
+ box-shadow: inset 0 0 0 1px var(--accent-primary);
+}
+
.file-viewer-title {
@apply text-[11px] font-semibold uppercase tracking-wide;
color: var(--text-muted);
@@ -193,6 +213,16 @@
@apply flex-1 p-4 overflow-auto min-h-0;
}
+.file-viewer-content--monaco {
+ @apply flex-1 overflow-hidden min-h-0;
+ padding: 0;
+}
+
+.monaco-viewer {
+ width: 100%;
+ height: 100%;
+}
+
.file-viewer-empty {
@apply flex flex-col items-center justify-center h-full gap-3 text-center;
color: var(--text-muted);
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
index 6e231b07..b0e1150c 100644
--- a/packages/ui/vite.config.ts
+++ b/packages/ui/vite.config.ts
@@ -11,6 +11,81 @@ export default defineConfig({
root: "./src/renderer",
plugins: [
solid(),
+ {
+ name: "prepare-monaco-public-assets",
+ buildStart() {
+ const publicDir = resolve(__dirname, "src/renderer/public")
+ const destRoot = resolve(publicDir, "monaco/vs")
+
+ const candidates = [
+ resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
+ resolve(__dirname, "node_modules/monaco-editor/min/vs"),
+ ]
+ const sourceRoot = candidates.find((p) => fs.existsSync(resolve(p, "loader.js")))
+ if (!sourceRoot) {
+ this.warn("Monaco source directory not found; skipping copy")
+ return
+ }
+
+ fs.mkdirSync(destRoot, { recursive: true })
+
+ const copyRecursive = (src: string, dest: string) => {
+ const stat = fs.statSync(src)
+ if (stat.isDirectory()) {
+ fs.mkdirSync(dest, { recursive: true })
+ for (const entry of fs.readdirSync(src)) {
+ copyRecursive(resolve(src, entry), resolve(dest, entry))
+ }
+ return
+ }
+ fs.copyFileSync(src, dest)
+ }
+
+ // Keep the working tree clean; these assets are generated.
+ try {
+ fs.rmSync(destRoot, { recursive: true, force: true })
+ } catch {
+ // ignore
+ }
+ fs.mkdirSync(destRoot, { recursive: true })
+
+ // Copy core Monaco runtime.
+ for (const dir of ["base", "editor", "platform"] as const) {
+ const src = resolve(sourceRoot, dir)
+ if (fs.existsSync(src)) {
+ copyRecursive(src, resolve(destRoot, dir))
+ }
+ }
+ // loader.js is required.
+ copyRecursive(resolve(sourceRoot, "loader.js"), resolve(destRoot, "loader.js"))
+
+ // Copy baseline rich language packages + workers.
+ for (const lang of ["typescript", "html", "json", "css"] as const) {
+ const src = resolve(sourceRoot, "language", lang)
+ if (fs.existsSync(src)) {
+ copyRecursive(src, resolve(destRoot, "language", lang))
+ }
+ }
+
+ // Copy baseline basic tokenizers.
+ for (const lang of ["python", "markdown", "cpp", "kotlin"] as const) {
+ const src = resolve(sourceRoot, "basic-languages", lang)
+ if (fs.existsSync(src)) {
+ copyRecursive(src, resolve(destRoot, "basic-languages", lang))
+ }
+ }
+
+ // Copy monaco.contribution.js entrypoints (needed by some loads).
+ const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js")
+ if (fs.existsSync(monacoContribution)) {
+ copyRecursive(monacoContribution, resolve(destRoot, "basic-languages", "monaco.contribution.js"))
+ }
+ const underscoreContribution = resolve(sourceRoot, "basic-languages", "_.contribution.js")
+ if (fs.existsSync(underscoreContribution)) {
+ copyRecursive(underscoreContribution, resolve(destRoot, "basic-languages", "_.contribution.js"))
+ }
+ },
+ },
{
name: "emit-ui-version",
generateBundle() {
@@ -51,14 +126,20 @@ export default defineConfig({
theme_color: "#1a1a1a",
},
workbox: {
- // Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
- navigateFallback: null,
- // Only precache static assets (avoid caching HTML documents / routes).
- globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
- globIgnores: ["**/*.html"],
- // Only cache static UI assets; never cache API traffic.
- runtimeCaching: [
- {
+ // Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
+ navigateFallback: null,
+ // Only precache static assets (avoid caching HTML documents / routes).
+ globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
+ // Monaco assets can be large; cache them at runtime instead.
+ globIgnores: [
+ "**/*.html",
+ "**/assets/*worker-*.js",
+ "**/assets/editor.api-*.js",
+ "**/monaco/vs/**/*",
+ ],
+ // Only cache static UI assets; never cache API traffic.
+ runtimeCaching: [
+ {
urlPattern: ({ url, request }) => {
if (url.pathname.startsWith("/api/")) return false
if (request.destination === "document") return false