2 Commits

Author SHA1 Message Date
nikita 8f5558fdb7 Merge branch 'frontend' of gitea.d3m0k1d.ru:d3m0k1d/HellreigN into HEAD
ci-front / build (push) Successful in 1m59s
2026-04-05 00:56:55 +03:00
nikita 07066ec8c0 feat: request for tree 2026-04-05 00:56:48 +03:00
4 changed files with 358 additions and 161 deletions
+46 -6
View File
@@ -58,19 +58,39 @@ export const IDE: React.FC<IDEProps> = ({
const createNewProject = useIDEStore((state) => state.createNewProject); const createNewProject = useIDEStore((state) => state.createNewProject);
const selectFile = useIDEStore((state) => state.selectFile); const selectFile = useIDEStore((state) => state.selectFile);
const updateFileContent = useIDEStore((state) => state.updateFileContent); const updateFileContent = useIDEStore((state) => state.updateFileContent);
const saveActiveFile = useIDEStore((state) => state.saveActiveFile);
const closeFile = useIDEStore((state) => state.closeFile); const closeFile = useIDEStore((state) => state.closeFile);
const closeAllFiles = useIDEStore((state) => state.closeAllFiles); const closeAllFiles = useIDEStore((state) => state.closeAllFiles);
const closeOtherFiles = useIDEStore((state) => state.closeOtherFiles); const closeOtherFiles = useIDEStore((state) => state.closeOtherFiles);
const initialize = useIDEStore((state) => state.initialize); const initialize = useIDEStore((state) => state.initialize);
const isInitialized = useIDEStore((state) => state.isInitialized); 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(() => { useEffect(() => {
if (!isInitialized) { if (!isInitialized) {
const filesToInit = externalFiles || defaultInitialFiles; fetchTree().catch(() => {
initialize(filesToInit); // Только при ошибке — используем моковые данные
const state = useIDEStore.getState();
if (!state.files) {
const filesToInit = externalFiles || defaultInitialFiles;
initialize(filesToInit);
}
});
} }
}, [isInitialized, externalFiles, initialize]); }, [isInitialized]);
// Если проект не открыт // Если проект не открыт
if (!files) { if (!files) {
@@ -249,10 +269,30 @@ export const IDE: React.FC<IDEProps> = ({
)} )}
{!onBack && <div />} {!onBack && <div />}
<span style={{ fontWeight: 400 }}> <span style={{ fontWeight: 400 }}>
{activeFile ? `${activeFile.name} - ` : ""} {activeFile
? `${activeFile.name}${activeFile.dirty ? " •" : ""} - `
: ""}
{files.name} {files.name}
</span> </span>
<div style={{ width: 60 }} /> <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{activeFile?.dirty && (
<button
onClick={saveActiveFile}
style={{
background: "transparent",
border: "none",
color: c.textPrimary,
cursor: "pointer",
fontSize: "11px",
padding: "4px 8px",
borderRadius: "4px",
}}
title="Сохранить (Ctrl+S)"
>
Сохранить
</button>
)}
</div>
</div> </div>
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}> <div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
<div style={{ width: "260px", flexShrink: 0 }}> <div style={{ width: "260px", flexShrink: 0 }}>
@@ -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<ScriptNodeDto[]> => {
const res = await apiClient.get<ScriptNodeDto[]>("/scripts/tree");
return res.data;
},
createScript: async (
payload: CreateScriptPayload,
): Promise<ScriptResponse> => {
const res = await apiClient.post<ScriptResponse>("/scripts", payload);
return res.data;
},
updateScript: async (
id: number,
payload: UpdateScriptPayload,
): Promise<ScriptResponse> => {
const res = await apiClient.put<ScriptResponse>(`/scripts/${id}`, payload);
return res.data;
},
deleteScript: async (id: number): Promise<void> => {
await apiClient.delete(`/scripts/${id}`);
},
};
+166 -50
View File
@@ -8,6 +8,7 @@ import {
addNode, addNode,
renameNode, renameNode,
} from "../helpers/fileTree"; } from "../helpers/fileTree";
import { scriptsApi } from "../api/scripts.api";
export const initialFiles: FileNode = { export const initialFiles: FileNode = {
name: "my-project", name: "my-project",
@@ -38,11 +39,15 @@ export const initialFiles: FileNode = {
], ],
}; };
interface IDEFileNode extends FileNode {
dirty?: boolean;
}
interface IDEState { interface IDEState {
// Файловая система // Файловая система
files: FileNode | null; files: FileNode | null;
openFiles: FileNode[]; openFiles: IDEFileNode[];
activeFile: FileNode | null; activeFile: IDEFileNode | null;
expandedFolders: Set<string>; expandedFolders: Set<string>;
searchQuery: string; searchQuery: string;
showSearch: boolean; showSearch: boolean;
@@ -59,6 +64,7 @@ interface IDEState {
// Действия с файлами // Действия с файлами
selectFile: (node: FileNode) => void; selectFile: (node: FileNode) => void;
updateFileContent: (content: string) => void; updateFileContent: (content: string) => void;
saveActiveFile: () => Promise<void>;
closeFile: (file: FileNode) => void; closeFile: (file: FileNode) => void;
closeAllFiles: () => void; closeAllFiles: () => void;
closeOtherFiles: (file: FileNode) => void; closeOtherFiles: (file: FileNode) => void;
@@ -72,6 +78,20 @@ interface IDEState {
deleteRoot: () => void; deleteRoot: () => void;
createNewProject: () => void; createNewProject: () => void;
// API методы
fetchTree: () => Promise<void>;
createScript: (payload: {
content: string;
interpreter_id: number;
path: string;
}) => Promise<void>;
updateScript: (
id: number,
payload: { content: string; interpreter_id: number; path: string },
) => Promise<void>;
deleteScript: (id: number) => Promise<void>;
saveActiveFile: () => Promise<void>;
// Поиск // Поиск
setSearchQuery: (query: string) => void; setSearchQuery: (query: string) => void;
toggleSearch: () => void; toggleSearch: () => void;
@@ -94,8 +114,8 @@ interface IDEState {
initialize: (initialFiles: FileNode) => void; initialize: (initialFiles: FileNode) => void;
// Диалог подтверждения // Диалог подтверждения
handleDialogConfirm: (value: string) => void; handleDialogConfirm: (value: string) => Promise<void>;
handleDeleteNode: (node: FileNode) => void; handleDeleteNode: (node: FileNode) => Promise<void>;
} }
export const useIDEStore = create<IDEState>((set, get) => ({ export const useIDEStore = create<IDEState>((set, get) => ({
@@ -142,7 +162,7 @@ export const useIDEStore = create<IDEState>((set, get) => ({
updateFileContent: (content: string) => { updateFileContent: (content: string) => {
const { activeFile, files } = get(); const { activeFile, files } = get();
if (activeFile && files) { if (activeFile && files) {
const updatedFile = { ...activeFile, content }; const updatedFile = { ...activeFile, content, dirty: true };
set({ activeFile: updatedFile }); set({ activeFile: updatedFile });
set((state) => ({ set((state) => ({
openFiles: state.openFiles.map((f) => openFiles: state.openFiles.map((f) =>
@@ -275,6 +295,113 @@ export const useIDEStore = create<IDEState>((set, get) => ({
}); });
}, },
// API: загрузка дерева с сервера
fetchTree: async () => {
try {
const data = await scriptsApi.getTree();
const nodeMap = new Map<string, FileNode>();
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<string>();
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) => { setSearchQuery: (query: string) => {
set({ searchQuery: query }); set({ searchQuery: query });
@@ -290,9 +417,8 @@ export const useIDEStore = create<IDEState>((set, get) => ({
setTabContextMenu: (menu) => set({ tabContextMenu: menu }), setTabContextMenu: (menu) => set({ tabContextMenu: menu }),
// Подтверждение диалога // Подтверждение диалога
handleDialogConfirm: (value: string) => { handleDialogConfirm: async (value: string) => {
const { dialog, files, refreshFiles, toggleFolder, autoExpandPaths } = const { dialog, files, toggleFolder, autoExpandPaths } = get();
get();
if (!dialog) return; if (!dialog) return;
if (dialog.type === "rename" && dialog.node) { if (dialog.type === "rename" && dialog.node) {
@@ -315,47 +441,34 @@ export const useIDEStore = create<IDEState>((set, get) => ({
value, value,
); );
if (newFiles) { if (newFiles) {
refreshFiles(newFiles); set({ files: newFiles });
} }
set({ dialog: null }); set({ dialog: null });
return; return;
} }
let parentPath: string; let parentPath: string;
if (!dialog.node) { if (!dialog.node) {
parentPath = files!.path || files!.name; parentPath = "";
} else if (dialog.node.type === "folder") { } else if (dialog.node.type === "folder") {
parentPath = dialog.node.path || dialog.node.name; parentPath = dialog.node.path || dialog.node.name;
} else { } else {
const pathParts = (dialog.node.path || dialog.node.name).split("/"); const pathParts = (dialog.node.path || dialog.node.name).split("/");
pathParts.pop(); pathParts.pop();
parentPath = pathParts.join("/") || files!.path || files!.name; parentPath = pathParts.join("/");
} }
const parentNode = findNode(files!, parentPath); const fullPath = parentPath ? `${parentPath}/${value}` : value;
if (
parentNode?.children?.some(
(c) => c.name.toLowerCase() === value.toLowerCase(),
)
) {
alert(`"${value}" already exists in this folder.`);
set({ dialog: null });
return;
}
let newFiles: FileNode | null = null; try {
let createdNode: FileNode | null = null; const result = await scriptsApi.createScript({
content: "",
interpreter_id: 0,
path: fullPath,
});
if (dialog.type === "newFile") { await get().fetchTree();
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);
}
if (newFiles) {
const allParentPaths: string[] = []; const allParentPaths: string[] = [];
let current = parentPath; let current = parentPath;
while (current) { while (current) {
@@ -371,35 +484,38 @@ export const useIDEStore = create<IDEState>((set, get) => ({
}); });
autoExpandPaths(new Set(allParentPaths)); autoExpandPaths(new Set(allParentPaths));
if (createdNode && createdNode.type === "file") { if (dialog.type === "newFile") {
const findAndOpen = (node: FileNode, name: string): FileNode | null => { const createdNode: FileNode = {
if (node.name === name && node.type === "file") return node; id: result.id,
if (node.children) { name: value,
for (const child of node.children) { type: "file",
const found = findAndOpen(child, name); content: result.content,
if (found) return found; path: result.path,
} interpreter_id: result.interpreter_id,
}
return null;
}; };
const openedFile = findAndOpen(newFiles, value); get().selectFile(createdNode);
refreshFiles(newFiles, openedFile || undefined);
} else {
refreshFiles(newFiles);
} }
} catch (e) {
console.error("Failed to create:", e);
} }
set({ dialog: null }); set({ dialog: null });
}, },
// Удаление узла // Удаление узла
handleDeleteNode: (node: FileNode) => { handleDeleteNode: async (node: FileNode) => {
const { files, refreshFiles } = get(); const { files } = get();
const isRootNode = node.path === files?.path; const isRootNode = node.path === files?.path;
if (isRootNode) { if (isRootNode) {
get().deleteRoot(); get().deleteRoot();
} else if (window.confirm(`Delete "${node.name}"?`)) { } else if (window.confirm(`Delete "${node.name}"?`)) {
const newFiles = deleteNode(files!, node.path || node.name); if (node.id) {
if (newFiles) refreshFiles(newFiles); try {
await get().deleteScript(node.id);
} catch (e) {
console.error("Failed to delete:", e);
}
}
} }
}, },
})); }));
+88 -105
View File
@@ -1,117 +1,81 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FiEdit3, FiPlay } from "react-icons/fi"; import { FiEdit3, FiPlay } from "react-icons/fi";
import { FaSpinner } from "react-icons/fa";
import { FilePicker, useFilePickerStore } from "../modules/ide"; import { FilePicker, useFilePickerStore } from "../modules/ide";
import type { FileNode } from "../modules/ide"; import type { FileNode } from "../modules/ide";
import { scriptsApi } from "../modules/ide/api/scripts.api";
const mockFiles: FileNode = { const convertTreeToFileNode = (data: any[]): FileNode => {
name: "templates", const nodeMap = new Map<string, FileNode>();
type: "folder", const childrenMap = new Map<string, string[]>();
children: [
{ // Создаём все узлы
name: "python-basic", data.forEach((item) => {
type: "folder", nodeMap.set(item.name, {
children: [ id: item.id,
{ name: item.name,
name: "src", type: item.type === "folder" ? "folder" : "file",
type: "folder", content: item.content || "",
children: [ path: item.name,
{ interpreter_id: item.interpreter_id,
name: "main.py", children: item.type === "folder" ? [] : undefined,
type: "file", });
content:
'print("Hello, World!")\n\ndef main():\n print("Welcome!")\n\nif __name__ == "__main__":\n main()', if (item.children && item.children.length > 0) {
}, childrenMap.set(item.name, item.children);
{ }
name: "utils.py", });
type: "file",
content: "def helper():\n return 42", // Строим дерево
}, const roots: FileNode[] = [];
], const hasParent = new Set<string>();
},
{ childrenMap.forEach((children, parentName) => {
name: "README.md", const parentNode = nodeMap.get(parentName);
type: "file", if (!parentNode) return;
content: "# Python Project\n\nA basic Python project.",
}, children.forEach((childName: string) => {
], hasParent.add(childName);
}, const childNode = nodeMap.get(childName);
{ if (childNode && parentNode.children) {
name: "react-starter", parentNode.children.push(childNode);
type: "folder", }
children: [ });
{ });
name: "src",
type: "folder", // Корневые элементы — те у кого нет родителя
children: [ nodeMap.forEach((node, name) => {
{ if (!hasParent.has(name)) {
name: "App.tsx", roots.push(node);
type: "file", }
content: });
'import React from "react";\n\nexport const App: React.FC = () => {\n return <div>Hello React!</div>;\n};',
}, return {
{ name: "templates",
name: "index.tsx", type: "folder",
type: "file", children: roots,
content: };
'import React from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App";\n\ncreateRoot(document.getElementById("root")!).render(<App />);',
},
],
},
{
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:
'<!DOCTYPE html>\n<html>\n<head>\n <title>My Landing</title>\n <link rel="stylesheet" href="styles.css">\n</head>\n<body>\n <h1>Welcome!</h1>\n</body>\n</html>',
},
{
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}",
},
],
},
],
}; };
export const TemplatesPage = () => { export const TemplatesPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const selectedPaths = useFilePickerStore((s) => s.selectedPaths); const selectedPaths = useFilePickerStore((s) => s.selectedPaths);
const [files, setFiles] = useState<FileNode | null>(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 ( return (
<div <div
@@ -183,7 +147,26 @@ export const TemplatesPage = () => {
{/* File Picker */} {/* File Picker */}
<div style={{ height: "100%", overflow: "hidden" }}> <div style={{ height: "100%", overflow: "hidden" }}>
<FilePicker files={mockFiles} /> {loading ? (
<div
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<FaSpinner
size={24}
style={{
color: "var(--accent)",
animation: "spin 1s linear infinite",
}}
/>
</div>
) : files ? (
<FilePicker files={files} />
) : null}
</div> </div>
</div> </div>
); );