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.
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
1
packages/ui/.gitignore
vendored
1
packages/ui/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
src/renderer/public/logo.png
|
||||
src/renderer/public/monaco/
|
||||
|
||||
@@ -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",
|
||||
|
||||
110
packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
Normal file
110
packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
Normal file
@@ -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 <div class="monaco-viewer" ref={host} />
|
||||
}
|
||||
@@ -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 <div class="monaco-viewer" ref={host} />
|
||||
}
|
||||
@@ -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<InstanceShellProps> = (props) => {
|
||||
@@ -164,6 +171,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
"plugins",
|
||||
])
|
||||
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
|
||||
|
||||
const [browserPath, setBrowserPath] = createSignal(".")
|
||||
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
|
||||
const [browserLoading, setBrowserLoading] = createSignal(false)
|
||||
const [browserError, setBrowserError] = createSignal<string | null>(null)
|
||||
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
|
||||
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||
|
||||
const [diffViewMode, setDiffViewMode] = createSignal<"split" | "unified">("split")
|
||||
const [diffContextMode, setDiffContextMode] = createSignal<"expanded" | "collapsed">("collapsed")
|
||||
|
||||
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||
@@ -993,6 +1013,93 @@ const InstanceShell2: Component<InstanceShellProps> = (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<FileNode[]>(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<FileContent>(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<InstanceShellProps> = (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 (
|
||||
<div class="files-tab-container">
|
||||
<div class="mobile-file-selector">
|
||||
<span class="mobile-file-selector-label">{t("instanceShell.filesShell.mobileSelectorLabel")}</span>
|
||||
<button type="button" class="selector-trigger mobile-file-selector-trigger" disabled>
|
||||
<span class="selector-trigger-label selector-trigger-primary selector-trigger-primary--align-left truncate">
|
||||
{selectedFileData?.file || t("instanceShell.filesShell.mobileSelectorEmpty")}
|
||||
</span>
|
||||
<span class="selector-trigger-icon" aria-hidden="true">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</span>
|
||||
</button>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-2 max-h-[32vh] overflow-y-auto">
|
||||
<div class="flex flex-col">
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm ${selectedFileData?.file === item.file ? "bg-surface-base" : ""}`}
|
||||
onClick={() => setSelectedFile(item.file)}
|
||||
title={item.file}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div
|
||||
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
|
||||
title={item.file}
|
||||
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
|
||||
>
|
||||
{item.file}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
|
||||
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
|
||||
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-file-viewer">
|
||||
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-header">
|
||||
<span class="file-viewer-title">{t("instanceShell.filesShell.viewerTitle")}</span>
|
||||
<div class="file-viewer-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${diffViewMode() === "split" ? " active" : ""}`}
|
||||
aria-pressed={diffViewMode() === "split"}
|
||||
onClick={() => setDiffViewMode("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${diffViewMode() === "unified" ? " active" : ""}`}
|
||||
aria-pressed={diffViewMode() === "unified"}
|
||||
onClick={() => setDiffViewMode("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${diffContextMode() === "collapsed" ? " active" : ""}`}
|
||||
aria-pressed={diffContextMode() === "collapsed"}
|
||||
onClick={() => setDiffContextMode("collapsed")}
|
||||
title="Hide unchanged regions"
|
||||
>
|
||||
Collapsed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${diffContextMode() === "expanded" ? " active" : ""}`}
|
||||
aria-pressed={diffContextMode() === "expanded"}
|
||||
onClick={() => setDiffContextMode("expanded")}
|
||||
title="Show full file"
|
||||
>
|
||||
Expanded
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-viewer-content">
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={selectedFileData}
|
||||
fallback={
|
||||
@@ -1062,10 +1235,23 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<div class="file-viewer-selected-file">
|
||||
<span class="file-viewer-file-name">{file().file}</span>
|
||||
<p class="file-viewer-placeholder">{t("instanceShell.filesShell.viewerPlaceholder")}</p>
|
||||
</div>
|
||||
<Show
|
||||
when={!isBinaryDiff(file())}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Binary file cannot be displayed</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MonacoDiffViewer
|
||||
scopeKey={scopeKey}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={diffViewMode()}
|
||||
contextMode={diffContextMode()}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -1123,8 +1309,44 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-header">
|
||||
<span class="file-viewer-title">{t("instanceShell.filesShell.viewerTitle")}</span>
|
||||
<div class="file-viewer-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${diffViewMode() === "split" ? " active" : ""}`}
|
||||
aria-pressed={diffViewMode() === "split"}
|
||||
onClick={() => setDiffViewMode("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${diffViewMode() === "unified" ? " active" : ""}`}
|
||||
aria-pressed={diffViewMode() === "unified"}
|
||||
onClick={() => setDiffViewMode("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${diffContextMode() === "collapsed" ? " active" : ""}`}
|
||||
aria-pressed={diffContextMode() === "collapsed"}
|
||||
onClick={() => setDiffContextMode("collapsed")}
|
||||
title="Hide unchanged regions"
|
||||
>
|
||||
Collapsed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${diffContextMode() === "expanded" ? " active" : ""}`}
|
||||
aria-pressed={diffContextMode() === "expanded"}
|
||||
onClick={() => setDiffContextMode("expanded")}
|
||||
title="Show full file"
|
||||
>
|
||||
Expanded
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-viewer-content">
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={selectedFileData}
|
||||
fallback={
|
||||
@@ -1134,12 +1356,157 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<div class="file-viewer-selected-file">
|
||||
<span class="file-viewer-file-name">{file().file}</span>
|
||||
<p class="file-viewer-placeholder">{t("instanceShell.filesShell.viewerPlaceholder")}</p>
|
||||
<Show
|
||||
when={!isBinaryDiff(file())}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Binary file cannot be displayed</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MonacoDiffViewer
|
||||
scopeKey={scopeKey}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={diffViewMode()}
|
||||
contextMode={diffContextMode()}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderBrowserTabContent = () => {
|
||||
if (browserLoading() && browserEntries() === null) {
|
||||
return (
|
||||
<div class="right-panel-empty">
|
||||
<span class="text-xs">Loading files...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div class="files-tab-container">
|
||||
<div class="files-tab-header">
|
||||
<div class="files-tab-stats">
|
||||
<span class="files-tab-stat">
|
||||
<span class="files-tab-stat-value">{browserPath()}</span>
|
||||
</span>
|
||||
<Show when={browserLoading()}>
|
||||
<span>Loading…</span>
|
||||
</Show>
|
||||
<Show when={browserError()}>
|
||||
{(err) => <span class="text-error">{err()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 gap-3 flex-1">
|
||||
<div class="file-list-panel">
|
||||
<div class="file-list-header">
|
||||
<span class="file-list-title">Files</span>
|
||||
<span class="file-list-count">{sorted.length}</span>
|
||||
</div>
|
||||
<div class="file-list-scroll">
|
||||
<Show when={parent}>
|
||||
{(p) => (
|
||||
<div class="file-list-item" onClick={() => void loadBrowserEntries(p())}>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={p()}>
|
||||
..
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
if (item.type === "directory") {
|
||||
void loadBrowserEntries(item.path)
|
||||
return
|
||||
}
|
||||
void openBrowserFile(item.path)
|
||||
}}
|
||||
title={item.path}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
{item.name}
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<span class="text-[10px] text-secondary">{item.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-header">
|
||||
<span class="file-viewer-title">Viewer</span>
|
||||
</div>
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={browserSelectedLoading()}
|
||||
fallback={
|
||||
<Show
|
||||
when={browserSelectedError()}
|
||||
fallback={
|
||||
<Show
|
||||
when={browserSelectedPath() && browserSelectedContent() !== null
|
||||
? { path: browserSelectedPath() as string, content: browserSelectedContent() as string }
|
||||
: null}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Select a file to preview</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(payload) => (
|
||||
<MonacoFileViewer
|
||||
scopeKey={scopeKey}
|
||||
path={payload().path}
|
||||
content={payload().content}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(err) => (
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{err()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Loading…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1470,6 +1837,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
>
|
||||
<span class="tab-label">{t("instanceShell.rightPanel.tabs.changes")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass("browser")}
|
||||
aria-selected={rightPanelTab() === "browser"}
|
||||
onClick={() => setRightPanelTab("browser")}
|
||||
>
|
||||
<span class="tab-label">{t("instanceShell.rightPanel.tabs.files")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -1489,6 +1865,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<Show when={rightPanelTab() === "files"}>{renderFilesTabContent()}</Show>
|
||||
<Show when={rightPanelTab() === "browser"}>{renderBrowserTabContent()}</Show>
|
||||
<Show when={rightPanelTab() === "status"}>{renderStatusTabContent()}</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "セッション変更",
|
||||
|
||||
@@ -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": "Изменения сессии",
|
||||
|
||||
@@ -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": "会话更改",
|
||||
|
||||
70
packages/ui/src/lib/monaco/language.ts
Normal file
70
packages/ui/src/lib/monaco/language.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
type MonacoApi = any
|
||||
|
||||
let cachedLanguageMaps:
|
||||
| {
|
||||
fileNameToId: Map<string, string>
|
||||
extToId: Map<string, string>
|
||||
}
|
||||
| null = null
|
||||
|
||||
function buildLanguageMaps(monaco: MonacoApi) {
|
||||
if (cachedLanguageMaps) return cachedLanguageMaps
|
||||
|
||||
const fileNameToId = new Map<string, string>()
|
||||
const extToId = new Map<string, string>()
|
||||
|
||||
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"
|
||||
}
|
||||
53
packages/ui/src/lib/monaco/model-cache.ts
Normal file
53
packages/ui/src/lib/monaco/model-cache.ts
Normal file
@@ -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<string, CachedModel>()
|
||||
|
||||
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
|
||||
}
|
||||
180
packages/ui/src/lib/monaco/setup.ts
Normal file
180
packages/ui/src/lib/monaco/setup.ts
Normal file
@@ -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<MonacoApi> | null = null
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<any[]> {
|
||||
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<string>()
|
||||
const pendingContributions = new Map<string, Promise<void>>()
|
||||
|
||||
export async function ensureMonacoLanguageLoaded(languageId: string): Promise<void> {
|
||||
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<MonacoApi> {
|
||||
if (monacoPromise) return monacoPromise
|
||||
|
||||
monacoPromise = (async () => {
|
||||
await ensureLoaderScript()
|
||||
configureWorkers()
|
||||
|
||||
const online = await canReachCdn()
|
||||
const requireConfig = getRequireConfig()
|
||||
|
||||
const paths: Record<string, string> = {
|
||||
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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user