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 */}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface MockFileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "file" | "dir";
|
||||
language?: string;
|
||||
content?: string;
|
||||
children?: MockFileNode[];
|
||||
}
|
||||
Reference in New Issue
Block a user