From b59e85abda9c45094a3a73eaf4eeff0b1ac253ab Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 21:00:40 +0000 Subject: [PATCH] feat(ui): add Monaco changes/files right drawer viewers Use OpenCode v2 file APIs for browsing and Monaco DiffEditor for session snapshot diffs, with local baseline language metadata and optional CDN language loading. --- package-lock.json | 7 + packages/ui/.gitignore | 1 + packages/ui/package.json | 1 + .../file-viewer/monaco-diff-viewer.tsx | 110 +++++ .../file-viewer/monaco-file-viewer.tsx | 89 ++++ .../components/instance/instance-shell2.tsx | 421 +++++++++++++++++- .../ui/src/lib/i18n/messages/en/instance.ts | 1 + .../ui/src/lib/i18n/messages/es/instance.ts | 1 + .../ui/src/lib/i18n/messages/fr/instance.ts | 1 + .../ui/src/lib/i18n/messages/ja/instance.ts | 1 + .../ui/src/lib/i18n/messages/ru/instance.ts | 1 + .../src/lib/i18n/messages/zh-Hans/instance.ts | 1 + packages/ui/src/lib/monaco/language.ts | 70 +++ packages/ui/src/lib/monaco/model-cache.ts | 53 +++ packages/ui/src/lib/monaco/setup.ts | 180 ++++++++ packages/ui/src/styles/panels/right-panel.css | 30 ++ packages/ui/vite.config.ts | 97 +++- 17 files changed, 1035 insertions(+), 30 deletions(-) create mode 100644 packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx create mode 100644 packages/ui/src/components/file-viewer/monaco-file-viewer.tsx create mode 100644 packages/ui/src/lib/monaco/language.ts create mode 100644 packages/ui/src/lib/monaco/model-cache.ts create mode 100644 packages/ui/src/lib/monaco/setup.ts 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) => ( + + )} + +
-
+ +
{t("instanceShell.filesShell.viewerTitle")} +
+ + + + +
-
+
= (props) => { } > {(file) => ( -
- {file().file} -

{t("instanceShell.filesShell.viewerPlaceholder")}

-
+ + Binary file cannot be displayed +
+ } + > + + )}
@@ -1123,8 +1309,44 @@ const InstanceShell2: Component = (props) => {
{t("instanceShell.filesShell.viewerTitle")} +
+ + + + +
-
+
= (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 ( +
+
+
+ + {browserPath()} + + + Loading… + + + {(err) => {err()}} + +
+
+ +
+
+
+ Files + {sorted.length} +
+
+ + {(p) => ( +
void loadBrowserEntries(p())}> +
+
+ .. +
+
)}
+ + + {(item) => ( +
{ + if (item.type === "directory") { + void loadBrowserEntries(item.path) + return + } + void openBrowserFile(item.path) + }} + title={item.path} + > +
+
+ {item.name} +
+
+ {item.type} +
+
+
+ )} +
+
+
+ +
+
+ Viewer +
+
+ + 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