Compare commits
2 Commits
57b43da2e3
...
f537f1eab9
| Author | SHA1 | Date | |
|---|---|---|---|
| f537f1eab9 | |||
| 9d1096a9b4 |
Generated
+7152
File diff suppressed because it is too large
Load Diff
@@ -11,19 +11,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@uiw/react-codemirror": "^4.25.8",
|
"@uiw/react-codemirror": "^4.25.8",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"file-surf": "^1.0.3",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"monaco-languageclient": "^10.7.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.7",
|
"primereact": "^10.9.7",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-force-graph-2d": "^1.29.1",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"tailwind": "^4.0.0",
|
"tailwind": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
|
"vscode-ws-jsonrpc": "^3.5.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useAuthStore } from "@/store/auth/auth.store";
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
// if (!isAuthenticated) {
|
||||||
return <Navigate to="/auth" replace />;
|
// return <Navigate to="/auth" replace />;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,105 @@ import { Suspense } from "react";
|
|||||||
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
||||||
import { HomePage } from "@/pages/home.page";
|
import { HomePage } from "@/pages/home.page";
|
||||||
import { ThemesPage } from "@/pages/themes.page";
|
import { ThemesPage } from "@/pages/themes.page";
|
||||||
|
import { TestPage } from "@/pages/test.page";
|
||||||
|
import { Test2Page, type GraphData } from "@/pages/test2.page";
|
||||||
import { AuthPage } from "@/pages/auth.page";
|
import { AuthPage } from "@/pages/auth.page";
|
||||||
import { RegisterPage } from "@/pages/register.page";
|
import { RegisterPage } from "@/pages/register.page";
|
||||||
import { AddAgentsPage } from "@/pages/add-agents.page";
|
|
||||||
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
||||||
|
import { AddAgentsPage } from "@/pages/add-agents.page";
|
||||||
|
import { IDEPage } from "@/pages/ide.page";
|
||||||
|
|
||||||
|
export const mockGraphData: GraphData = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "api-gateway",
|
||||||
|
name: "API Gateway",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Входная точка API",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "auth-service",
|
||||||
|
name: "Auth Service",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Аутентификация",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "db-service",
|
||||||
|
name: "Database",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Хранилище данных",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redis-service",
|
||||||
|
name: "Redis",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Кэширование",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "queue-service",
|
||||||
|
name: "Message Queue",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Очередь сообщений",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-agent",
|
||||||
|
name: "User Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Обработка пользователей",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "payment-agent",
|
||||||
|
name: "Payment Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Платежи",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notification-agent",
|
||||||
|
name: "Notification Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Уведомления",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "analytics-agent",
|
||||||
|
name: "Analytics Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Аналитика",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "report-agent",
|
||||||
|
name: "Report Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Отчеты",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ source: "user-agent", target: "api-gateway", type: "uses" },
|
||||||
|
{ source: "user-agent", target: "auth-service", type: "uses" },
|
||||||
|
{ source: "user-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "api-gateway", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "auth-service", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "queue-service", type: "uses" },
|
||||||
|
{ source: "notification-agent", target: "redis-service", type: "uses" },
|
||||||
|
{ source: "notification-agent", target: "queue-service", type: "uses" },
|
||||||
|
{ source: "analytics-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "report-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "report-agent", target: "redis-service", type: "uses" },
|
||||||
|
{ source: "api-gateway", target: "auth-service", type: "depends_on" },
|
||||||
|
{ source: "auth-service", target: "db-service", type: "depends_on" },
|
||||||
|
{ source: "api-gateway", target: "queue-service", type: "depends_on" },
|
||||||
|
{ source: "queue-service", target: "redis-service", type: "depends_on" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const Routing = () => {
|
export const Routing = () => {
|
||||||
return (
|
return (
|
||||||
@@ -17,17 +112,20 @@ export const Routing = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReactRoutes>
|
<ReactRoutes>
|
||||||
{/* Публичные маршруты */}
|
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
|
||||||
{/* Защищённые маршруты с Layout */}
|
|
||||||
<Route element={<DefaultLayout />}>
|
<Route element={<DefaultLayout />}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/themes" element={<ThemesPage />} />
|
<Route path="/themes" element={<ThemesPage />} />
|
||||||
<Route path="/add-agents" element={<AddAgentsPage />} />
|
<Route path="/add-agents" element={<AddAgentsPage />} />
|
||||||
|
<Route path="/IDE" element={<IDEPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/test" element={<TestPage />} />
|
||||||
|
|
||||||
|
<Route path="/test2" element={<Test2Page data={mockGraphData} />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</ReactRoutes>
|
</ReactRoutes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { MdAdd } from "react-icons/md";
|
||||||
|
import { GoTrash } from "react-icons/go";
|
||||||
|
import {
|
||||||
|
useIDEStore,
|
||||||
|
initialFiles as defaultInitialFiles,
|
||||||
|
} from "./store/useIDEStore";
|
||||||
|
import type { FileNode } from "./types";
|
||||||
|
import {
|
||||||
|
FileExplorer,
|
||||||
|
TabBar,
|
||||||
|
CodeEditor,
|
||||||
|
TitleBar,
|
||||||
|
StatusBar,
|
||||||
|
} from "./components";
|
||||||
|
|
||||||
|
interface IDEProps {
|
||||||
|
initialFiles?: FileNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IDE: React.FC<IDEProps> = ({
|
||||||
|
initialFiles: externalFiles,
|
||||||
|
}: IDEProps = {}) => {
|
||||||
|
const files = useIDEStore((state) => state.files);
|
||||||
|
const openFiles = useIDEStore((state) => state.openFiles);
|
||||||
|
const activeFile = useIDEStore((state) => state.activeFile);
|
||||||
|
const createNewProject = useIDEStore((state) => state.createNewProject);
|
||||||
|
const selectFile = useIDEStore((state) => state.selectFile);
|
||||||
|
const updateFileContent = useIDEStore((state) => state.updateFileContent);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Инициализация файлов
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) {
|
||||||
|
const filesToInit = externalFiles || defaultInitialFiles;
|
||||||
|
initialize(filesToInit);
|
||||||
|
}
|
||||||
|
}, [isInitialized, externalFiles, initialize]);
|
||||||
|
|
||||||
|
// Если проект не открыт
|
||||||
|
if (!files) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TitleBar />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "24px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GoTrash size={72} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "22px",
|
||||||
|
marginBottom: "12px",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontWeight: 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No project open
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
color: "#858585",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create a new project to get started
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={createNewProject}
|
||||||
|
style={{
|
||||||
|
padding: "10px 24px",
|
||||||
|
backgroundColor: "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#1177bb";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#0e639c";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdAdd size={14} /> New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar activeFile={null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "30px",
|
||||||
|
backgroundColor: "#323233",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderBottom: "1px solid #1e1e1e",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#cccccc",
|
||||||
|
userSelect: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 400 }}>
|
||||||
|
{activeFile ? `${activeFile.name} - ` : ""}
|
||||||
|
{files.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||||
|
<div style={{ width: "260px", flexShrink: 0 }}>
|
||||||
|
<FileExplorer
|
||||||
|
files={files}
|
||||||
|
onDeleteRoot={useIDEStore.getState().deleteRoot}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabBar
|
||||||
|
openFiles={openFiles}
|
||||||
|
activeFile={activeFile}
|
||||||
|
onSelectFile={selectFile}
|
||||||
|
onCloseFile={closeFile}
|
||||||
|
onCloseAll={closeAllFiles}
|
||||||
|
onCloseOthers={closeOtherFiles}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
filePath={activeFile?.path || ""}
|
||||||
|
content={activeFile?.content || ""}
|
||||||
|
onChange={updateFileContent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar activeFile={activeFile} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IDE;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
import { FiFolder } from "react-icons/fi";
|
||||||
|
import { getLanguage } from "../helpers/fileTree";
|
||||||
|
|
||||||
|
interface CodeEditorProps {
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||||
|
filePath,
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{filePath ? (
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language={getLanguage(filePath)}
|
||||||
|
value={content}
|
||||||
|
onChange={(value) => onChange(value || "")}
|
||||||
|
theme="vs-dark"
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'Cascadia Code', 'Fira Code', monospace",
|
||||||
|
tabSize: 4,
|
||||||
|
wordWrap: "on",
|
||||||
|
lineNumbers: "on",
|
||||||
|
automaticLayout: true,
|
||||||
|
renderWhitespace: "selection",
|
||||||
|
smoothScrolling: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
color: "#858585",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "24px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiFolder size={64} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "18px",
|
||||||
|
marginBottom: "12px",
|
||||||
|
color: "#cccccc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Welcome to Web VS Code
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "13px", marginBottom: "8px" }}>
|
||||||
|
Right-click on a folder to create files
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#0e639c" }}>
|
||||||
|
Or right-click anywhere in the explorer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { FiFile, FiFolder, FiEdit3, FiTrash2 } from "react-icons/fi";
|
||||||
|
|
||||||
|
const MenuItem: React.FC<{
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ onClick, danger, children }) => (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: danger ? "#f48771" : "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onNewFile: () => void;
|
||||||
|
onNewFolder: () => void;
|
||||||
|
onRename: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
hasNode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
onNewFile,
|
||||||
|
onNewFolder,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
hasNode,
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = () => onClose();
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
return () => document.removeEventListener("click", handleClick);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: "180px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={onNewFile}>
|
||||||
|
<FiFile /> New File
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={onNewFolder}>
|
||||||
|
<FiFolder /> New Folder
|
||||||
|
</MenuItem>
|
||||||
|
{hasNode && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "1px",
|
||||||
|
backgroundColor: "#3e3e42",
|
||||||
|
margin: "4px 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem onClick={onRename}>
|
||||||
|
<FiEdit3 /> Rename
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={onDelete} danger>
|
||||||
|
<FiTrash2 /> Delete
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { FiSearch, FiFile, FiFolder, FiMinus } from "react-icons/fi";
|
||||||
|
import { GoKebabHorizontal } from "react-icons/go";
|
||||||
|
import { MdClose, MdAdd } from "react-icons/md";
|
||||||
|
import { FileTreeItem } from "./FileTreeItem";
|
||||||
|
import { ContextMenu } from "./ContextMenu";
|
||||||
|
import { InputDialog } from "./InputDialog";
|
||||||
|
import { filterTree, collectPathsToExpand } from "../helpers/fileTree";
|
||||||
|
import { useIDEStore } from "../store/useIDEStore";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface FileExplorerProps {
|
||||||
|
files: FileNode;
|
||||||
|
onDeleteRoot: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileExplorer: React.FC<FileExplorerProps> = ({
|
||||||
|
files,
|
||||||
|
onDeleteRoot,
|
||||||
|
}) => {
|
||||||
|
const store = useIDEStore();
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
|
||||||
|
const handleEmptyContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
store.setContextMenu({ x: e.clientX, y: e.clientY, node: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeContextMenu = (e: React.MouseEvent, node: FileNode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
store.setContextMenu({ x: e.clientX, y: e.clientY, node });
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredFiles = store.searchQuery
|
||||||
|
? filterTree(files, store.searchQuery)
|
||||||
|
: files;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (store.searchQuery && files) {
|
||||||
|
const pathsToExpand = collectPathsToExpand(files, store.searchQuery);
|
||||||
|
if (pathsToExpand.size > 0) {
|
||||||
|
store.autoExpandPaths(pathsToExpand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [store.searchQuery, files, store.autoExpandPaths]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
}}
|
||||||
|
onContextMenu={handleEmptyContextMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0 8px",
|
||||||
|
height: "35px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#bbbbbb",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "11px",
|
||||||
|
letterSpacing: "0.8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
EXPLORER
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", gap: "2px", alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSearch(!showSearch)}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: showSearch ? "#cccccc" : "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
title="Search in files"
|
||||||
|
>
|
||||||
|
<FiSearch size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={store.collapseAllFolders}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
e.currentTarget.style.color = "#cccccc";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
}}
|
||||||
|
title="Collapse All"
|
||||||
|
>
|
||||||
|
<FiMinus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={store.expandAllFolders}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
e.currentTarget.style.color = "#cccccc";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
}}
|
||||||
|
title="Expand All"
|
||||||
|
>
|
||||||
|
<GoKebabHorizontal size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiFolder size={14} color="#858585" />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#cccccc",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "11px",
|
||||||
|
letterSpacing: "0.3px",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{files.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
border: store.searchQuery
|
||||||
|
? "1px solid #007acc"
|
||||||
|
: "1px solid transparent",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "0 6px",
|
||||||
|
transition: "border-color 0.1s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiSearch size={13} color="#858585" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={store.searchQuery}
|
||||||
|
onChange={(e) => store.setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "5px 6px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontSize: "12px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{store.searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => store.setSearchQuery("")}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||||
|
{filteredFiles ? (
|
||||||
|
<FileTreeItem
|
||||||
|
node={filteredFiles}
|
||||||
|
level={0}
|
||||||
|
onFileSelect={store.selectFile}
|
||||||
|
selectedFile={store.activeFile?.path || null}
|
||||||
|
onContextMenu={handleNodeContextMenu}
|
||||||
|
expandedFolders={store.expandedFolders}
|
||||||
|
onToggleFolder={store.toggleFolder}
|
||||||
|
onDelete={store.handleDeleteNode}
|
||||||
|
isRoot
|
||||||
|
searchQuery={store.searchQuery}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
color: "#858585",
|
||||||
|
fontSize: "13px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{store.contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={store.contextMenu.x}
|
||||||
|
y={store.contextMenu.y}
|
||||||
|
onClose={() => store.setContextMenu(null)}
|
||||||
|
onNewFile={() => {
|
||||||
|
store.setDialog({
|
||||||
|
type: "newFile",
|
||||||
|
node: store.contextMenu?.node || null,
|
||||||
|
});
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onNewFolder={() => {
|
||||||
|
store.setDialog({
|
||||||
|
type: "newFolder",
|
||||||
|
node: store.contextMenu?.node || null,
|
||||||
|
});
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onRename={() => {
|
||||||
|
store.setDialog({
|
||||||
|
type: "rename",
|
||||||
|
node: store.contextMenu?.node || null,
|
||||||
|
});
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
if (store.contextMenu?.node) {
|
||||||
|
store.handleDeleteNode(store.contextMenu.node);
|
||||||
|
}
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
hasNode={!!store.contextMenu.node}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{store.dialog && (
|
||||||
|
<InputDialog
|
||||||
|
title={
|
||||||
|
store.dialog.type === "newFile"
|
||||||
|
? "New File"
|
||||||
|
: store.dialog.type === "newFolder"
|
||||||
|
? "New Folder"
|
||||||
|
: "Rename"
|
||||||
|
}
|
||||||
|
initialValue={
|
||||||
|
store.dialog.type === "rename" && store.dialog.node
|
||||||
|
? store.dialog.node.name
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onConfirm={store.handleDialogConfirm}
|
||||||
|
onCancel={() => store.setDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { FiChevronRight, FiChevronDown, FiTrash2 } from "react-icons/fi";
|
||||||
|
import { GoFile } from "react-icons/go";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface FileTreeItemProps {
|
||||||
|
node: FileNode;
|
||||||
|
level: number;
|
||||||
|
onFileSelect: (node: FileNode) => void;
|
||||||
|
selectedFile: string | null;
|
||||||
|
onContextMenu: (e: React.MouseEvent, node: FileNode) => void;
|
||||||
|
expandedFolders: Set<string>;
|
||||||
|
onToggleFolder: (path: string) => void;
|
||||||
|
onDelete: (node: FileNode) => void;
|
||||||
|
isRoot?: boolean;
|
||||||
|
searchQuery?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileTreeItem: React.FC<FileTreeItemProps> = ({
|
||||||
|
node,
|
||||||
|
level,
|
||||||
|
onFileSelect,
|
||||||
|
selectedFile,
|
||||||
|
onContextMenu,
|
||||||
|
expandedFolders,
|
||||||
|
onToggleFolder,
|
||||||
|
onDelete,
|
||||||
|
isRoot,
|
||||||
|
searchQuery,
|
||||||
|
}) => {
|
||||||
|
const isFolder = node.type === "folder";
|
||||||
|
const isSelected = selectedFile === node.path && !isFolder;
|
||||||
|
const isExpanded = expandedFolders.has(node.path || node.name);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isFolder) {
|
||||||
|
onToggleFolder(node.path || node.name);
|
||||||
|
} else {
|
||||||
|
onFileSelect(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightText = (text: string, query: string) => {
|
||||||
|
if (!query) return text;
|
||||||
|
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||||
|
if (idx === -1) return text;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{text.slice(0, idx)}
|
||||||
|
<span style={{ backgroundColor: "#613214", color: "#f9f9a4" }}>
|
||||||
|
{text.slice(idx, idx + query.length)}
|
||||||
|
</span>
|
||||||
|
{text.slice(idx + query.length)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
onContextMenu={(e) => onContextMenu(e, node)}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
paddingLeft: isRoot ? "8px" : `${level * 16 + 8}px`,
|
||||||
|
paddingTop: "4px",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
backgroundColor: isSelected ? "#094771" : "transparent",
|
||||||
|
color: isSelected ? "#fff" : "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
userSelect: "none",
|
||||||
|
minHeight: "28px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
width: "16px",
|
||||||
|
textAlign: "center",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFolder ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<FiChevronDown />
|
||||||
|
) : (
|
||||||
|
<FiChevronRight />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<GoFile />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchQuery ? highlightText(node.name, searchQuery) : node.name}
|
||||||
|
</span>
|
||||||
|
{hovered && !isRoot && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
title={`Delete ${node.name}`}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "3px",
|
||||||
|
flexShrink: 0,
|
||||||
|
width: "20px",
|
||||||
|
height: "20px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "#f48771";
|
||||||
|
e.currentTarget.style.backgroundColor = "#3e3e42";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTrash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isFolder && isExpanded && node.children && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child, idx) => (
|
||||||
|
<FileTreeItem
|
||||||
|
key={idx}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
|
onDelete={onDelete}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface InputDialogProps {
|
||||||
|
title: string;
|
||||||
|
initialValue?: string;
|
||||||
|
onConfirm: (value: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputDialog: React.FC<InputDialogProps> = ({
|
||||||
|
title,
|
||||||
|
initialValue = "",
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
}}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#2d2d30",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "24px",
|
||||||
|
minWidth: "320px",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}>
|
||||||
|
Enter a new name
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
e.key === "Enter" && value.trim() && onConfirm(value.trim())
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "14px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "1px solid #0e639c",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#0e639c",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => value.trim() && onConfirm(value.trim())}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
backgroundColor: "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiGitBranch, FiCheckCircle, FiAlertCircle } from "react-icons/fi";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface StatusBarProps {
|
||||||
|
activeFile: FileNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBar: React.FC<StatusBarProps> = ({ activeFile }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "22px",
|
||||||
|
backgroundColor: "#007acc",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#ffffff",
|
||||||
|
userSelect: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||||
|
<FiGitBranch size={12} /> main
|
||||||
|
</span>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FiCheckCircle size={12} /> 0 <FiAlertCircle size={12} /> 0
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||||
|
{activeFile && (
|
||||||
|
<span>
|
||||||
|
Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "}
|
||||||
|
{activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>Web VS Code</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { GoFile } from "react-icons/go";
|
||||||
|
import { MdClose } from "react-icons/md";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
openFiles: FileNode[];
|
||||||
|
activeFile: FileNode | null;
|
||||||
|
onSelectFile: (file: FileNode) => void;
|
||||||
|
onCloseFile: (file: FileNode) => void;
|
||||||
|
onCloseAll: () => void;
|
||||||
|
onCloseOthers: (file: FileNode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabBar: React.FC<TabBarProps> = ({
|
||||||
|
openFiles,
|
||||||
|
activeFile,
|
||||||
|
onSelectFile,
|
||||||
|
onCloseFile,
|
||||||
|
onCloseAll,
|
||||||
|
onCloseOthers,
|
||||||
|
}) => {
|
||||||
|
const [showContextMenu, setShowContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
file: FileNode;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent, file: FileNode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowContextMenu({ x: e.clientX, y: e.clientY, file });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
overflowX: "auto",
|
||||||
|
minHeight: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
padding: "0 12px",
|
||||||
|
gap: "8px",
|
||||||
|
borderRight: "1px solid #3e3e42",
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onCloseAll}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#cccccc",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
title="Close All"
|
||||||
|
>
|
||||||
|
<MdClose size={14} />
|
||||||
|
<span style={{ fontSize: "11px" }}>Close All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{openFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
onClick={() => onSelectFile(file)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 16px",
|
||||||
|
backgroundColor:
|
||||||
|
activeFile?.path === file.path ? "#1e1e1e" : "#2d2d30",
|
||||||
|
color: activeFile?.path === file.path ? "#fff" : "#cccccc",
|
||||||
|
borderRight: "1px solid #3e3e42",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
gap: "10px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
borderTop:
|
||||||
|
activeFile?.path === file.path
|
||||||
|
? "2px solid #0e639c"
|
||||||
|
: "2px solid transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GoFile />
|
||||||
|
<span>{file.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCloseFile(file);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "16px",
|
||||||
|
padding: "0 4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
e.currentTarget.style.backgroundColor = "#3e3e42";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showContextMenu && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: showContextMenu.y,
|
||||||
|
left: showContextMenu.x,
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: "160px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
onCloseOthers(showContextMenu.file);
|
||||||
|
setShowContextMenu(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close Others
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
onCloseAll();
|
||||||
|
setShowContextMenu(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close All
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiGitBranch, FiCheckCircle } from "react-icons/fi";
|
||||||
|
|
||||||
|
export const TitleBar: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "32px",
|
||||||
|
backgroundColor: "#2d2d30",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#ed6a5e",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#f5bd4f",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#61c454",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: "#cccccc", fontSize: "12px", fontWeight: 500 }}>
|
||||||
|
Web VS Code
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FiGitBranch size={12} color="#858585" />
|
||||||
|
<span style={{ color: "#858585", fontSize: "11px" }}>main</span>
|
||||||
|
<FiCheckCircle size={12} color="#61c454" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { ContextMenu } from "./ContextMenu";
|
||||||
|
export { InputDialog } from "./InputDialog";
|
||||||
|
export { FileTreeItem } from "./FileTreeItem";
|
||||||
|
export { FileExplorer } from "./FileExplorer";
|
||||||
|
export { TabBar } from "./TabBar";
|
||||||
|
export { CodeEditor } from "./CodeEditor";
|
||||||
|
export { TitleBar } from "./TitleBar";
|
||||||
|
export { StatusBar } from "./StatusBar";
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
export const addPaths = (node: FileNode, parentPath: string = ""): FileNode => {
|
||||||
|
const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||||
|
const newNode = { ...node, path: currentPath };
|
||||||
|
if (newNode.children) {
|
||||||
|
newNode.children = newNode.children.map((child) =>
|
||||||
|
addPaths(child, currentPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return newNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllFolderPaths = (node: FileNode): string[] => {
|
||||||
|
let paths: string[] = [];
|
||||||
|
if (node.type === "folder") {
|
||||||
|
paths.push(node.path || node.name);
|
||||||
|
if (node.children) {
|
||||||
|
node.children.forEach((child) => {
|
||||||
|
paths = [...paths, ...getAllFolderPaths(child)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findNode = (node: FileNode, path: string): FileNode | null => {
|
||||||
|
if (node.path === path) return node;
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
const found = findNode(child, path);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteNode = (node: FileNode, path: string): FileNode | null => {
|
||||||
|
if (node.path === path) return null;
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const filtered = node.children.filter((child) => child.path !== path);
|
||||||
|
const mapped = filtered
|
||||||
|
.map((child) => deleteNode(child, path))
|
||||||
|
.filter((child): child is FileNode => child !== null);
|
||||||
|
return { ...node, children: mapped };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addNode = (
|
||||||
|
node: FileNode,
|
||||||
|
parentPath: string,
|
||||||
|
newNode: FileNode,
|
||||||
|
): FileNode => {
|
||||||
|
if (node.path === parentPath) {
|
||||||
|
const newPath = addPaths(newNode, node.path);
|
||||||
|
return { ...node, children: [...(node.children || []), newPath] };
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: node.children.map((child) =>
|
||||||
|
addNode(child, parentPath, newNode),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renameNode = (
|
||||||
|
node: FileNode,
|
||||||
|
oldPath: string,
|
||||||
|
newName: string,
|
||||||
|
): FileNode | null => {
|
||||||
|
if (node.path === oldPath) {
|
||||||
|
const pathParts = node.path?.split("/") || [];
|
||||||
|
pathParts[pathParts.length - 1] = newName;
|
||||||
|
const newPath = pathParts.join("/");
|
||||||
|
const renamedNode = { ...node, name: newName, path: newPath };
|
||||||
|
|
||||||
|
if (renamedNode.children) {
|
||||||
|
renamedNode.children = renamedNode.children.map((child) => {
|
||||||
|
const oldChildPath = child.path || "";
|
||||||
|
const newChildPath = oldChildPath.replace(oldPath, newPath);
|
||||||
|
return (
|
||||||
|
renameNode(
|
||||||
|
child,
|
||||||
|
oldChildPath,
|
||||||
|
newChildPath.split("/").pop() || "",
|
||||||
|
) || child
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return renamedNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: node.children.map(
|
||||||
|
(child) => renameNode(child, oldPath, newName) || child,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterTree = (node: FileNode, query: string): FileNode | null => {
|
||||||
|
if (!query) return node;
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
if (node.type === "file") {
|
||||||
|
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const filteredChildren = node.children
|
||||||
|
.map((child) => filterTree(child, query))
|
||||||
|
.filter((child): child is FileNode => child !== null);
|
||||||
|
|
||||||
|
if (filteredChildren.length > 0) {
|
||||||
|
return { ...node, children: filteredChildren };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectPathsToExpand = (
|
||||||
|
node: FileNode,
|
||||||
|
query: string,
|
||||||
|
): Set<string> => {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
if (!query) return paths;
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
const search = (n: FileNode, currentPath: string) => {
|
||||||
|
if (n.name.toLowerCase().includes(lowerQuery)) {
|
||||||
|
const pathParts = currentPath.split("/");
|
||||||
|
for (let i = 1; i < pathParts.length; i++) {
|
||||||
|
paths.add(pathParts.slice(0, i).join("/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.children) {
|
||||||
|
n.children.forEach((child) => {
|
||||||
|
const childPath = child.path || `${currentPath}/${child.name}`;
|
||||||
|
search(child, childPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
search(node, node.path || node.name);
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLanguage = (path: string) => {
|
||||||
|
const ext = path.split(".").pop();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
py: "python",
|
||||||
|
js: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
jsx: "javascript",
|
||||||
|
tsx: "typescript",
|
||||||
|
json: "json",
|
||||||
|
md: "markdown",
|
||||||
|
css: "css",
|
||||||
|
html: "html",
|
||||||
|
};
|
||||||
|
return map[ext || ""] || "plaintext";
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { IDE } from "./IDE";
|
||||||
|
export { useIDEStore, initialFiles } from "./store/useIDEStore";
|
||||||
|
export type { FileNode } from "./types";
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
import {
|
||||||
|
addPaths,
|
||||||
|
getAllFolderPaths,
|
||||||
|
findNode,
|
||||||
|
deleteNode,
|
||||||
|
addNode,
|
||||||
|
renameNode,
|
||||||
|
} from "../helpers/fileTree";
|
||||||
|
|
||||||
|
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 IDEState {
|
||||||
|
// Файловая система
|
||||||
|
files: FileNode | null;
|
||||||
|
openFiles: FileNode[];
|
||||||
|
activeFile: FileNode | null;
|
||||||
|
expandedFolders: Set<string>;
|
||||||
|
searchQuery: string;
|
||||||
|
showSearch: boolean;
|
||||||
|
isInitialized: boolean;
|
||||||
|
|
||||||
|
// Диалоги и контекстные меню
|
||||||
|
contextMenu: { x: number; y: number; node: FileNode | null } | null;
|
||||||
|
dialog: {
|
||||||
|
type: "newFile" | "newFolder" | "rename";
|
||||||
|
node: FileNode | null;
|
||||||
|
} | null;
|
||||||
|
tabContextMenu: { x: number; y: number; file: FileNode } | null;
|
||||||
|
|
||||||
|
// Действия с файлами
|
||||||
|
selectFile: (node: FileNode) => void;
|
||||||
|
updateFileContent: (content: string) => 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;
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
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) => void;
|
||||||
|
handleDeleteNode: (node: FileNode) => 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,
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
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 } = get();
|
||||||
|
if (!openFiles.find((f) => f.path === node.path)) {
|
||||||
|
set((state) => ({ openFiles: [...state.openFiles, node] }));
|
||||||
|
}
|
||||||
|
set({ activeFile: node });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновление содержимого файла
|
||||||
|
updateFileContent: (content: string) => {
|
||||||
|
const { activeFile } = get();
|
||||||
|
if (activeFile) {
|
||||||
|
const updatedFile = { ...activeFile, content };
|
||||||
|
set({ activeFile: updatedFile });
|
||||||
|
set((state) => ({
|
||||||
|
openFiles: state.openFiles.map((f) =>
|
||||||
|
f.path === activeFile.path ? updatedFile : f,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Закрытие файла
|
||||||
|
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: "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
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: (value: string) => {
|
||||||
|
const { dialog, files, refreshFiles, 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) {
|
||||||
|
refreshFiles(newFiles);
|
||||||
|
}
|
||||||
|
set({ dialog: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parentPath: string;
|
||||||
|
|
||||||
|
if (!dialog.node) {
|
||||||
|
parentPath = files!.path || files!.name;
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newFiles: FileNode | null = null;
|
||||||
|
let createdNode: FileNode | null = null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newFiles) {
|
||||||
|
const allParentPaths: string[] = [];
|
||||||
|
let current = parentPath;
|
||||||
|
while (current) {
|
||||||
|
allParentPaths.push(current);
|
||||||
|
const parts = current.split("/");
|
||||||
|
parts.pop();
|
||||||
|
current = parts.join("/");
|
||||||
|
}
|
||||||
|
allParentPaths.forEach((p) => {
|
||||||
|
if (!get().expandedFolders.has(p)) {
|
||||||
|
toggleFolder(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
const openedFile = findAndOpen(newFiles, value);
|
||||||
|
refreshFiles(newFiles, openedFile || undefined);
|
||||||
|
} else {
|
||||||
|
refreshFiles(newFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ dialog: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление узла
|
||||||
|
handleDeleteNode: (node: FileNode) => {
|
||||||
|
const { files, refreshFiles } = 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface FileNode {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "folder";
|
||||||
|
content?: string;
|
||||||
|
children?: FileNode[];
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
node: FileNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogState {
|
||||||
|
type: "newFile" | "newFolder" | "rename";
|
||||||
|
node: FileNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
file: FileNode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IDE } from "../modules/ide";
|
||||||
|
|
||||||
|
export const IDEPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full z-90">
|
||||||
|
<IDE initialFiles={{ name: "тест", type: "folder" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,534 @@
|
|||||||
|
import React, { useRef, useState, useEffect } from "react";
|
||||||
|
import ForceGraph2D from "react-force-graph-2d";
|
||||||
|
import {
|
||||||
|
FiDownload,
|
||||||
|
FiZoomIn,
|
||||||
|
FiZoomOut,
|
||||||
|
FiMove,
|
||||||
|
FiCpu,
|
||||||
|
FiServer,
|
||||||
|
FiPlus,
|
||||||
|
FiTrash2,
|
||||||
|
FiLink,
|
||||||
|
FiMinusCircle,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
|
||||||
|
interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "agent" | "service";
|
||||||
|
val?: number;
|
||||||
|
description?: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphLink {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphData {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
links: GraphLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomGraphProps {
|
||||||
|
data: GraphData;
|
||||||
|
onExport?: () => void;
|
||||||
|
onDataChange?: (newData: GraphData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Test2Page: React.FC<CustomGraphProps> = ({
|
||||||
|
data: initialData,
|
||||||
|
onExport,
|
||||||
|
onDataChange,
|
||||||
|
}) => {
|
||||||
|
const fgRef = useRef<any>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [data, setData] = useState<GraphData>(initialData);
|
||||||
|
const [highlightNodes, setHighlightNodes] = useState<Set<string>>(new Set());
|
||||||
|
const [highlightLinks, setHighlightLinks] = useState<Set<any>>(new Set());
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
const [isLinkMode, setIsLinkMode] = useState(false);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
node: GraphNode | null;
|
||||||
|
link: GraphLink | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) setData(initialData);
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// Отслеживаем размеры контейнера через ResizeObserver
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
|
setDimensions({
|
||||||
|
width: container.clientWidth,
|
||||||
|
height: container.clientHeight || window.innerHeight - 160,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(updateDimensions);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Функция для подсветки связанных элементов
|
||||||
|
const handleNodeHover = (node: GraphNode | null) => {
|
||||||
|
const newHighlightNodes = new Set<string>();
|
||||||
|
const newHighlightLinks = new Set<any>();
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
newHighlightNodes.add(node.id);
|
||||||
|
data.links.forEach((link) => {
|
||||||
|
if (link.source === node.id || link.target === node.id) {
|
||||||
|
newHighlightLinks.add(link);
|
||||||
|
newHighlightNodes.add(link.source as string);
|
||||||
|
newHighlightNodes.add(link.target as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHighlightNodes(newHighlightNodes);
|
||||||
|
setHighlightLinks(newHighlightLinks);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик клика по узлу для создания связей
|
||||||
|
const handleNodeClick = (node: GraphNode) => {
|
||||||
|
if (isLinkMode) {
|
||||||
|
if (selectedNode === null) {
|
||||||
|
setSelectedNode(node);
|
||||||
|
} else if (selectedNode.id !== node.id) {
|
||||||
|
const newLink: GraphLink = {
|
||||||
|
source: selectedNode.id,
|
||||||
|
target: node.id,
|
||||||
|
type: "custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkExists = data.links.some(
|
||||||
|
(link) =>
|
||||||
|
(link.source === selectedNode.id && link.target === node.id) ||
|
||||||
|
(link.source === node.id && link.target === selectedNode.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!linkExists) {
|
||||||
|
const newData = {
|
||||||
|
nodes: [...data.nodes],
|
||||||
|
links: [...data.links, newLink],
|
||||||
|
};
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
}
|
||||||
|
setSelectedNode(null);
|
||||||
|
setIsLinkMode(false);
|
||||||
|
} else {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// УДАЛЕНИЕ СВЯЗИ
|
||||||
|
const handleDeleteLink = (linkToDelete: GraphLink) => {
|
||||||
|
const filteredLinks = data.links.filter((link) => link !== linkToDelete);
|
||||||
|
const newData = {
|
||||||
|
nodes: [...data.nodes],
|
||||||
|
links: filteredLinks,
|
||||||
|
};
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// УДАЛЕНИЕ УЗЛА
|
||||||
|
const handleDeleteNode = (nodeToDelete: GraphNode) => {
|
||||||
|
const filteredNodes = data.nodes.filter(
|
||||||
|
(node) => node.id !== nodeToDelete.id,
|
||||||
|
);
|
||||||
|
const filteredLinks = data.links.filter(
|
||||||
|
(link) =>
|
||||||
|
link.source !== nodeToDelete.id && link.target !== nodeToDelete.id,
|
||||||
|
);
|
||||||
|
const newData = {
|
||||||
|
nodes: filteredNodes,
|
||||||
|
links: filteredLinks,
|
||||||
|
};
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавление нового узла
|
||||||
|
const handleAddNode = () => {
|
||||||
|
const newNodeName = prompt(
|
||||||
|
"Введите имя узла:",
|
||||||
|
`Node ${data.nodes.length + 1}`,
|
||||||
|
);
|
||||||
|
if (newNodeName) {
|
||||||
|
const isService = window.confirm(
|
||||||
|
"Выберите тип: OK - Сервис, Отмена - Агент",
|
||||||
|
);
|
||||||
|
const newNode: GraphNode = {
|
||||||
|
id: `node-${Date.now()}`,
|
||||||
|
name: newNodeName,
|
||||||
|
type: isService ? "service" : "agent",
|
||||||
|
val: isService ? 12 : 8,
|
||||||
|
description: "Новый узел",
|
||||||
|
};
|
||||||
|
|
||||||
|
const newData = {
|
||||||
|
nodes: [...data.nodes, newNode],
|
||||||
|
links: [...data.links],
|
||||||
|
};
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Открытие контекстного меню
|
||||||
|
const openContextMenu = (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
node?: GraphNode,
|
||||||
|
link?: GraphLink,
|
||||||
|
) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, node, link: null });
|
||||||
|
} else if (link) {
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, node: null, link });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Закрыть контекстное меню
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = () => setContextMenu(null);
|
||||||
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("click", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Функция для определения цвета узла
|
||||||
|
const getNodeColor = (node: GraphNode) => {
|
||||||
|
if (highlightNodes.has(node.id)) return "#fbbf24";
|
||||||
|
if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case "service":
|
||||||
|
return "#3b82f6";
|
||||||
|
case "agent":
|
||||||
|
return "#8b5cf6";
|
||||||
|
default:
|
||||||
|
return "#6b7280";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для размера узла
|
||||||
|
const getNodeSize = (node: GraphNode) => {
|
||||||
|
switch (node.type) {
|
||||||
|
case "service":
|
||||||
|
return 3;
|
||||||
|
case "agent":
|
||||||
|
return 3;
|
||||||
|
default:
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Кастомный рендер узла
|
||||||
|
const renderNode = (
|
||||||
|
node: GraphNode,
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
globalScale: number,
|
||||||
|
) => {
|
||||||
|
const size = getNodeSize(node);
|
||||||
|
const color = getNodeColor(node);
|
||||||
|
|
||||||
|
if (!node.x || !node.y) return;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
|
||||||
|
if (node.type === "service") {
|
||||||
|
ctx.fillText("S", node.x, node.y);
|
||||||
|
} else if (node.type === "agent") {
|
||||||
|
ctx.fillText("A", node.x, node.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalScale > 0.5) {
|
||||||
|
ctx.fillStyle = "#e5e7eb";
|
||||||
|
ctx.font = `${Math.min(12, 12 / globalScale)}px "Arial", sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText(node.name, node.x, node.y + size + 8);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (onExport) {
|
||||||
|
onExport();
|
||||||
|
} else {
|
||||||
|
const dataStr = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([dataStr], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "graph-data.json";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
const currentZoom = fgRef.current.zoom();
|
||||||
|
fgRef.current.zoom(currentZoom * 1.2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
const currentZoom = fgRef.current.zoom();
|
||||||
|
fgRef.current.zoom(currentZoom / 1.2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFit = () => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
fgRef.current.zoomToFit(400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data || data.nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-400 mb-4">Нет данных для отображения</p>
|
||||||
|
<button
|
||||||
|
onClick={handleAddNode}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors text-white mx-auto"
|
||||||
|
>
|
||||||
|
<FiPlus /> Добавить первый узел
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="graph-container border border-gray-800 rounded-lg overflow-hidden relative"
|
||||||
|
style={{
|
||||||
|
height: "calc(100vh - 200px)",
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ForceGraph2D
|
||||||
|
ref={fgRef}
|
||||||
|
graphData={data}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
nodeCanvasObject={renderNode}
|
||||||
|
nodeLabel={(node: GraphNode) => {
|
||||||
|
return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`;
|
||||||
|
}}
|
||||||
|
linkLabel={(link: GraphLink) => {
|
||||||
|
// ВОЗВРАЩАЕМ СТРОКУ
|
||||||
|
const sourceName =
|
||||||
|
data.nodes.find((n) => n.id === link.source)?.name || link.source;
|
||||||
|
const targetName =
|
||||||
|
data.nodes.find((n) => n.id === link.target)?.name || link.target;
|
||||||
|
return `Связь: ${sourceName} → ${targetName}\nПКМ для удаления`;
|
||||||
|
}}
|
||||||
|
linkColor={(link: any) => {
|
||||||
|
return highlightLinks.has(link) ? "#fbbf24" : "#4b5563";
|
||||||
|
}}
|
||||||
|
linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1.5)}
|
||||||
|
linkDirectionalParticles={0}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeRightClick={(node, event) =>
|
||||||
|
openContextMenu(event as any, node, undefined)
|
||||||
|
}
|
||||||
|
onLinkRightClick={(link, event) =>
|
||||||
|
openContextMenu(event as any, undefined, link)
|
||||||
|
}
|
||||||
|
onNodeHover={handleNodeHover}
|
||||||
|
cooldownTicks={50}
|
||||||
|
cooldownTime={2000}
|
||||||
|
d3AlphaDecay={0.03}
|
||||||
|
d3VelocityDecay={0.4}
|
||||||
|
warmupTicks={50}
|
||||||
|
onEngineStop={() => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
fgRef.current.zoomToFit(400);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{contextMenu && (
|
||||||
|
<div
|
||||||
|
className="fixed bg-gray-800 rounded-lg shadow-lg border border-gray-700 py-1 z-50"
|
||||||
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{contextMenu.node && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-1 text-xs text-gray-400 border-b border-gray-700">
|
||||||
|
{contextMenu.node.name}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsLinkMode(true);
|
||||||
|
setSelectedNode(contextMenu.node);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FiLink size={14} /> Создать связь
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteNode(contextMenu.node!)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FiTrash2 size={14} /> Удалить узел
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{contextMenu.link && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-1 text-xs text-gray-400 border-b border-gray-700">
|
||||||
|
Связь:{" "}
|
||||||
|
{typeof contextMenu.link.source === "string"
|
||||||
|
? contextMenu.link.source
|
||||||
|
: (contextMenu.link.source as any).name ||
|
||||||
|
(contextMenu.link.source as any).id}{" "}
|
||||||
|
→{" "}
|
||||||
|
{typeof contextMenu.link.target === "string"
|
||||||
|
? contextMenu.link.target
|
||||||
|
: (contextMenu.link.target as any).name ||
|
||||||
|
(contextMenu.link.target as any).id}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLink(contextMenu.link!)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FiMinusCircle size={14} /> Удалить связь
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLinkMode && (
|
||||||
|
<div className="absolute bottom-4 left-4 bg-green-600 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-2">
|
||||||
|
<FiLink /> Режим создания связей: кликните на два узла для
|
||||||
|
соединения
|
||||||
|
{selectedNode && (
|
||||||
|
<span className="ml-2">Выбран: {selectedNode.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-gray-500 flex justify-between items-center">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiServer className="text-gray-400" />
|
||||||
|
<span>
|
||||||
|
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiCpu className="text-gray-400" />
|
||||||
|
<span>
|
||||||
|
Агенты: {data.nodes.filter((n) => n.type === "agent").length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 bg-gray-500 rounded-sm"></div>
|
||||||
|
<span>Связи: {data.links.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsLinkMode(!isLinkMode);
|
||||||
|
setSelectedNode(null);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||||
|
isLinkMode
|
||||||
|
? "bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
: "bg-gray-800 hover:bg-gray-700 text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FiLink />
|
||||||
|
<span className="text-sm">
|
||||||
|
{isLinkMode ? "Создание связи..." : "Добавить связь"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAddNode}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiPlus />
|
||||||
|
<span className="text-sm">Узел</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiZoomIn />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiZoomOut />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleFit}
|
||||||
|
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiMove />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
||||||
|
>
|
||||||
|
<FiDownload />
|
||||||
|
<span className="text-sm">Экспорт</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,9 +5,9 @@ import { Layout } from "@/app/providers/layout/layout";
|
|||||||
export const DefaultLayout = () => {
|
export const DefaultLayout = () => {
|
||||||
const { token } = useAuthStore();
|
const { token } = useAuthStore();
|
||||||
|
|
||||||
if (!token) {
|
// if (!token) {
|
||||||
return <Navigate to="/auth" replace />;
|
// return <Navigate to="/auth" replace />;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
+1496
-823
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user