diff --git a/web/src/components/editor/EditorPanel.tsx b/web/src/components/editor/EditorPanel.tsx index 8e5adaa..4aa227f 100644 --- a/web/src/components/editor/EditorPanel.tsx +++ b/web/src/components/editor/EditorPanel.tsx @@ -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[0]>([]); - if (!file || file.kind !== "file") { + if (!path) { return (
@@ -21,19 +86,49 @@ export function EditorPanel({ file }: EditorPanelProps) { } return ( -
- +
+
+
+ + {dirty ? "●" : "•"} + + {path} +
+
+ {saveInProgress && ( + Saving… + )} + {write.isError && ( + {write.error.message} + )} +
+
+ {readError && ( +
+ {readError.message} +
+ )} + {isLoading && ( +
+ Loading… +
+ )} +
+ setBuffer(value ?? "")} + onValidate={setMarkers} + onMount={handleMount} + options={{ + automaticLayout: true, + minimap: { enabled: false }, + readOnly: false, + }} + /> +
); } diff --git a/web/src/components/layout/WorkspaceShell.tsx b/web/src/components/layout/WorkspaceShell.tsx index fdc8062..a841b9a 100644 --- a/web/src/components/layout/WorkspaceShell.tsx +++ b/web/src/components/layout/WorkspaceShell.tsx @@ -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 (
@@ -86,7 +71,7 @@ export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) { {/* Editor */} - + {/* Terminal panel */} diff --git a/web/src/lib/api/files.ts b/web/src/lib/api/files.ts index b4193a2..5f1be9c 100644 --- a/web/src/lib/api/files.ts +++ b/web/src/lib/api/files.ts @@ -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 { + 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 { + 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), + }); + }, + }); +} diff --git a/web/src/pages/workspace/mock-data.ts b/web/src/pages/workspace/mock-data.ts deleted file mode 100644 index 15d00a8..0000000 --- a/web/src/pages/workspace/mock-data.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface MockFileNode { - name: string; - path: string; - kind: "file" | "dir"; - language?: string; - content?: string; - children?: MockFileNode[]; -}