Files
HellreigN/frontend/src/modules/ide/store/useIDEStore.ts
T
2026-04-05 03:28:31 +03:00

611 lines
17 KiB
TypeScript

import { create } from "zustand";
import type { FileNode, Interpreter, DialogState } from "../types";
import {
addPaths,
getAllFolderPaths,
findNode,
deleteNode,
addNode,
renameNode,
} from "../helpers/fileTree";
import { scriptsApi } from "../api/scripts.api";
export const initialFiles: FileNode = {
name: "my-project",
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: "# My Project\n\nWelcome!",
},
],
};
interface IDEFileNode extends FileNode {
dirty?: boolean;
}
interface IDEState {
// Файловая система
files: FileNode | null;
openFiles: IDEFileNode[];
activeFile: IDEFileNode | null;
expandedFolders: Set<string>;
searchQuery: string;
showSearch: boolean;
isInitialized: boolean;
interpreters: Interpreter[];
// Диалоги и контекстные меню
contextMenu: { x: number; y: number; node: FileNode | null } | null;
dialog: DialogState | null;
tabContextMenu: { x: number; y: number; file: FileNode } | null;
// Действия с файлами
selectFile: (node: FileNode) => void;
updateFileContent: (content: string) => void;
saveActiveFile: () => Promise<void>;
closeFile: (file: FileNode) => void;
closeAllFiles: () => void;
closeOtherFiles: (file: FileNode) => void;
// Действия с деревом
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => void;
toggleFolder: (path: string) => void;
expandAllFolders: () => void;
collapseAllFolders: () => void;
autoExpandPaths: (paths: Set<string>) => void;
deleteRoot: () => void;
createNewProject: () => void;
// Интерпретаторы
fetchInterpreters: () => Promise<void>;
// API методы
fetchTree: () => Promise<void>;
createScript: (payload: {
content: string;
interpreter_id: number;
path: string;
}) => Promise<void>;
createFolder: (path: string) => Promise<void>;
updateScript: (
id: number,
payload: { content: string; interpreter_id: number; path: string },
) => Promise<void>;
deleteScript: (id: number) => Promise<void>;
deleteFolder: (payload: { path: string }) => Promise<void>;
saveActiveFile: () => Promise<void>;
// Поиск
setSearchQuery: (query: string) => void;
toggleSearch: () => void;
// Контекстные меню и диалоги
setContextMenu: (
menu: { x: number; y: number; node: FileNode | null } | null,
) => void;
setDialog: (
dialog: {
type: "newFile" | "newFolder" | "rename";
node: FileNode | null;
} | null,
) => void;
setTabContextMenu: (
menu: { x: number; y: number; file: FileNode } | null,
) => void;
// Инициализация
initialize: (initialFiles: FileNode) => void;
// Диалог подтверждения
handleDialogConfirm: (value: string, interpreterId?: number) => Promise<void>;
handleDeleteNode: (node: FileNode) => Promise<void>;
}
export const useIDEStore = create<IDEState>((set, get) => ({
// Начальное состояние
files: null,
openFiles: [],
activeFile: null,
expandedFolders: new Set(),
searchQuery: "",
showSearch: false,
isInitialized: false,
contextMenu: null,
dialog: null,
tabContextMenu: null,
interpreters: [],
// Инициализация
initialize: (initialFiles: FileNode) => {
const filesWithPaths = addPaths(initialFiles);
set({
files: filesWithPaths,
expandedFolders: new Set([filesWithPaths.path || filesWithPaths.name]),
isInitialized: true,
});
},
// Выбор файла
selectFile: (node: FileNode) => {
if (node.type === "file") {
const { openFiles, files } = get();
// Берём актуальную версию из дерева файлов
const latestFile = files ? findNode(files, node.path || "") : null;
const fileToOpen =
latestFile && latestFile.type === "file" ? latestFile : node;
if (!openFiles.find((f) => f.path === fileToOpen.path)) {
set((state) => ({ openFiles: [...state.openFiles, fileToOpen] }));
}
set({ activeFile: fileToOpen });
}
},
// Обновление содержимого файла
updateFileContent: (content: string) => {
const { activeFile, files } = get();
if (activeFile && files) {
const updatedFile = { ...activeFile, content, dirty: true };
set({ activeFile: updatedFile });
set((state) => ({
openFiles: state.openFiles.map((f) =>
f.path === activeFile.path ? updatedFile : f,
),
}));
// Обновляем также в дереве файлов
const updateFileInTree = (node: FileNode): FileNode => {
if (node.path === activeFile.path) {
return updatedFile;
}
if (node.children) {
return {
...node,
children: node.children.map((child) => updateFileInTree(child)),
};
}
return node;
};
set({ files: updateFileInTree(files) });
}
},
// Закрытие файла
closeFile: (file: FileNode) => {
const { openFiles, activeFile } = get();
const newOpenFiles = openFiles.filter((f) => f.path !== file.path);
set({ openFiles: newOpenFiles });
if (activeFile?.path === file.path) {
set({ activeFile: newOpenFiles[newOpenFiles.length - 1] || null });
}
},
// Закрыть все файлы
closeAllFiles: () => {
set({ openFiles: [], activeFile: null });
},
// Закрыть другие файлы
closeOtherFiles: (file: FileNode) => {
set({ openFiles: [file], activeFile: file });
},
// Обновить файловую систему
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => {
const { openFiles, activeFile, selectFile } = get();
set({ files: newFiles });
if (!newFiles) {
set({ openFiles: [], activeFile: null });
return;
}
const updatedOpenFiles = openFiles
.map((f) => {
const found = findNode(newFiles, f.path || "");
return found && found.type === "file" ? found : null;
})
.filter((f): f is FileNode => f !== null);
set({ openFiles: updatedOpenFiles });
if (newFile) {
selectFile(newFile);
} else if (activeFile) {
const stillExists = findNode(newFiles, activeFile.path || "");
if (!stillExists) {
set({
activeFile: updatedOpenFiles[updatedOpenFiles.length - 1] || null,
});
} else if (stillExists.type === "file") {
set({ activeFile: stillExists });
}
}
},
// Переключить папку
toggleFolder: (path: string) => {
set((state) => {
const newSet = new Set(state.expandedFolders);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
return { expandedFolders: newSet };
});
},
// Раскрыть все папки
expandAllFolders: () => {
const { files } = get();
if (files) {
set({ expandedFolders: new Set(getAllFolderPaths(files)) });
}
},
// Свернуть все папки
collapseAllFolders: () => {
set({ expandedFolders: new Set() });
},
// Автоматически раскрыть пути
autoExpandPaths: (paths: Set<string>) => {
set((state) => ({
expandedFolders: new Set([...state.expandedFolders, ...paths]),
}));
},
// Удалить корень
deleteRoot: () => {
set({
files: null,
openFiles: [],
activeFile: null,
expandedFolders: new Set(),
});
},
// Создать новый проект
createNewProject: () => {
const newProject = addPaths(initialFiles);
set({
files: newProject,
expandedFolders: new Set([newProject.path || newProject.name]),
searchQuery: "",
});
},
// Интерпретаторы
fetchInterpreters: async () => {
try {
const interpreters = await scriptsApi.getInterpreters();
set({ interpreters });
} catch (e) {
console.error("Failed to fetch interpreters:", e);
}
},
// API: загрузка дерева с сервера
fetchTree: async () => {
try {
const data = await scriptsApi.getTree();
const { expandedFolders } = get();
const convertItem = (item: any): FileNode => {
const node: FileNode = {
id: item.id,
name: item.name,
type: item.type === "folder" ? "folder" : "file",
content: item.content || "",
path: item.name,
interpreter_id: item.interpreter_id,
};
if (item.type === "folder") {
node.children = [];
if (item.children && Array.isArray(item.children)) {
node.children = item.children.map((child: any) => {
const childNode = convertItem(child);
childNode.path = `${item.name}/${child.name}`;
return childNode;
});
}
}
return node;
};
const roots = data.map((item) => convertItem(item));
set({
files: {
name: "scripts",
type: "folder",
children: roots,
},
expandedFolders,
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: создание папки
createFolder: async (path: string) => {
try {
await scriptsApi.createFolder(path);
await get().fetchTree();
} catch (e) {
console.error("Failed to create folder:", e);
throw e;
}
},
// API: удаление папки
deleteFolder: async ({ path }: { path: string }) => {
try {
const { openFiles } = get();
// Закрываем все файлы, которые находятся в удаляемой папке
const folderPathPrefix = path.endsWith("/") ? path : `${path}/`;
const filesToClose = openFiles.filter(
(f) => f.path === path || f.path?.startsWith(folderPathPrefix),
);
filesToClose.forEach((f) => get().closeFile(f));
await scriptsApi.deleteFolder(path);
await get().fetchTree();
} catch (e) {
console.error("Failed to delete folder:", 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 });
},
toggleSearch: () => {
set((state) => ({ showSearch: !state.showSearch }));
},
// Контекстные меню и диалоги
setContextMenu: (menu) => set({ contextMenu: menu }),
setDialog: (dialog) => set({ dialog: dialog }),
setTabContextMenu: (menu) => set({ tabContextMenu: menu }),
// Подтверждение диалога
handleDialogConfirm: async (value: string, interpreterId?: number) => {
const { dialog, files, toggleFolder, autoExpandPaths } = get();
if (!dialog) return;
if (dialog.type === "rename" && dialog.node) {
const parentPath =
dialog.node.path?.split("/").slice(0, -1).join("/") || "";
const parentNode = parentPath ? findNode(files!, parentPath) : files;
if (
parentNode?.children?.some(
(c) =>
c.name.toLowerCase() === value.toLowerCase() &&
c.path !== dialog.node?.path,
)
) {
alert(`"${value}" already exists.`);
return;
}
const newFiles = renameNode(
files!,
dialog.node.path || dialog.node.name,
value,
);
if (newFiles) {
set({ files: newFiles });
}
set({ dialog: null });
return;
}
// Определяем родительский путь
let parentPath: string;
if (!dialog.node) {
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("/");
}
// Проверяем наличие расширения
const hasExtension =
value.includes(".") && value.split(".").pop() !== value;
let finalName = value;
let isFile = false;
// Если диалог создания файла
if (dialog.type === "newFile") {
isFile = true;
// Если нет расширения — добавляем .txt
if (!hasExtension) {
finalName = `${value}.txt`;
}
} else if (dialog.type === "newFolder") {
// Если диалог создания папки — но имя с расширением, считаем файлом
if (hasExtension) {
isFile = true;
}
}
const fullPath = parentPath ? `${parentPath}/${finalName}` : finalName;
// Сохраняем раскрытые папки ДО перезагрузки дерева
const savedExpandedFolders = new Set(get().expandedFolders);
try {
// Создание папки
if (dialog.type === "newFolder" && !isFile) {
await scriptsApi.createFolder(fullPath);
await get().fetchTree();
// Восстанавливаем раскрытые папки
set({ expandedFolders: savedExpandedFolders });
// Собираем все пути от корня до родительской папки
const allParentPaths: string[] = [];
let current = parentPath;
while (current) {
allParentPaths.push(current);
const parts = current.split("/");
parts.pop();
current = parts.join("/");
}
// Раскрываем родительскую цепочку
autoExpandPaths(new Set(allParentPaths));
} else {
// Создание файла
const result = await scriptsApi.createScript({
content: "",
interpreter_id: interpreterId || 0,
path: fullPath,
});
await get().fetchTree();
// Восстанавливаем раскрытые папки
set({ expandedFolders: savedExpandedFolders });
// Собираем все пути от корня до родительской папки
const allParentPaths: string[] = [];
let current = parentPath;
while (current) {
allParentPaths.push(current);
const parts = current.split("/");
parts.pop();
current = parts.join("/");
}
// Раскрываем родительскую цепочку
autoExpandPaths(new Set(allParentPaths));
const createdNode: FileNode = {
id: result.id,
name: finalName,
type: "file",
content: result.content,
path: result.path,
interpreter_id: result.interpreter_id,
};
get().selectFile(createdNode);
}
} catch (e) {
console.error("Failed to create:", e);
}
set({ dialog: null });
},
// Удаление узла
handleDeleteNode: async (node: FileNode) => {
const { files } = get();
const isRootNode = node.path === files?.path;
if (isRootNode) {
get().deleteRoot();
} else if (window.confirm(`Delete "${node.name}"?`)) {
try {
if (node.type === "folder") {
await get().deleteFolder({ path: node.path || node.name });
} else if (node.id) {
await get().deleteScript(node.id);
}
} catch (e) {
console.error("Failed to delete:", e);
}
}
},
}));