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 Editor, { type OnMount, type OnValidate } from "@monaco-editor/react";
|
||||||
import { useState } from "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 {
|
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]>([]);
|
const [, setMarkers] = useState<Parameters<OnValidate>[0]>([]);
|
||||||
|
|
||||||
if (!file || file.kind !== "file") {
|
if (!path) {
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -21,19 +86,49 @@ export function EditorPanel({ file }: EditorPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="h-full w-full overflow-hidden bg-background">
|
<section className="flex h-full w-full flex-col overflow-hidden bg-background">
|
||||||
<Editor
|
<header className="flex h-8 shrink-0 items-center justify-between border-b border-border bg-sidebar px-3 text-xs">
|
||||||
key={file.path}
|
<div className="flex items-center gap-2 truncate">
|
||||||
theme="vs-dark"
|
<span className={dirty ? "text-foreground" : "text-muted-foreground"}>
|
||||||
language={file.language ?? "typescript"}
|
{dirty ? "●" : "•"}
|
||||||
value={file.content ?? ""}
|
</span>
|
||||||
onValidate={setMarkers}
|
<span className="truncate text-foreground">{path}</span>
|
||||||
options={{
|
</div>
|
||||||
automaticLayout: true,
|
<div className="flex items-center gap-2">
|
||||||
minimap: { enabled: false },
|
{saveInProgress && (
|
||||||
readOnly: false,
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,33 +8,18 @@ import { TerminalPanel } from "@/components/terminal/TerminalPanel";
|
|||||||
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
|
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
|
||||||
import { StatusBar } from "@/components/workspace/StatusBar";
|
import { StatusBar } from "@/components/workspace/StatusBar";
|
||||||
import { WorkspaceSelector } from "@/components/workspace/WorkspaceSelector";
|
import { WorkspaceSelector } from "@/components/workspace/WorkspaceSelector";
|
||||||
import { inferLanguage } from "@/lib/api/files";
|
|
||||||
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
|
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
|
||||||
import type { MockFileNode } from "@/pages/workspace/mock-data";
|
|
||||||
|
|
||||||
interface WorkspaceShellProps {
|
interface WorkspaceShellProps {
|
||||||
workspaceId: string;
|
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) {
|
export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
|
||||||
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
|
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
|
||||||
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
|
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
|
||||||
const terminalVisible = useWorkspaceUiStore((s) => s.terminalVisible);
|
const terminalVisible = useWorkspaceUiStore((s) => s.terminalVisible);
|
||||||
const toggleSidebar = useWorkspaceUiStore((s) => s.toggleSidebar);
|
const toggleSidebar = useWorkspaceUiStore((s) => s.toggleSidebar);
|
||||||
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
|
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
|
||||||
const activeFile = activeFilePath
|
|
||||||
? fileNodeFromPath(activeFilePath)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col bg-background text-foreground">
|
<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">
|
<PanelGroup direction="vertical">
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<Panel>
|
<Panel>
|
||||||
<EditorPanel file={activeFile} />
|
<EditorPanel workspaceId={workspaceId} path={activeFilePath || null} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Terminal 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";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
@@ -31,6 +37,11 @@ export function inferLanguage(name: string): string | undefined {
|
|||||||
return ext ? LANGUAGE_BY_EXTENSION[ext] : undefined;
|
return ext ? LANGUAGE_BY_EXTENSION[ext] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReadFileResponse {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
function fileListQueryKey(workspaceId: string, path: string) {
|
function fileListQueryKey(workspaceId: string, path: string) {
|
||||||
return ["workspaces", workspaceId, "files", path || "."] as const;
|
return ["workspaces", workspaceId, "files", path || "."] as const;
|
||||||
}
|
}
|
||||||
@@ -50,6 +61,35 @@ async function fetchFileList(
|
|||||||
return (await res.json()) as FileEntry[];
|
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(
|
export function useFileList(
|
||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -62,3 +102,29 @@ export function useFileList(
|
|||||||
enabled: !!workspaceId && enabled,
|
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