@@ -8,7 +8,8 @@
|
||||
"Bash(dir)",
|
||||
"Bash(move *)",
|
||||
"Bash(findstr *)",
|
||||
"Bash(del *)"
|
||||
"Bash(del *)",
|
||||
"Bash(mkdir *)"
|
||||
]
|
||||
},
|
||||
"$version": 3
|
||||
|
||||
@@ -10,29 +10,8 @@ interface AgentState {
|
||||
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) => ({
|
||||
agents: mockAgents,
|
||||
agents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
|
||||
@@ -31,6 +31,26 @@ export interface UpdateScriptPayload {
|
||||
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
|
||||
export const scriptsApi = {
|
||||
getInterpreters: async (): Promise<Interpreter[]> => {
|
||||
@@ -84,11 +104,19 @@ export const scriptsApi = {
|
||||
return res.data;
|
||||
},
|
||||
|
||||
run: async (path: string): Promise<{ id: number; status: string }> => {
|
||||
const res = await apiClient.post<{ id: number; status: string }>(
|
||||
"/scripts/run",
|
||||
{ path },
|
||||
runScript: async (
|
||||
id: number,
|
||||
payload: RunScriptPayload,
|
||||
): Promise<RunScriptResponse> => {
|
||||
const res = await apiClient.post<RunScriptResponse>(
|
||||
`/scripts/${id}/run`,
|
||||
payload,
|
||||
);
|
||||
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;
|
||||
}> = ({ node, level, onRun }) => {
|
||||
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
|
||||
const runningScripts = useFilePickerStore((s) => s.runningScripts);
|
||||
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
|
||||
|
||||
const nodePath = node.path || node.name;
|
||||
const isExpanded = expandedFolders.has(nodePath);
|
||||
const isRunning = node.type === "file" && runningScripts.has(nodePath);
|
||||
|
||||
if (node.type === "file") {
|
||||
return (
|
||||
@@ -27,7 +25,6 @@ const FilePickerTree: React.FC<{
|
||||
name={node.name}
|
||||
type="file"
|
||||
path={nodePath}
|
||||
isSelected={isRunning}
|
||||
level={level}
|
||||
onRun={onRun}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,6 @@ interface FilePickerItemProps {
|
||||
name: string;
|
||||
type: "file" | "folder";
|
||||
path: string;
|
||||
isSelected?: boolean;
|
||||
isExpanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
level: number;
|
||||
@@ -24,7 +23,6 @@ export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
||||
name,
|
||||
type,
|
||||
path,
|
||||
isSelected,
|
||||
isExpanded,
|
||||
children,
|
||||
level,
|
||||
@@ -131,12 +129,10 @@ export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "4px",
|
||||
backgroundColor: isSelected ? "#238636" : "transparent",
|
||||
border: isSelected
|
||||
? "1px solid #2ea043"
|
||||
: "1px solid transparent",
|
||||
backgroundColor: "transparent",
|
||||
border: "1px solid transparent",
|
||||
borderRadius: "3px",
|
||||
color: isSelected ? "#ffffff" : "var(--text-secondary)",
|
||||
color: "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
transition: "all 0.15s",
|
||||
@@ -151,15 +147,9 @@ export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
||||
e.currentTarget.style.borderColor = "#2ea043";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isSelected
|
||||
? "#238636"
|
||||
: "transparent";
|
||||
e.currentTarget.style.color = isSelected
|
||||
? "#ffffff"
|
||||
: "var(--text-secondary)";
|
||||
e.currentTarget.style.borderColor = isSelected
|
||||
? "#2ea043"
|
||||
: "transparent";
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
e.currentTarget.style.color = "var(--text-secondary)";
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
}}
|
||||
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 { scriptsApi } from "../api/scripts.api";
|
||||
|
||||
interface FilePickerState {
|
||||
selectedPaths: Set<string>;
|
||||
expandedFolders: Set<string>;
|
||||
runningScripts: Map<string, { id: number; status: string }>;
|
||||
|
||||
toggleSelection: (path: string) => void;
|
||||
selectAll: (paths: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
toggleFolder: (path: string) => void;
|
||||
getSelectedPaths: () => string[];
|
||||
runScript: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useFilePickerStore = create<FilePickerState>((set, get) => ({
|
||||
selectedPaths: new Set(),
|
||||
expandedFolders: new Set(),
|
||||
runningScripts: new Map(),
|
||||
|
||||
toggleSelection: (path: string) => {
|
||||
set((state) => {
|
||||
@@ -58,17 +54,4 @@ export const useFilePickerStore = create<FilePickerState>((set, get) => ({
|
||||
getSelectedPaths: () => {
|
||||
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 { FiEdit3, FiPlay } from "react-icons/fi";
|
||||
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 { scriptsApi } from "../modules/ide/api/scripts.api";
|
||||
|
||||
@@ -40,11 +43,15 @@ const convertTreeToFileNode = (data: any[]): FileNode => {
|
||||
|
||||
export const TemplatesPage = () => {
|
||||
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 [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(() => {
|
||||
scriptsApi
|
||||
@@ -59,18 +66,22 @@ export const TemplatesPage = () => {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleRun = async (path: string) => {
|
||||
await runScript(path);
|
||||
const handleRun = (path: string, id?: number) => {
|
||||
if (!id) {
|
||||
console.warn("Script ID not found for:", path);
|
||||
return;
|
||||
}
|
||||
setRunModal({ scriptPath: path, scriptId: id });
|
||||
};
|
||||
|
||||
const runningCount = runningScripts.size;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100vh",
|
||||
position: "relative",
|
||||
backgroundColor: "var(--bg-primary)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Floating header */}
|
||||
@@ -85,24 +96,6 @@ export const TemplatesPage = () => {
|
||||
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 */}
|
||||
<button
|
||||
onClick={() => navigate("/ide")}
|
||||
@@ -132,29 +125,78 @@ export const TemplatesPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File Picker */}
|
||||
<div style={{ height: "100%", overflow: "hidden" }}>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<FaSpinner
|
||||
size={24}
|
||||
{/* File Picker + Terminal */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
animation: "spin 1s linear infinite",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<FaSpinner
|
||||
size={24}
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : files ? (
|
||||
<FilePicker
|
||||
files={files}
|
||||
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}
|
||||
</div>
|
||||
|
||||
{/* Terminal */}
|
||||
{terminalOpen && (
|
||||
<div style={{ height: `${terminalHeight}px`, flexShrink: 0 }}>
|
||||
<TerminalOutput />
|
||||
</div>
|
||||
) : files ? (
|
||||
<FilePicker files={files} onRun={handleRun} />
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Run Script Modal */}
|
||||
{runModal && (
|
||||
<RunScriptModal
|
||||
scriptPath={runModal.scriptPath}
|
||||
scriptId={runModal.scriptId}
|
||||
onClose={() => setRunModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user