@@ -8,7 +8,8 @@
|
|||||||
"Bash(dir)",
|
"Bash(dir)",
|
||||||
"Bash(move *)",
|
"Bash(move *)",
|
||||||
"Bash(findstr *)",
|
"Bash(findstr *)",
|
||||||
"Bash(del *)"
|
"Bash(del *)",
|
||||||
|
"Bash(mkdir *)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"$version": 3
|
"$version": 3
|
||||||
|
|||||||
@@ -10,29 +10,8 @@ interface AgentState {
|
|||||||
removeAgent: (name: string) => void;
|
removeAgent: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockAgents: AgentInfo[] = [
|
|
||||||
{
|
|
||||||
label: "agent-core-01",
|
|
||||||
token: "tok_a1b2c3d4e5f6g7h8",
|
|
||||||
services: ["postgres", "redis", "log-collector"],
|
|
||||||
connected_at: "2026-04-04 15:25:09",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "agent-worker-02",
|
|
||||||
token: "tok_x9y8z7w6v5u4t3s2",
|
|
||||||
services: ["celery-worker", "flower"],
|
|
||||||
connected_at: "2026-04-04 15:25:09",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "agent-monitor-03",
|
|
||||||
token: "tok_m1n2o3p4q5r6s7t8",
|
|
||||||
services: ["prometheus", "grafana", "alertmanager"],
|
|
||||||
connected_at: "2026-04-04 15:25:09",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const useAgentStore = create<AgentState>()((set, get) => ({
|
export const useAgentStore = create<AgentState>()((set, get) => ({
|
||||||
agents: mockAgents,
|
agents: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,26 @@ export interface UpdateScriptPayload {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunScriptPayload {
|
||||||
|
stdin?: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunScriptResponse {
|
||||||
|
command: string[];
|
||||||
|
id: number;
|
||||||
|
wait_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobWaitResponse {
|
||||||
|
command: string[];
|
||||||
|
id: number;
|
||||||
|
status: number;
|
||||||
|
stderr: string;
|
||||||
|
stdin: string;
|
||||||
|
stdout: string;
|
||||||
|
}
|
||||||
|
|
||||||
// apiClient уже имеет интерсептор для Authorization header
|
// apiClient уже имеет интерсептор для Authorization header
|
||||||
export const scriptsApi = {
|
export const scriptsApi = {
|
||||||
getInterpreters: async (): Promise<Interpreter[]> => {
|
getInterpreters: async (): Promise<Interpreter[]> => {
|
||||||
@@ -84,11 +104,19 @@ export const scriptsApi = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
run: async (path: string): Promise<{ id: number; status: string }> => {
|
runScript: async (
|
||||||
const res = await apiClient.post<{ id: number; status: string }>(
|
id: number,
|
||||||
"/scripts/run",
|
payload: RunScriptPayload,
|
||||||
{ path },
|
): Promise<RunScriptResponse> => {
|
||||||
|
const res = await apiClient.post<RunScriptResponse>(
|
||||||
|
`/scripts/${id}/run`,
|
||||||
|
payload,
|
||||||
);
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
waitJob: async (id: number): Promise<JobWaitResponse> => {
|
||||||
|
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,12 +14,10 @@ const FilePickerTree: React.FC<{
|
|||||||
onRun?: (path: string) => void;
|
onRun?: (path: string) => void;
|
||||||
}> = ({ node, level, onRun }) => {
|
}> = ({ node, level, onRun }) => {
|
||||||
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
|
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
|
||||||
const runningScripts = useFilePickerStore((s) => s.runningScripts);
|
|
||||||
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
|
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
|
||||||
|
|
||||||
const nodePath = node.path || node.name;
|
const nodePath = node.path || node.name;
|
||||||
const isExpanded = expandedFolders.has(nodePath);
|
const isExpanded = expandedFolders.has(nodePath);
|
||||||
const isRunning = node.type === "file" && runningScripts.has(nodePath);
|
|
||||||
|
|
||||||
if (node.type === "file") {
|
if (node.type === "file") {
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +25,6 @@ const FilePickerTree: React.FC<{
|
|||||||
name={node.name}
|
name={node.name}
|
||||||
type="file"
|
type="file"
|
||||||
path={nodePath}
|
path={nodePath}
|
||||||
isSelected={isRunning}
|
|
||||||
level={level}
|
level={level}
|
||||||
onRun={onRun}
|
onRun={onRun}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ interface FilePickerItemProps {
|
|||||||
name: string;
|
name: string;
|
||||||
type: "file" | "folder";
|
type: "file" | "folder";
|
||||||
path: string;
|
path: string;
|
||||||
isSelected?: boolean;
|
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
level: number;
|
level: number;
|
||||||
@@ -24,7 +23,6 @@ export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
|||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
path,
|
path,
|
||||||
isSelected,
|
|
||||||
isExpanded,
|
isExpanded,
|
||||||
children,
|
children,
|
||||||
level,
|
level,
|
||||||
@@ -131,12 +129,10 @@ export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
padding: "4px",
|
padding: "4px",
|
||||||
backgroundColor: isSelected ? "#238636" : "transparent",
|
backgroundColor: "transparent",
|
||||||
border: isSelected
|
border: "1px solid transparent",
|
||||||
? "1px solid #2ea043"
|
|
||||||
: "1px solid transparent",
|
|
||||||
borderRadius: "3px",
|
borderRadius: "3px",
|
||||||
color: isSelected ? "#ffffff" : "var(--text-secondary)",
|
color: "var(--text-secondary)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
@@ -151,15 +147,9 @@ export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
|||||||
e.currentTarget.style.borderColor = "#2ea043";
|
e.currentTarget.style.borderColor = "#2ea043";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.backgroundColor = isSelected
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
? "#238636"
|
e.currentTarget.style.color = "var(--text-secondary)";
|
||||||
: "transparent";
|
e.currentTarget.style.borderColor = "transparent";
|
||||||
e.currentTarget.style.color = isSelected
|
|
||||||
? "#ffffff"
|
|
||||||
: "var(--text-secondary)";
|
|
||||||
e.currentTarget.style.borderColor = isSelected
|
|
||||||
? "#2ea043"
|
|
||||||
: "transparent";
|
|
||||||
}}
|
}}
|
||||||
title="Run script"
|
title="Run script"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { MdClose } from "react-icons/md";
|
||||||
|
import { scriptsApi } from "../api/scripts.api";
|
||||||
|
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
|
||||||
|
interface RunScriptModalProps {
|
||||||
|
scriptPath: string;
|
||||||
|
scriptId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RunScriptModal: React.FC<RunScriptModalProps> = ({
|
||||||
|
scriptPath,
|
||||||
|
scriptId,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [selectedAgentIdx, setSelectedAgentIdx] = useState(0);
|
||||||
|
const [stdinValue, setStdinValue] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLSelectElement>(null);
|
||||||
|
|
||||||
|
const agents = useAgentStore((s) => s.agents);
|
||||||
|
const addJob = useTerminalStore((s) => s.addJob);
|
||||||
|
const openTerminal = useTerminalStore((s) => s.openTerminal);
|
||||||
|
|
||||||
|
const selectedAgent = agents[selectedAgentIdx];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
if (!selectedAgent) {
|
||||||
|
setError("No agents available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Запускаем скрипт
|
||||||
|
const runResult = await scriptsApi.runScript(scriptId, {
|
||||||
|
stdin: stdinValue,
|
||||||
|
token: selectedAgent.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Добавляем джоб в терминал
|
||||||
|
addJob({
|
||||||
|
id: runResult.id,
|
||||||
|
scriptPath,
|
||||||
|
command: runResult.command,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Открываем терминал
|
||||||
|
openTerminal();
|
||||||
|
|
||||||
|
// 4. Ждём завершения по id
|
||||||
|
const jobResult = await scriptsApi.waitJob(runResult.id);
|
||||||
|
|
||||||
|
// 5. Обновляем джоб
|
||||||
|
addJob({
|
||||||
|
id: jobResult.id,
|
||||||
|
scriptPath,
|
||||||
|
command: jobResult.command,
|
||||||
|
stdin: jobResult.stdin,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем с финальным статусом
|
||||||
|
const terminalStore = useTerminalStore.getState();
|
||||||
|
terminalStore.updateJob(jobResult.id, {
|
||||||
|
status: jobResult.status,
|
||||||
|
stdout: jobResult.stdout,
|
||||||
|
stderr: jobResult.stderr,
|
||||||
|
stdin: jobResult.stdin,
|
||||||
|
isRunning: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Failed to run script:", e);
|
||||||
|
setError(e?.response?.data?.detail || "Failed to run script");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
width: "420px",
|
||||||
|
maxWidth: "90vw",
|
||||||
|
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Run Script
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ padding: "20px" }}>
|
||||||
|
{/* Script path */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Script
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scriptPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent selector */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Agent <span style={{ color: "#f44747" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
ref={inputRef}
|
||||||
|
value={selectedAgentIdx}
|
||||||
|
onChange={(e) => setSelectedAgentIdx(Number(e.target.value))}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agents.length === 0 && (
|
||||||
|
<option value="">No agents available</option>
|
||||||
|
)}
|
||||||
|
{agents.map((agent, idx) => (
|
||||||
|
<option key={agent.label} value={idx}>
|
||||||
|
{agent.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stdin (optional) */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stdin <span style={{ color: "#858585" }}>(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={stdinValue}
|
||||||
|
onChange={(e) => setStdinValue(e.target.value)}
|
||||||
|
placeholder="Enter input data..."
|
||||||
|
rows={4}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
resize: "vertical",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "rgba(244, 71, 71, 0.1)",
|
||||||
|
border: "1px solid #f44747",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#f44747",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Run button */}
|
||||||
|
<button
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={loading || !selectedAgent}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: loading || !selectedAgent ? "#555" : "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: loading || !selectedAgent ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⏳
|
||||||
|
</span>
|
||||||
|
Running...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>▶ Run</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { scriptsApi } from "../api/scripts.api";
|
|
||||||
|
|
||||||
interface FilePickerState {
|
interface FilePickerState {
|
||||||
selectedPaths: Set<string>;
|
selectedPaths: Set<string>;
|
||||||
expandedFolders: Set<string>;
|
expandedFolders: Set<string>;
|
||||||
runningScripts: Map<string, { id: number; status: string }>;
|
|
||||||
|
|
||||||
toggleSelection: (path: string) => void;
|
toggleSelection: (path: string) => void;
|
||||||
selectAll: (paths: string[]) => void;
|
selectAll: (paths: string[]) => void;
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
toggleFolder: (path: string) => void;
|
toggleFolder: (path: string) => void;
|
||||||
getSelectedPaths: () => string[];
|
getSelectedPaths: () => string[];
|
||||||
runScript: (path: string) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFilePickerStore = create<FilePickerState>((set, get) => ({
|
export const useFilePickerStore = create<FilePickerState>((set, get) => ({
|
||||||
selectedPaths: new Set(),
|
selectedPaths: new Set(),
|
||||||
expandedFolders: new Set(),
|
expandedFolders: new Set(),
|
||||||
runningScripts: new Map(),
|
|
||||||
|
|
||||||
toggleSelection: (path: string) => {
|
toggleSelection: (path: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -58,17 +54,4 @@ export const useFilePickerStore = create<FilePickerState>((set, get) => ({
|
|||||||
getSelectedPaths: () => {
|
getSelectedPaths: () => {
|
||||||
return Array.from(get().selectedPaths);
|
return Array.from(get().selectedPaths);
|
||||||
},
|
},
|
||||||
|
|
||||||
runScript: async (path: string) => {
|
|
||||||
try {
|
|
||||||
const result = await scriptsApi.run(path);
|
|
||||||
set((state) => {
|
|
||||||
const newMap = new Map(state.runningScripts);
|
|
||||||
newMap.set(path, result);
|
|
||||||
return { runningScripts: newMap };
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to run script:", e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTerminalStore } from "../store/useTerminalStore";
|
||||||
|
import { MdClose, MdClearAll } from "react-icons/md";
|
||||||
|
import { FiTerminal } from "react-icons/fi";
|
||||||
|
|
||||||
|
export const TerminalOutput: React.FC = () => {
|
||||||
|
const {
|
||||||
|
jobs,
|
||||||
|
isOpen,
|
||||||
|
activeJobId,
|
||||||
|
closeTerminal,
|
||||||
|
setActiveJob,
|
||||||
|
clearJobs,
|
||||||
|
removeJob,
|
||||||
|
} = useTerminalStore();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const activeJob = jobs.find((j) => j.id === activeJobId) || jobs[jobs.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
borderTop: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Terminal header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
height: "35px",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FiTerminal size={14} color="#bbbbbb" />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#bbbbbb",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "11px",
|
||||||
|
letterSpacing: "0.8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
TERMINAL
|
||||||
|
</span>
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#858585",
|
||||||
|
fontSize: "11px",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{jobs.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearJobs}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
title="Clear all"
|
||||||
|
>
|
||||||
|
<MdClearAll size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={closeTerminal}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<MdClose size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job tabs */}
|
||||||
|
{jobs.length > 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
backgroundColor: "#2d2d2d",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<button
|
||||||
|
key={job.id}
|
||||||
|
onClick={() => setActiveJob(job.id)}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
backgroundColor:
|
||||||
|
job.id === activeJobId ? "#1e1e1e" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderBottom:
|
||||||
|
job.id === activeJobId
|
||||||
|
? "2px solid #0e639c"
|
||||||
|
: "2px solid transparent",
|
||||||
|
color: job.isRunning ? "#cccccc" : "#858585",
|
||||||
|
fontSize: "12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "8px",
|
||||||
|
height: "8px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: job.isRunning ? "#4ec9b0" : "#858585",
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{job.scriptPath.split("/").pop()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terminal output */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "12px",
|
||||||
|
fontFamily: "'Consolas', 'Courier New', monospace",
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob ? (
|
||||||
|
<>
|
||||||
|
{/* Command header */}
|
||||||
|
<div style={{ marginBottom: "8px" }}>
|
||||||
|
<span style={{ color: "#6a9955" }}>$ </span>
|
||||||
|
<span style={{ color: "#cccccc" }}>
|
||||||
|
{activeJob.command.join(" ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stdin if provided */}
|
||||||
|
{activeJob.stdin && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "#2d2d2d",
|
||||||
|
borderRadius: "4px",
|
||||||
|
borderLeft: "3px solid #0e639c",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#858585" }}>stdin: </span>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: "#cccccc",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob.stdin}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stdout */}
|
||||||
|
{activeJob.stdout && (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
color: "#cccccc",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob.stdout}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stderr */}
|
||||||
|
{activeJob.stderr && (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
color: "#f44747",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob.stderr}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{activeJob.isRunning ? (
|
||||||
|
<div style={{ color: "#4ec9b0" }}>⏳ Running...</div>
|
||||||
|
) : activeJob.status !== null ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: activeJob.status === 0 ? "#4ec9b0" : "#f44747",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob.status === 0
|
||||||
|
? "✓ Process exited with code 0"
|
||||||
|
: `✗ Process exited with code ${activeJob.status}`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#858585",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTerminal size={32} />
|
||||||
|
<span>No active jobs</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { TerminalOutput } from "./components/TerminalOutput";
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export interface TerminalJob {
|
||||||
|
id: number;
|
||||||
|
scriptPath: string;
|
||||||
|
command: string[];
|
||||||
|
status: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
stdin: string;
|
||||||
|
isRunning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalState {
|
||||||
|
jobs: TerminalJob[];
|
||||||
|
isOpen: boolean;
|
||||||
|
activeJobId: number | null;
|
||||||
|
|
||||||
|
openTerminal: () => void;
|
||||||
|
closeTerminal: () => void;
|
||||||
|
addJob: (job: Omit<TerminalJob, "status" | "stdout" | "stderr" | "isRunning">) => void;
|
||||||
|
updateJob: (id: number, updates: Partial<TerminalJob>) => void;
|
||||||
|
setActiveJob: (id: number | null) => void;
|
||||||
|
clearJobs: () => void;
|
||||||
|
removeJob: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTerminalStore = create<TerminalState>((set) => ({
|
||||||
|
jobs: [],
|
||||||
|
isOpen: false,
|
||||||
|
activeJobId: null,
|
||||||
|
|
||||||
|
openTerminal: () => set({ isOpen: true }),
|
||||||
|
|
||||||
|
closeTerminal: () => set({ isOpen: false }),
|
||||||
|
|
||||||
|
addJob: (job) =>
|
||||||
|
set((state) => ({
|
||||||
|
jobs: [
|
||||||
|
...state.jobs,
|
||||||
|
{
|
||||||
|
...job,
|
||||||
|
status: null,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
stdin: "",
|
||||||
|
isRunning: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeJobId: job.id,
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateJob: (id, updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
jobs: state.jobs.map((j) => (j.id === id ? { ...j, ...updates } : j)),
|
||||||
|
})),
|
||||||
|
|
||||||
|
setActiveJob: (id) => set({ activeJobId: id }),
|
||||||
|
|
||||||
|
clearJobs: () => set({ jobs: [], activeJobId: null }),
|
||||||
|
|
||||||
|
removeJob: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const newJobs = state.jobs.filter((j) => j.id !== id);
|
||||||
|
return {
|
||||||
|
jobs: newJobs,
|
||||||
|
activeJobId:
|
||||||
|
state.activeJobId === id
|
||||||
|
? newJobs.length > 0
|
||||||
|
? newJobs[newJobs.length - 1].id
|
||||||
|
: null
|
||||||
|
: state.activeJobId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -2,7 +2,10 @@ import { useState, useEffect } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { FiEdit3, FiPlay } from "react-icons/fi";
|
import { FiEdit3, FiPlay } from "react-icons/fi";
|
||||||
import { FaSpinner } from "react-icons/fa";
|
import { FaSpinner } from "react-icons/fa";
|
||||||
import { FilePicker, useFilePickerStore } from "../modules/ide";
|
import { FilePicker } from "../modules/ide";
|
||||||
|
import { RunScriptModal } from "../modules/ide/components/RunScriptModal";
|
||||||
|
import { TerminalOutput } from "../modules/terminal";
|
||||||
|
import { useTerminalStore } from "../modules/terminal/store/useTerminalStore";
|
||||||
import type { FileNode } from "../modules/ide";
|
import type { FileNode } from "../modules/ide";
|
||||||
import { scriptsApi } from "../modules/ide/api/scripts.api";
|
import { scriptsApi } from "../modules/ide/api/scripts.api";
|
||||||
|
|
||||||
@@ -40,11 +43,15 @@ const convertTreeToFileNode = (data: any[]): FileNode => {
|
|||||||
|
|
||||||
export const TemplatesPage = () => {
|
export const TemplatesPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const selectedPaths = useFilePickerStore((s) => s.selectedPaths);
|
|
||||||
const runningScripts = useFilePickerStore((s) => s.runningScripts);
|
|
||||||
const runScript = useFilePickerStore((s) => s.runScript);
|
|
||||||
const [files, setFiles] = useState<FileNode | null>(null);
|
const [files, setFiles] = useState<FileNode | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [runModal, setRunModal] = useState<{
|
||||||
|
scriptPath: string;
|
||||||
|
scriptId: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const terminalOpen = useTerminalStore((s) => s.isOpen);
|
||||||
|
const [terminalHeight] = useState(300);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scriptsApi
|
scriptsApi
|
||||||
@@ -59,18 +66,22 @@ export const TemplatesPage = () => {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRun = async (path: string) => {
|
const handleRun = (path: string, id?: number) => {
|
||||||
await runScript(path);
|
if (!id) {
|
||||||
|
console.warn("Script ID not found for:", path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRunModal({ scriptPath: path, scriptId: id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const runningCount = runningScripts.size;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
backgroundColor: "var(--bg-primary)",
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Floating header */}
|
{/* Floating header */}
|
||||||
@@ -85,24 +96,6 @@ export const TemplatesPage = () => {
|
|||||||
gap: "16px",
|
gap: "16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Running scripts counter */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
padding: "6px 12px",
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiPlay size={13} color="#61c454" />
|
|
||||||
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
|
||||||
{runningCount} script{runningCount !== 1 ? "s" : ""} running
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Open in Editor button */}
|
{/* Open in Editor button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/ide")}
|
onClick={() => navigate("/ide")}
|
||||||
@@ -132,8 +125,16 @@ export const TemplatesPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Picker */}
|
{/* File Picker + Terminal */}
|
||||||
<div style={{ height: "100%", overflow: "hidden" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -152,9 +153,50 @@ export const TemplatesPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : files ? (
|
) : files ? (
|
||||||
<FilePicker files={files} onRun={handleRun} />
|
<FilePicker
|
||||||
|
files={files}
|
||||||
|
onRun={(path) => {
|
||||||
|
// Находим ID скрипта по пути
|
||||||
|
const findNodeById = (
|
||||||
|
node: FileNode,
|
||||||
|
p: string,
|
||||||
|
): FileNode | null => {
|
||||||
|
if (node.path === p) return node;
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
const found = findNodeById(child, p);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const node = findNodeById(files, path);
|
||||||
|
if (node?.id) {
|
||||||
|
handleRun(path, node.id);
|
||||||
|
} else {
|
||||||
|
console.warn("Script ID not found for path:", path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal */}
|
||||||
|
{terminalOpen && (
|
||||||
|
<div style={{ height: `${terminalHeight}px`, flexShrink: 0 }}>
|
||||||
|
<TerminalOutput />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Run Script Modal */}
|
||||||
|
{runModal && (
|
||||||
|
<RunScriptModal
|
||||||
|
scriptPath={runModal.scriptPath}
|
||||||
|
scriptId={runModal.scriptId}
|
||||||
|
onClose={() => setRunModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user