feat: wire Monaco editor to real file read/write
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user