From e264bd078ad0d29dd6fadf290f2e11fced2b5c0f Mon Sep 17 00:00:00 2001 From: laoxi <513608613@qq.com> Date: Mon, 1 Jun 2026 22:44:51 +0800 Subject: [PATCH 1/2] feat: add Monaco Editor for in-browser code editing Add Monaco Editor as an overlay to the existing Pierre read-only code viewer, enabling direct code editing from the web UI with Ctrl+S save. Changes: - Add PUT /file/content API endpoint for writing files - Add File.write() method to the file service - Add SDK file.write() client method - Create MonacoEditor SolidJS wrapper component with: - Syntax highlighting for 80+ languages - Ctrl+S / Cmd+S save shortcut - Dark/light theme sync - Bracket pair colorization - Add Edit button to file viewer that opens Monaco overlay - Wire save through file context to backend API Co-Authored-By: Claude --- packages/app/package.json | 1 + packages/app/src/context/file.tsx | 11 ++ packages/app/src/pages/session/file-tabs.tsx | 5 + packages/app/vite.js | 8 + packages/opencode/src/file/index.ts | 14 +- .../routes/instance/httpapi/groups/file.ts | 17 ++ .../routes/instance/httpapi/handlers/file.ts | 14 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 41 +++++ packages/ui/package.json | 3 +- packages/ui/src/components/file.tsx | 58 +++++- packages/ui/src/components/monaco-editor.tsx | 169 ++++++++++++++++++ packages/ui/src/utils/language-map.ts | 112 ++++++++++++ 12 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/components/monaco-editor.tsx create mode 100644 packages/ui/src/utils/language-map.ts diff --git a/packages/app/package.json b/packages/app/package.json index 239eacdd1ba8..d67a834f5de8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -70,6 +70,7 @@ "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", + "monaco-editor": "^0.52.0", "remeda": "catalog:", "shiki": "catalog:", "solid-js": "catalog:", diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 0298e3416afd..f8b07cd94632 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -245,6 +245,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ viewCache.clear() }) + const save = (file: string, content: string) => { + const normalized = path.normalize(file) + return sdk.client.file + .write({ path: normalized, content }) + .then(() => { + // Reload the file to reflect changes + void load(normalized, { force: true }) + }) + } + return { ready: () => view().ready(), normalize: path.normalize, @@ -267,6 +277,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ }, get, load, + save, scrollTop, scrollLeft, setScrollTop, diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 65b076d7c630..2938a4a5d23d 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -404,6 +404,11 @@ export function FileTabContent(props: { tab: string }) { contents: source, cacheKey: cacheKey(), }} + filePath={path()} + onSave={(content: string) => { + const p = path() + if (p) file.save(p, content) + }} enableLineSelection enableHoverUtility selectedLines={activeSelection()} diff --git a/packages/app/vite.js b/packages/app/vite.js index e1f851653c62..6e5c1efa2279 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -30,6 +30,14 @@ export default [ }, worker: { format: "es", + rollupOptions: { + output: { + entryFileNames: "assets/[name]-[hash].js", + }, + }, + }, + optimizeDeps: { + include: ["monaco-editor"], }, } }, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index c4aa2e1cfba5..ad3a11b62f0d 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -316,6 +316,7 @@ export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect readonly read: (file: string) => Effect.Effect + readonly write: (file: string, content: string) => Effect.Effect readonly list: (dir?: string) => Effect.Effect readonly search: (input: { query: string @@ -640,8 +641,19 @@ export const layer = Layer.effect( return output }) + const write: Interface["write"] = Effect.fn("File.write")(function* (file: string, content: string) { + const ctx = yield* InstanceState.context + const full = path.join(ctx.directory, file) + + if (!containsPath(full, ctx)) { + throw new Error("Access denied: path escapes project directory") + } + + yield* appFs.writeWithDirs(full, content) + }) + log.info("init") - return Service.of({ init, status, read, list, search }) + return Service.of({ init, status, read, write, list, search }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index c636e583d7b9..78d84e506dde 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -11,12 +11,19 @@ import { WorkspaceRoutingQueryFields, } from "../middleware/workspace-routing" import { described } from "./metadata" +import { FileWatcher } from "@/file/watcher" +import { EventV2Bridge } from "@/event-v2-bridge" export const FileQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, path: Schema.String, }) +export const FileWriteBody = Schema.Struct({ + path: Schema.String, + content: Schema.String, +}) + export const FindTextQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, pattern: Schema.String, @@ -110,6 +117,16 @@ export const FileApi = HttpApi.make("file") description: "Get the git status of all files in the project.", }), ), + HttpApiEndpoint.put("write", "/file/content", { + payload: FileWriteBody, + success: described(Schema.Struct({ success: Schema.Boolean }), "Write result"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.write", + summary: "Write file", + description: "Write content to a specified file.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 98ee5968e0cd..42346a8cebc4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,6 +1,8 @@ import * as InstanceState from "@/effect/instance-state" import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" +import { FileWatcher } from "@/file/watcher" +import { EventV2Bridge } from "@/event-v2-bridge" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" @@ -9,6 +11,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl Effect.gen(function* () { const svc = yield* File.Service const ripgrep = yield* Ripgrep.Service + const events = yield* EventV2Bridge.Service const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { return (yield* ripgrep @@ -43,6 +46,16 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl return yield* svc.status() }) + const write = Effect.fn("FileHttpApi.write")(function* (ctx: { payload: { path: string; content: string } }) { + yield* svc.write(ctx.payload.path, ctx.payload.content) + yield* events.publish(File.Event.Edited, { file: ctx.payload.path }) + yield* events.publish(FileWatcher.Event.Updated, { + file: ctx.payload.path, + event: "change", + }) + return { success: true } + }) + return handlers .handle("findText", findText) .handle("findFile", findFile) @@ -50,5 +63,6 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("list", list) .handle("content", content) .handle("status", status) + .handle("write", write) }), ) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index be1e4abc65fa..ffa430257709 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1624,6 +1624,47 @@ export class File extends HeyApiClient { ...params, }) } + + /** + * Write file + * + * Write content to a specified file. + */ + public write( + parameters: { + directory?: string + workspace?: string + path: string + content: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put< + { success: boolean }, + never, + ThrowOnError + >({ + url: "/file/content", + ...options, + ...params, + body: { path: parameters.path, content: parameters.content }, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } } export class Instance extends HeyApiClient { diff --git a/packages/ui/package.json b/packages/ui/package.json index 8f2275ce51bb..c08cfd50f305 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -75,6 +75,7 @@ "solid-js": "catalog:", "solid-list": "catalog:", "strip-ansi": "7.1.2", - "virtua": "catalog:" + "virtua": "catalog:", + "monaco-editor": "^0.52.0" } } diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index bfcc05b40acb..a8d8b2644013 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -46,6 +46,7 @@ import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { getWorkerPool } from "../pierre/worker" import { FileMedia, type FileMediaOptions } from "./file-media" import { FileSearchBar } from "./file-search" +import { MonacoEditor } from "./monaco-editor" const VIRTUALIZE_BYTES = 500_000 @@ -81,6 +82,8 @@ export type TextFileProps = FileOptions & file: FileContents annotations?: LineAnnotation[] preloadedDiff?: PreloadMultiFileDiffResult + filePath?: string + onSave?: (content: string) => void } type DiffPreload = PreloadMultiFileDiffResult | PreloadFileDiffResult @@ -911,7 +914,60 @@ function TextViewer(props: TextFileProps) { virtuals.cleanup() }) - return + // Monaco edit overlay + const [editing, setEditing] = createSignal(false) + const editContent = () => { + const value = local.file.contents as unknown + if (typeof value === "string") return value + if (Array.isArray(value)) return value.join("\n") + if (value == null) return "" + return String(value) + } + + return ( +
+ + {props.filePath && props.onSave && ( + + )} + {editing() && props.filePath && props.onSave && ( +
+ { + props.onSave!(content) + setEditing(false) + }} + /> +
+ )} +
+ ) } // --------------------------------------------------------------------------- diff --git a/packages/ui/src/components/monaco-editor.tsx b/packages/ui/src/components/monaco-editor.tsx new file mode 100644 index 000000000000..7fa69c0cc350 --- /dev/null +++ b/packages/ui/src/components/monaco-editor.tsx @@ -0,0 +1,169 @@ +import { onMount, onCleanup, createSignal, Show, type VoidComponent } from "solid-js" +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker" +import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker" +import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker" +import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker" +import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker" +import { getLanguageForFile } from "../utils/language-map" + +// @ts-ignore +self.MonacoEnvironment = { + getWorker(_: string, label: string) { + if (label === "json") return new jsonWorker() + if (label === "css" || label === "scss" || label === "less") return new cssWorker() + if (label === "html" || label === "handlebars" || label === "razor") return new htmlWorker() + if (label === "typescript" || label === "javascript") return new tsWorker() + return new editorWorker() + }, +} + +let monaco: typeof import("monaco-editor") | undefined + +async function getMonaco() { + if (!monaco) { + monaco = await import("monaco-editor") + } + return monaco +} + +export interface MonacoEditorProps { + filePath: string + content: string + onSave?: (content: string) => void + readOnly?: boolean + class?: string +} + +export const MonacoEditor: VoidComponent = (props) => { + let containerRef!: HTMLDivElement + let editor: import("monaco-editor").editor.IStandaloneCodeEditor | undefined + const [loaded, setLoaded] = createSignal(false) + + const isDark = () => + typeof document !== "undefined" && + document.documentElement.getAttribute("data-color-scheme") === "dark" + + const themeName = () => (isDark() ? "opencode-dark" : "opencode-light") + + onMount(async () => { + const m = await getMonaco() + const language = getLanguageForFile(props.filePath) + + m.editor.defineTheme("opencode-dark", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#0a0a0a", + "editor.foreground": "#e4e4e7", + "editor.lineHighlightBackground": "#1a1a1a", + "editorCursor.foreground": "#e4e4e7", + "editor.selectionBackground": "#27272a", + "editor.inactiveSelectionBackground": "#1a1a1a", + }, + }) + + m.editor.defineTheme("opencode-light", { + base: "vs", + inherit: true, + rules: [], + colors: { + "editor.background": "#ffffff", + "editor.foreground": "#1a1a1a", + "editor.lineHighlightBackground": "#f5f5f5", + "editorCursor.foreground": "#1a1a1a", + "editor.selectionBackground": "#d4d4d8", + "editor.inactiveSelectionBackground": "#e4e4e7", + }, + }) + + editor = m.editor.create(containerRef, { + value: props.content, + language, + theme: themeName(), + readOnly: props.readOnly ?? false, + automaticLayout: true, + minimap: { enabled: false }, + fontSize: 13, + fontFamily: "var(--font-family-mono, 'Menlo', 'Monaco', 'Courier New', monospace)", + lineHeight: 24, + padding: { top: 8, bottom: 8 }, + scrollBeyondLastLine: false, + renderWhitespace: "selection", + bracketPairColorization: { enabled: true }, + guides: { bracketPairs: true }, + smoothScrolling: true, + cursorSmoothCaretAnimation: "on", + wordWrap: "off", + tabSize: 2, + insertSpaces: true, + folding: true, + links: true, + colorDecorators: true, + contextmenu: true, + scrollbar: { + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + useShadows: false, + }, + }) + + // Ctrl+S / Cmd+S save + editor.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.KeyS, () => { + if (props.onSave && !props.readOnly) { + props.onSave(editor!.getValue()) + } + }) + + // Theme observer + const observer = new MutationObserver(() => { + m.editor.setTheme(themeName()) + }) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-color-scheme"], + }) + + setLoaded(true) + + onCleanup(() => { + observer.disconnect() + editor?.dispose() + editor = undefined + }) + }) + + return ( +
+ +
+ Loading editor... +
+
+
+
+ ) +} diff --git a/packages/ui/src/utils/language-map.ts b/packages/ui/src/utils/language-map.ts new file mode 100644 index 000000000000..e7d422a97090 --- /dev/null +++ b/packages/ui/src/utils/language-map.ts @@ -0,0 +1,112 @@ +const EXT_TO_LANGUAGE: Record = { + ts: "typescript", + tsx: "typescriptreact", + mts: "typescript", + cts: "typescript", + js: "javascript", + jsx: "javascriptreact", + mjs: "javascript", + cjs: "javascript", + json: "json", + jsonc: "jsonc", + json5: "json5", + py: "python", + rb: "ruby", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + kts: "kotlin", + c: "c", + h: "c", + cpp: "cpp", + cxx: "cpp", + cc: "cpp", + hpp: "cpp", + cs: "csharp", + swift: "swift", + dart: "dart", + php: "php", + lua: "lua", + r: "r", + R: "r", + julia: "julia", + ex: "elixir", + exs: "elixir", + erl: "erlang", + hs: "haskell", + scala: "scala", + clj: "clojure", + sh: "shell", + bash: "shell", + zsh: "shell", + fish: "shell", + ps1: "powershell", + psm1: "powershell", + cmd: "bat", + bat: "bat", + yaml: "yaml", + yml: "yaml", + toml: "toml", + xml: "xml", + html: "html", + htm: "html", + css: "css", + scss: "scss", + sass: "scss", + less: "less", + md: "markdown", + mdx: "markdown", + graphql: "graphql", + gql: "graphql", + sql: "sql", + ini: "ini", + cfg: "ini", + conf: "ini", + env: "dotenv", + txt: "plaintext", + dockerfile: "dockerfile", + makefile: "makefile", + vue: "vue", + svelte: "svelte", + astro: "astro", + zig: "zig", + nim: "nim", + v: "v", + ml: "ocaml", + mli: "ocaml", + fs: "fsharp", + fsx: "fsharp", + fsproj: "xml", + proto: "protobuf", + tf: "hcl", + hcl: "hcl", + nix: "nix", +} + +const NAME_TO_LANGUAGE: Record = { + dockerfile: "dockerfile", + makefile: "makefile", + ".gitignore": "ignore", + ".gitattributes": "ignore", + ".editorconfig": "ini", + ".npmrc": "ini", + ".nvmrc": "plaintext", + ".prettierrc": "json", + ".eslintrc": "json", + "tsconfig.json": "jsonc", + "package.json": "json", +} + +export function getLanguageForFile(filePath: string): string { + const name = filePath.split("/").pop() ?? "" + const nameLower = name.toLowerCase() + + if (NAME_TO_LANGUAGE[nameLower]) return NAME_TO_LANGUAGE[nameLower] + + const dotIndex = name.lastIndexOf(".") + if (dotIndex === -1) return "plaintext" + + const ext = name.slice(dotIndex + 1).toLowerCase() + return EXT_TO_LANGUAGE[ext] ?? "plaintext" +} From 0ea95474f89727f3e1c044e9ee88864ceb335b96 Mon Sep 17 00:00:00 2001 From: laoxi <513608613@qq.com> Date: Mon, 1 Jun 2026 23:02:12 +0800 Subject: [PATCH 2/2] fix: address Copilot review comments - Add WorkspaceRoutingQueryFields to write endpoint - Fix worker routing for typescriptreact/javascriptreact - Add SSR guard for self.MonacoEnvironment - Lazy-load Monaco workers via dynamic imports - Fix language map: remove unreachable R key, fix sass mapping - Add Close button and ESC handler to Monaco edit overlay Co-Authored-By: Claude --- .../routes/instance/httpapi/groups/file.ts | 5 +++ .../routes/instance/httpapi/handlers/file.ts | 5 ++- packages/ui/src/components/file.tsx | 21 +++++++++ packages/ui/src/components/monaco-editor.tsx | 43 +++++++++++++------ packages/ui/src/utils/language-map.ts | 3 +- 5 files changed, 60 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index 78d84e506dde..f2458dc84d65 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -19,6 +19,10 @@ export const FileQuery = Schema.Struct({ path: Schema.String, }) +export const FileWriteQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, +}) + export const FileWriteBody = Schema.Struct({ path: Schema.String, content: Schema.String, @@ -118,6 +122,7 @@ export const FileApi = HttpApi.make("file") }), ), HttpApiEndpoint.put("write", "/file/content", { + query: FileWriteQuery, payload: FileWriteBody, success: described(Schema.Struct({ success: Schema.Boolean }), "Write result"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 42346a8cebc4..c90f8dc9490f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -46,7 +46,10 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl return yield* svc.status() }) - const write = Effect.fn("FileHttpApi.write")(function* (ctx: { payload: { path: string; content: string } }) { + const write = Effect.fn("FileHttpApi.write")(function* (ctx: { + query: { directory?: string; workspace?: string } + payload: { path: string; content: string } + }) { yield* svc.write(ctx.payload.path, ctx.payload.content) yield* events.publish(File.Event.Edited, { file: ctx.payload.path }) yield* events.publish(FileWatcher.Event.Updated, { diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index a8d8b2644013..59072dbca598 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -955,7 +955,28 @@ function TextViewer(props: TextFileProps) { "z-index": "20", background: "var(--background-base)", }} + onKeyDown={(e) => { + if (e.key === "Escape") setEditing(false) + }} > + = { php: "php", lua: "lua", r: "r", - R: "r", julia: "julia", ex: "elixir", exs: "elixir", @@ -53,7 +52,7 @@ const EXT_TO_LANGUAGE: Record = { htm: "html", css: "css", scss: "scss", - sass: "scss", + sass: "sass", less: "less", md: "markdown", mdx: "markdown",