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 */}
+67 -1
View File
@@ -1,4 +1,10 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
@@ -31,6 +37,11 @@ export function inferLanguage(name: string): string | undefined {
return ext ? LANGUAGE_BY_EXTENSION[ext] : undefined;
}
export interface ReadFileResponse {
path: string;
content: string;
}
function fileListQueryKey(workspaceId: string, path: string) {
return ["workspaces", workspaceId, "files", path || "."] as const;
}
@@ -50,6 +61,35 @@ async function fetchFileList(
return (await res.json()) as FileEntry[];
}
export function fileReadQueryKey(workspaceId: string, path: string) {
return ["workspaces", workspaceId, "files", "read", path] as const;
}
async function fetchFileRead(
workspaceId: string,
path: string,
): Promise<ReadFileResponse> {
const url =
`/api/workspaces/${encodeURIComponent(workspaceId)}/files/read?` +
new URLSearchParams({ path }).toString();
const res = await apiClient(url);
if (!res.ok) {
throw new Error(`Failed to read file: ${res.status}`);
}
return (await res.json()) as ReadFileResponse;
}
export function useFileRead(
workspaceId: string | null,
path: string | null,
): UseQueryResult<ReadFileResponse, Error> {
return useQuery({
queryKey: fileReadQueryKey(workspaceId ?? "", path ?? ""),
queryFn: () => fetchFileRead(workspaceId!, path!),
enabled: !!workspaceId && !!path,
});
}
export function useFileList(
workspaceId: string | null,
path: string,
@@ -62,3 +102,29 @@ export function useFileList(
enabled: !!workspaceId && enabled,
});
}
export function useFileWrite(): UseMutationResult<
void,
Error,
{ path: string; content: string; workspaceId: string }
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ workspaceId, path, content }) => {
const url = `/api/workspaces/${encodeURIComponent(workspaceId)}/files/write`;
const res = await apiClient(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content }),
});
if (!res.ok) {
throw new Error(`Failed to save file: ${res.status}`);
}
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: fileReadQueryKey(variables.workspaceId, variables.path),
});
},
});
}
-8
View File
@@ -1,8 +0,0 @@
export interface MockFileNode {
name: string;
path: string;
kind: "file" | "dir";
language?: string;
content?: string;
children?: MockFileNode[];
}