611 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
},
|
|
}));
|