feat: wire Monaco editor to real file read/write

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 11:27:19 +08:00
co-authored by Claude
parent d587c94f50
commit 58aa6077d8
4 changed files with 182 additions and 44 deletions
+114 -19
View File
@@ -1,16 +1,81 @@
import Editor, { type OnValidate } from "@monaco-editor/react";
import { useState } from "react";
import Editor, { type OnMount, type OnValidate } from "@monaco-editor/react";
import { useEffect, useRef, useState } from "react";
import type { MockFileNode } from "@/pages/workspace/mock-data";
import { inferLanguage, useFileRead, useFileWrite } from "@/lib/api/files";
interface EditorPanelProps {
file?: MockFileNode;
workspaceId: string | null;
path: string | null;
language?: string;
}
export function EditorPanel({ file }: EditorPanelProps) {
export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
const { data, isLoading, error: readError } = useFileRead(workspaceId, path);
const write = useFileWrite();
const [buffer, setBuffer] = useState("");
const [savedContent, setSavedContent] = useState("");
const dirty = buffer !== savedContent;
const saveInProgress = write.isPending;
// Sync editor buffer with fetched content when the file changes or loads.
useEffect(() => {
if (data) {
setBuffer(data.content);
setSavedContent(data.content);
}
}, [data]);
const editorLanguage =
language ??
(path ? inferLanguage(path.split("/").pop() ?? path) : undefined);
// Saving state refs so the key handler always sees the latest values.
const bufferRef = useRef(buffer);
const savedContentRef = useRef(savedContent);
const pathRef = useRef(path);
const workspaceIdRef = useRef(workspaceId);
useEffect(() => {
bufferRef.current = buffer;
savedContentRef.current = savedContent;
pathRef.current = path;
workspaceIdRef.current = workspaceId;
}, [buffer, savedContent, path, workspaceId]);
// Wire Cmd/Ctrl+S to save the current buffer.
const handleMount: OnMount = (editor, monaco) => {
editor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S,
() => {
const currentBuffer = bufferRef.current;
const currentSaved = savedContentRef.current;
const currentPath = pathRef.current;
const currentWorkspaceId = workspaceIdRef.current;
if (
currentWorkspaceId &&
currentPath &&
currentBuffer !== currentSaved
) {
write.mutate(
{
workspaceId: currentWorkspaceId,
path: currentPath,
content: currentBuffer,
},
{
onSuccess: () => setSavedContent(currentBuffer),
},
);
}
},
);
};
// Resetting dirty state on file switch is acceptable for this task because
// each file gets its own EditorPanel mount via the `key={path}` prop.
const [, setMarkers] = useState<Parameters<OnValidate>[0]>([]);
if (!file || file.kind !== "file") {
if (!path) {
return (
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
<div className="text-center">
@@ -21,19 +86,49 @@ export function EditorPanel({ file }: EditorPanelProps) {
}
return (
<section className="h-full w-full overflow-hidden bg-background">
<Editor
key={file.path}
theme="vs-dark"
language={file.language ?? "typescript"}
value={file.content ?? ""}
onValidate={setMarkers}
options={{
automaticLayout: true,
minimap: { enabled: false },
readOnly: false,
}}
/>
<section className="flex h-full w-full flex-col overflow-hidden bg-background">
<header className="flex h-8 shrink-0 items-center justify-between border-b border-border bg-sidebar px-3 text-xs">
<div className="flex items-center gap-2 truncate">
<span className={dirty ? "text-foreground" : "text-muted-foreground"}>
{dirty ? "●" : "•"}
</span>
<span className="truncate text-foreground">{path}</span>
</div>
<div className="flex items-center gap-2">
{saveInProgress && (
<span className="text-muted-foreground">Saving</span>
)}
{write.isError && (
<span className="text-destructive">{write.error.message}</span>
)}
</div>
</header>
{readError && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
{readError.message}
</div>
)}
{isLoading && (
<div className="shrink-0 border-b border-border bg-muted px-3 py-1.5 text-xs text-muted-foreground">
Loading
</div>
)}
<div className="min-h-0 flex-1">
<Editor
key={path}
theme="vs-dark"
language={editorLanguage ?? "plaintext"}
value={buffer}
onChange={(value) => setBuffer(value ?? "")}
onValidate={setMarkers}
onMount={handleMount}
options={{
automaticLayout: true,
minimap: { enabled: false },
readOnly: false,
}}
/>
</div>
</section>
);
}
+1 -16
View File
@@ -8,33 +8,18 @@ import { TerminalPanel } from "@/components/terminal/TerminalPanel";
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
import { StatusBar } from "@/components/workspace/StatusBar";
import { WorkspaceSelector } from "@/components/workspace/WorkspaceSelector";
import { inferLanguage } from "@/lib/api/files";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import type { MockFileNode } from "@/pages/workspace/mock-data";
interface WorkspaceShellProps {
workspaceId: string;
}
function fileNodeFromPath(path: string): MockFileNode {
const name = path.split("/").pop() ?? path;
return {
name,
path,
kind: "file",
language: inferLanguage(name),
};
}
export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
const terminalVisible = useWorkspaceUiStore((s) => s.terminalVisible);
const toggleSidebar = useWorkspaceUiStore((s) => s.toggleSidebar);
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
const activeFile = activeFilePath
? fileNodeFromPath(activeFilePath)
: undefined;
return (
<div className="flex h-full w-full flex-col bg-background text-foreground">
@@ -86,7 +71,7 @@ export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
<PanelGroup direction="vertical">
{/* Editor */}
<Panel>
<EditorPanel file={activeFile} />
<EditorPanel workspaceId={workspaceId} path={activeFilePath || null} />
</Panel>
{/* Terminal panel */}