diff --git a/frontend/src/modules/ide/IDE.tsx b/frontend/src/modules/ide/IDE.tsx index 272fb2a..837d930 100644 --- a/frontend/src/modules/ide/IDE.tsx +++ b/frontend/src/modules/ide/IDE.tsx @@ -58,19 +58,39 @@ export const IDE: React.FC = ({ const createNewProject = useIDEStore((state) => state.createNewProject); const selectFile = useIDEStore((state) => state.selectFile); const updateFileContent = useIDEStore((state) => state.updateFileContent); + const saveActiveFile = useIDEStore((state) => state.saveActiveFile); const closeFile = useIDEStore((state) => state.closeFile); const closeAllFiles = useIDEStore((state) => state.closeAllFiles); const closeOtherFiles = useIDEStore((state) => state.closeOtherFiles); const initialize = useIDEStore((state) => state.initialize); const isInitialized = useIDEStore((state) => state.isInitialized); + const fetchTree = useIDEStore((state) => state.fetchTree); - // Инициализация файлов + // Обработка Ctrl+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + saveActiveFile(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [saveActiveFile]); + + // При загрузке пробуем загрузить дерево с сервера useEffect(() => { if (!isInitialized) { - const filesToInit = externalFiles || defaultInitialFiles; - initialize(filesToInit); + fetchTree().catch(() => { + // Только при ошибке — используем моковые данные + const state = useIDEStore.getState(); + if (!state.files) { + const filesToInit = externalFiles || defaultInitialFiles; + initialize(filesToInit); + } + }); } - }, [isInitialized, externalFiles, initialize]); + }, [isInitialized]); // Если проект не открыт if (!files) { @@ -249,10 +269,30 @@ export const IDE: React.FC = ({ )} {!onBack &&
} - {activeFile ? `${activeFile.name} - ` : ""} + {activeFile + ? `${activeFile.name}${activeFile.dirty ? " •" : ""} - ` + : ""} {files.name} -
+
+ {activeFile?.dirty && ( + + )} +
diff --git a/frontend/src/modules/ide/api/scripts.api.ts b/frontend/src/modules/ide/api/scripts.api.ts new file mode 100644 index 0000000..91c6c3e --- /dev/null +++ b/frontend/src/modules/ide/api/scripts.api.ts @@ -0,0 +1,58 @@ +import { apiClient } from "@/shared/api/axios.instance"; + +export interface ScriptNodeDto { + id: number; + name: string; + type: "file" | "folder"; + content?: string; + children?: string[]; + interpreter_id?: number; +} + +export interface ScriptResponse { + id: number; + content: string; + interpreter_id: number; + path: string; + created_at: string; + updated_at: string; +} + +export interface CreateScriptPayload { + content: string; + interpreter_id: number; + path: string; +} + +export interface UpdateScriptPayload { + content: string; + interpreter_id: number; + path: string; +} + +// apiClient уже имеет интерсептор для Authorization header +export const scriptsApi = { + getTree: async (): Promise => { + const res = await apiClient.get("/scripts/tree"); + return res.data; + }, + + createScript: async ( + payload: CreateScriptPayload, + ): Promise => { + const res = await apiClient.post("/scripts", payload); + return res.data; + }, + + updateScript: async ( + id: number, + payload: UpdateScriptPayload, + ): Promise => { + const res = await apiClient.put(`/scripts/${id}`, payload); + return res.data; + }, + + deleteScript: async (id: number): Promise => { + await apiClient.delete(`/scripts/${id}`); + }, +}; diff --git a/frontend/src/modules/ide/store/useIDEStore.ts b/frontend/src/modules/ide/store/useIDEStore.ts index 004637e..93dd972 100644 --- a/frontend/src/modules/ide/store/useIDEStore.ts +++ b/frontend/src/modules/ide/store/useIDEStore.ts @@ -8,6 +8,7 @@ import { addNode, renameNode, } from "../helpers/fileTree"; +import { scriptsApi } from "../api/scripts.api"; export const initialFiles: FileNode = { name: "my-project", @@ -38,11 +39,15 @@ export const initialFiles: FileNode = { ], }; +interface IDEFileNode extends FileNode { + dirty?: boolean; +} + interface IDEState { // Файловая система files: FileNode | null; - openFiles: FileNode[]; - activeFile: FileNode | null; + openFiles: IDEFileNode[]; + activeFile: IDEFileNode | null; expandedFolders: Set; searchQuery: string; showSearch: boolean; @@ -59,6 +64,7 @@ interface IDEState { // Действия с файлами selectFile: (node: FileNode) => void; updateFileContent: (content: string) => void; + saveActiveFile: () => Promise; closeFile: (file: FileNode) => void; closeAllFiles: () => void; closeOtherFiles: (file: FileNode) => void; @@ -72,6 +78,20 @@ interface IDEState { deleteRoot: () => void; createNewProject: () => void; + // API методы + fetchTree: () => Promise; + createScript: (payload: { + content: string; + interpreter_id: number; + path: string; + }) => Promise; + updateScript: ( + id: number, + payload: { content: string; interpreter_id: number; path: string }, + ) => Promise; + deleteScript: (id: number) => Promise; + saveActiveFile: () => Promise; + // Поиск setSearchQuery: (query: string) => void; toggleSearch: () => void; @@ -94,8 +114,8 @@ interface IDEState { initialize: (initialFiles: FileNode) => void; // Диалог подтверждения - handleDialogConfirm: (value: string) => void; - handleDeleteNode: (node: FileNode) => void; + handleDialogConfirm: (value: string) => Promise; + handleDeleteNode: (node: FileNode) => Promise; } export const useIDEStore = create((set, get) => ({ @@ -142,7 +162,7 @@ export const useIDEStore = create((set, get) => ({ updateFileContent: (content: string) => { const { activeFile, files } = get(); if (activeFile && files) { - const updatedFile = { ...activeFile, content }; + const updatedFile = { ...activeFile, content, dirty: true }; set({ activeFile: updatedFile }); set((state) => ({ openFiles: state.openFiles.map((f) => @@ -275,6 +295,113 @@ export const useIDEStore = create((set, get) => ({ }); }, + // API: загрузка дерева с сервера + fetchTree: async () => { + try { + const data = await scriptsApi.getTree(); + const nodeMap = new Map(); + + data.forEach((item) => { + nodeMap.set(item.name, { + id: item.id, + name: item.name, + type: item.type === "folder" ? "folder" : "file", + content: item.content || "", + path: item.name, + interpreter_id: item.interpreter_id, + children: item.type === "folder" ? [] : undefined, + }); + }); + + const hasParent = new Set(); + data.forEach((item) => { + if (item.children) { + item.children.forEach((childName: string) => { + hasParent.add(childName); + const parent = nodeMap.get(item.name); + const child = nodeMap.get(childName); + if (parent?.children && child) { + parent.children.push(child); + } + }); + } + }); + + const roots = [...nodeMap.entries()] + .filter(([name]) => !hasParent.has(name)) + .map(([, node]) => node); + + set({ + files: { + name: "scripts", + type: "folder", + children: roots, + }, + expandedFolders: new Set(), + isInitialized: true, + }); + } catch (e) { + console.error("Failed to fetch tree:", e); + throw e; + } + }, + + // API: создание скрипта + createScript: async (payload) => { + try { + await scriptsApi.createScript(payload); + await get().fetchTree(); + } catch (e) { + console.error("Failed to create script:", e); + throw e; + } + }, + + // API: обновление скрипта + updateScript: async (id, payload) => { + try { + await scriptsApi.updateScript(id, payload); + } catch (e) { + console.error("Failed to update script:", e); + throw e; + } + }, + + // API: удаление скрипта + deleteScript: async (id) => { + try { + await scriptsApi.deleteScript(id); + await get().fetchTree(); + } catch (e) { + console.error("Failed to delete script:", e); + throw e; + } + }, + + // API: сохранение активного файла + saveActiveFile: async () => { + const { activeFile } = get(); + if (!activeFile || !activeFile.id) return; + + try { + await scriptsApi.updateScript(activeFile.id, { + content: activeFile.content || "", + interpreter_id: activeFile.interpreter_id || 0, + path: activeFile.path || "", + }); + set((state) => ({ + activeFile: state.activeFile + ? { ...state.activeFile, dirty: false } + : null, + openFiles: state.openFiles.map((f) => + f.path === state.activeFile?.path ? { ...f, dirty: false } : f, + ), + })); + } catch (e) { + console.error("Failed to save file:", e); + } + }, + // Поиск setSearchQuery: (query: string) => { set({ searchQuery: query }); @@ -290,9 +417,8 @@ export const useIDEStore = create((set, get) => ({ setTabContextMenu: (menu) => set({ tabContextMenu: menu }), // Подтверждение диалога - handleDialogConfirm: (value: string) => { - const { dialog, files, refreshFiles, toggleFolder, autoExpandPaths } = - get(); + handleDialogConfirm: async (value: string) => { + const { dialog, files, toggleFolder, autoExpandPaths } = get(); if (!dialog) return; if (dialog.type === "rename" && dialog.node) { @@ -315,47 +441,34 @@ export const useIDEStore = create((set, get) => ({ value, ); if (newFiles) { - refreshFiles(newFiles); + set({ files: newFiles }); } set({ dialog: null }); return; } let parentPath: string; - if (!dialog.node) { - parentPath = files!.path || files!.name; + parentPath = ""; } else if (dialog.node.type === "folder") { parentPath = dialog.node.path || dialog.node.name; } else { const pathParts = (dialog.node.path || dialog.node.name).split("/"); pathParts.pop(); - parentPath = pathParts.join("/") || files!.path || files!.name; + parentPath = pathParts.join("/"); } - const parentNode = findNode(files!, parentPath); - if ( - parentNode?.children?.some( - (c) => c.name.toLowerCase() === value.toLowerCase(), - ) - ) { - alert(`"${value}" already exists in this folder.`); - set({ dialog: null }); - return; - } + const fullPath = parentPath ? `${parentPath}/${value}` : value; - let newFiles: FileNode | null = null; - let createdNode: FileNode | null = null; + try { + const result = await scriptsApi.createScript({ + content: "", + interpreter_id: 0, + path: fullPath, + }); - if (dialog.type === "newFile") { - createdNode = { name: value, type: "file", content: "" }; - newFiles = addNode(files!, parentPath, createdNode); - } else if (dialog.type === "newFolder") { - createdNode = { name: value, type: "folder", children: [] }; - newFiles = addNode(files!, parentPath, createdNode); - } + await get().fetchTree(); - if (newFiles) { const allParentPaths: string[] = []; let current = parentPath; while (current) { @@ -371,35 +484,38 @@ export const useIDEStore = create((set, get) => ({ }); autoExpandPaths(new Set(allParentPaths)); - if (createdNode && createdNode.type === "file") { - const findAndOpen = (node: FileNode, name: string): FileNode | null => { - if (node.name === name && node.type === "file") return node; - if (node.children) { - for (const child of node.children) { - const found = findAndOpen(child, name); - if (found) return found; - } - } - return null; + if (dialog.type === "newFile") { + const createdNode: FileNode = { + id: result.id, + name: value, + type: "file", + content: result.content, + path: result.path, + interpreter_id: result.interpreter_id, }; - const openedFile = findAndOpen(newFiles, value); - refreshFiles(newFiles, openedFile || undefined); - } else { - refreshFiles(newFiles); + get().selectFile(createdNode); } + } catch (e) { + console.error("Failed to create:", e); } + set({ dialog: null }); }, // Удаление узла - handleDeleteNode: (node: FileNode) => { - const { files, refreshFiles } = get(); + handleDeleteNode: async (node: FileNode) => { + const { files } = get(); const isRootNode = node.path === files?.path; if (isRootNode) { get().deleteRoot(); } else if (window.confirm(`Delete "${node.name}"?`)) { - const newFiles = deleteNode(files!, node.path || node.name); - if (newFiles) refreshFiles(newFiles); + if (node.id) { + try { + await get().deleteScript(node.id); + } catch (e) { + console.error("Failed to delete:", e); + } + } } }, })); diff --git a/frontend/src/pages/templates.page.tsx b/frontend/src/pages/templates.page.tsx index 68c6783..e43a334 100644 --- a/frontend/src/pages/templates.page.tsx +++ b/frontend/src/pages/templates.page.tsx @@ -1,117 +1,81 @@ +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { FiEdit3, FiPlay } from "react-icons/fi"; +import { FaSpinner } from "react-icons/fa"; import { FilePicker, useFilePickerStore } from "../modules/ide"; import type { FileNode } from "../modules/ide"; +import { scriptsApi } from "../modules/ide/api/scripts.api"; -const mockFiles: FileNode = { - name: "templates", - type: "folder", - children: [ - { - name: "python-basic", - type: "folder", - children: [ - { - name: "src", - type: "folder", - children: [ - { - name: "main.py", - type: "file", - content: - 'print("Hello, World!")\n\ndef main():\n print("Welcome!")\n\nif __name__ == "__main__":\n main()', - }, - { - name: "utils.py", - type: "file", - content: "def helper():\n return 42", - }, - ], - }, - { - name: "README.md", - type: "file", - content: "# Python Project\n\nA basic Python project.", - }, - ], - }, - { - name: "react-starter", - type: "folder", - children: [ - { - name: "src", - type: "folder", - children: [ - { - name: "App.tsx", - type: "file", - content: - 'import React from "react";\n\nexport const App: React.FC = () => {\n return
Hello React!
;\n};', - }, - { - name: "index.tsx", - type: "file", - content: - 'import React from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App";\n\ncreateRoot(document.getElementById("root")!).render();', - }, - ], - }, - { - name: "package.json", - type: "file", - content: '{\n "name": "react-project",\n "version": "1.0.0"\n}', - }, - ], - }, - { - name: "node-api", - type: "folder", - children: [ - { - name: "src", - type: "folder", - children: [ - { - name: "index.js", - type: "file", - content: - 'const express = require("express");\nconst app = express();\nconst PORT = 3000;\n\napp.get("/", (req, res) => {\n res.json({ message: "Hello!" });\n});\n\napp.listen(PORT, () => {\n console.log(`Server running on port ${PORT}`);\n});', - }, - ], - }, - { - name: "package.json", - type: "file", - content: - '{\n "name": "api-project",\n "dependencies": {\n "express": "^4.18.0"\n }\n}', - }, - ], - }, - { - name: "html-css", - type: "folder", - children: [ - { - name: "index.html", - type: "file", - content: - '\n\n\n My Landing\n \n\n\n

Welcome!

\n\n', - }, - { - name: "styles.css", - type: "file", - content: - "body {\n font-family: sans-serif;\n margin: 0;\n padding: 2rem;\n background: #f5f5f5;\n}\n\nh1 {\n color: #333;\n}", - }, - ], - }, - ], +const convertTreeToFileNode = (data: any[]): FileNode => { + const nodeMap = new Map(); + const childrenMap = new Map(); + + // Создаём все узлы + data.forEach((item) => { + nodeMap.set(item.name, { + id: item.id, + name: item.name, + type: item.type === "folder" ? "folder" : "file", + content: item.content || "", + path: item.name, + interpreter_id: item.interpreter_id, + children: item.type === "folder" ? [] : undefined, + }); + + if (item.children && item.children.length > 0) { + childrenMap.set(item.name, item.children); + } + }); + + // Строим дерево + const roots: FileNode[] = []; + const hasParent = new Set(); + + childrenMap.forEach((children, parentName) => { + const parentNode = nodeMap.get(parentName); + if (!parentNode) return; + + children.forEach((childName: string) => { + hasParent.add(childName); + const childNode = nodeMap.get(childName); + if (childNode && parentNode.children) { + parentNode.children.push(childNode); + } + }); + }); + + // Корневые элементы — те у кого нет родителя + nodeMap.forEach((node, name) => { + if (!hasParent.has(name)) { + roots.push(node); + } + }); + + return { + name: "templates", + type: "folder", + children: roots, + }; }; export const TemplatesPage = () => { const navigate = useNavigate(); const selectedPaths = useFilePickerStore((s) => s.selectedPaths); + const [files, setFiles] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + scriptsApi + .getTree() + .then((data) => { + setFiles(convertTreeToFileNode(data)); + }) + .catch((e) => { + console.error("Failed to load tree:", e); + setFiles({ name: "templates", type: "folder", children: [] }); + }) + .finally(() => setLoading(false)); + }, []); return (
{ {/* File Picker */}
- + {loading ? ( +
+ +
+ ) : files ? ( + + ) : null}
); diff --git a/frontend/src/shared/api/axios.instance.ts b/frontend/src/shared/api/axios.instance.ts index afd5aa1..4c7ba74 100644 --- a/frontend/src/shared/api/axios.instance.ts +++ b/frontend/src/shared/api/axios.instance.ts @@ -16,7 +16,7 @@ class ApiClient { constructor() { this.axiosInstance = axios.create({ - baseURL: "http://10.97.147.99:8080/api/v1", + baseURL: "http://213.165.213.170:8080/api/v1", timeout: 10000, headers: { "Content-Type": "application/json",