mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 04:18:42 -06:00
Added Script Editor with Syntax Highlighting
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"@mui/x-tree-view": "8.10.0",
|
"@mui/x-tree-view": "8.10.0",
|
||||||
"normalize.css": "8.0.1",
|
"normalize.css": "8.0.1",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
"react-simple-code-editor": "0.13.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-color": "2.19.3",
|
"react-color": "2.19.3",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
@@ -30,6 +30,7 @@ import "prismjs/components/prism-bash";
|
|||||||
import "prismjs/components/prism-powershell";
|
import "prismjs/components/prism-powershell";
|
||||||
import "prismjs/components/prism-batch";
|
import "prismjs/components/prism-batch";
|
||||||
import "prismjs/themes/prism-okaidia.css";
|
import "prismjs/themes/prism-okaidia.css";
|
||||||
|
import Editor from "react-simple-code-editor";
|
||||||
|
|
||||||
// ---------- helpers ----------
|
// ---------- helpers ----------
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
@@ -52,9 +53,11 @@ function typeFromFilename(name = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureExt(baseName, typeKey) {
|
function ensureExt(baseName, typeKey) {
|
||||||
|
if (!baseName) return baseName;
|
||||||
|
// If user already provided any extension, keep it.
|
||||||
|
if (/\.[^./\\]+$/i.test(baseName)) return baseName;
|
||||||
const t = TYPES[typeKey] || TYPES.ansible;
|
const t = TYPES[typeKey] || TYPES.ansible;
|
||||||
const bn = baseName.replace(/\.(yml|ps1|bat|sh)$/i, "");
|
return baseName + t.ext;
|
||||||
return bn + t.ext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTree(scripts, folders) {
|
function buildTree(scripts, folders) {
|
||||||
@@ -121,12 +124,12 @@ function highlightedHtml(code, prismLang) {
|
|||||||
const grammar = Prism.languages[prismLang] || Prism.languages.markup;
|
const grammar = Prism.languages[prismLang] || Prism.languages.markup;
|
||||||
return Prism.highlight(code ?? "", grammar, prismLang);
|
return Prism.highlight(code ?? "", grammar, prismLang);
|
||||||
} catch {
|
} catch {
|
||||||
return (code ?? "").replace(/[&<>]/g, (c) => ({"&":"&","<":"<",">":">"}[c]));
|
return (code ?? "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local dialog: rename script file
|
// Local dialog: rename script file
|
||||||
function RenameScriptDialog({ open, value, onChange, onCancel, onSave }) {
|
function RenameScriptDialog({ open, value, onChange, onCancel, onSave, onBlur }) {
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
component={(props) => (
|
component={(props) => (
|
||||||
@@ -155,6 +158,7 @@ function RenameScriptDialog({ open, value, onChange, onCancel, onSave }) {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={() => onBlur && onBlur()}
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
backgroundColor: "#2a2a2a",
|
backgroundColor: "#2a2a2a",
|
||||||
@@ -177,7 +181,7 @@ function RenameScriptDialog({ open, value, onChange, onCancel, onSave }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Local dialog: new script (name + type)
|
// Local dialog: new script (name + type)
|
||||||
function NewScriptDialog({ open, name, type, onChangeName, onChangeType, onCancel, onCreate }) {
|
function NewScriptDialog({ open, name, type, onChangeName, onChangeType, onCancel, onCreate, onBlurName }) {
|
||||||
return (
|
return (
|
||||||
<Paper component={(p) => <div {...p} />} sx={{ display: open ? "block" : "none" }}>
|
<Paper component={(p) => <div {...p} />} sx={{ display: open ? "block" : "none" }}>
|
||||||
<div
|
<div
|
||||||
@@ -201,6 +205,7 @@ function NewScriptDialog({ open, name, type, onChangeName, onChangeType, onCance
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => onChangeName(e.target.value)}
|
onChange={(e) => onChangeName(e.target.value)}
|
||||||
|
onBlur={() => onBlurName && onBlurName()}
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
backgroundColor: "#2a2a2a",
|
backgroundColor: "#2a2a2a",
|
||||||
@@ -344,9 +349,11 @@ export default function ScriptEditor() {
|
|||||||
setNewScriptOpen(true);
|
setNewScriptOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Ensure filename extension aligns with selected type when saving new file by name
|
||||||
|
const normalizedName = currentPath ? undefined : ensureExt(fileName, type);
|
||||||
const payload = {
|
const payload = {
|
||||||
path: currentPath || undefined,
|
path: currentPath || undefined,
|
||||||
name: currentPath ? undefined : fileName,
|
name: normalizedName,
|
||||||
content: code,
|
content: code,
|
||||||
type
|
type
|
||||||
};
|
};
|
||||||
@@ -400,10 +407,11 @@ export default function ScriptEditor() {
|
|||||||
|
|
||||||
const saveRenameFile = async () => {
|
const saveRenameFile = async () => {
|
||||||
try {
|
try {
|
||||||
|
const finalName = ensureExt(renameValue, type);
|
||||||
const res = await fetch("/api/scripts/rename_file", {
|
const res = await fetch("/api/scripts/rename_file", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ path: selectedNode.path, new_name: renameValue, type })
|
body: JSON.stringify({ path: selectedNode.path, new_name: finalName, type })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||||
@@ -491,7 +499,7 @@ export default function ScriptEditor() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
|
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
|
||||||
const highlighted = useMemo(() => highlightedHtml(code, prismLang), [code, prismLang]);
|
// live highlighting handled by react-simple-code-editor
|
||||||
|
|
||||||
const renderItems = (nodes) =>
|
const renderItems = (nodes) =>
|
||||||
(nodes || []).map((n) => (
|
(nodes || []).map((n) => (
|
||||||
@@ -523,7 +531,7 @@ export default function ScriptEditor() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", flex: 1, height: "100%", overflow: "hidden" }}>
|
<Box sx={{ display: "flex", flex: 1, height: "100%", overflow: "hidden" }}>
|
||||||
{/* Left: Tree */}
|
{/* Left: Tree */}
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e", width: 360, flexShrink: 0 }} elevation={2}>
|
<Paper sx={{ m: 1, p: 0, bgcolor: "#1e1e1e", width: 320, flexShrink: 0 }} elevation={2}>
|
||||||
<Box sx={{ p: 2, pb: 1 }}>
|
<Box sx={{ p: 2, pb: 1 }}>
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>Scripts</Typography>
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>Scripts</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||||
@@ -575,7 +583,7 @@ export default function ScriptEditor() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Right: Editor */}
|
{/* Right: Editor */}
|
||||||
<Paper sx={{ m: 2, p: 2, bgcolor: "#1e1e1e", display: "flex", flexDirection: "column", flex: 1 }} elevation={2}>
|
<Paper sx={{ m: 1, p: 1.5, bgcolor: "#1e1e1e", display: "flex", flexDirection: "column", flex: 1 }} elevation={2}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2 }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
|
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
|
||||||
@@ -622,34 +630,25 @@ export default function ScriptEditor() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Editor input */}
|
{/* Single-pane live-highlight editor */}
|
||||||
<TextField
|
<Box sx={{ flex: 1, minHeight: 300, border: "1px solid #444", borderRadius: 1, background: "#121212", overflow: "auto" }}>
|
||||||
multiline
|
<Editor
|
||||||
minRows={18}
|
value={code}
|
||||||
placeholder={currentPath ? `Editing: ${currentPath}` : "New Script..."}
|
onValueChange={setCode}
|
||||||
value={code}
|
highlight={(src) => highlightedHtml(src, prismLang)}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
padding={12}
|
||||||
sx={{
|
placeholder={currentPath ? `Editing: ${currentPath}` : "New Script..."}
|
||||||
flex: 0,
|
style={{
|
||||||
mb: 2,
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||||
"& .MuiOutlinedInput-root": {
|
fontSize: 14,
|
||||||
backgroundColor: "#121212",
|
|
||||||
color: "#e6edf3",
|
color: "#e6edf3",
|
||||||
fontFamily: "monospace",
|
background: "#121212",
|
||||||
fontSize: "0.9rem",
|
outline: "none",
|
||||||
|
minHeight: 300,
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
"& fieldset": { borderColor: "#444" },
|
caretColor: "#58a6ff"
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
}}
|
||||||
}
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Highlighted preview */}
|
|
||||||
<Box sx={{ flex: 1, minHeight: 120, overflowY: "auto", border: "1px solid #444", borderRadius: 1, p: 1, background: "#121212" }}>
|
|
||||||
<Typography variant="caption" sx={{ color: "#aaa" }}>Preview (syntax highlighted)</Typography>
|
|
||||||
<pre style={{ margin: 0 }}>
|
|
||||||
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
|
||||||
</pre>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -660,6 +659,7 @@ export default function ScriptEditor() {
|
|||||||
onChange={setRenameValue}
|
onChange={setRenameValue}
|
||||||
onCancel={() => setRenameOpen(false)}
|
onCancel={() => setRenameOpen(false)}
|
||||||
onSave={saveRenameFile}
|
onSave={saveRenameFile}
|
||||||
|
onBlur={() => setRenameValue((v) => ensureExt(v, type))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RenameFolderDialog
|
<RenameFolderDialog
|
||||||
@@ -680,6 +680,7 @@ export default function ScriptEditor() {
|
|||||||
onChangeType={setNewScriptType}
|
onChangeType={setNewScriptType}
|
||||||
onCancel={() => setNewScriptOpen(false)}
|
onCancel={() => setNewScriptOpen(false)}
|
||||||
onCreate={createNewScript}
|
onCreate={createNewScript}
|
||||||
|
onBlurName={() => setNewScriptName((v) => ensureExt(v, newScriptType))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
|
@@ -498,17 +498,20 @@ def save_script():
|
|||||||
|
|
||||||
# Determine target path
|
# Determine target path
|
||||||
if rel_path:
|
if rel_path:
|
||||||
# Ensure extension matches type if provided
|
# Append extension only if none provided
|
||||||
base, ext = os.path.splitext(rel_path)
|
base, ext = os.path.splitext(rel_path)
|
||||||
desired_ext = _ext_for_type(script_type) or ext
|
if not ext:
|
||||||
if desired_ext and not rel_path.lower().endswith(desired_ext):
|
desired_ext = _ext_for_type(script_type)
|
||||||
rel_path = base + desired_ext
|
if desired_ext:
|
||||||
|
rel_path = base + desired_ext
|
||||||
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
|
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
|
||||||
else:
|
else:
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({"error": "Missing name"}), 400
|
return jsonify({"error": "Missing name"}), 400
|
||||||
desired_ext = _ext_for_type(script_type) or os.path.splitext(name)[1] or ".txt"
|
# Append extension only if none provided
|
||||||
if not name.lower().endswith(desired_ext):
|
ext = os.path.splitext(name)[1]
|
||||||
|
if not ext:
|
||||||
|
desired_ext = _ext_for_type(script_type) or ".txt"
|
||||||
name = os.path.splitext(name)[0] + desired_ext
|
name = os.path.splitext(name)[0] + desired_ext
|
||||||
abs_path = os.path.abspath(os.path.join(scripts_root, os.path.basename(name)))
|
abs_path = os.path.abspath(os.path.join(scripts_root, os.path.basename(name)))
|
||||||
|
|
||||||
@@ -537,9 +540,11 @@ def rename_script_file():
|
|||||||
return jsonify({"error": "File not found"}), 404
|
return jsonify({"error": "File not found"}), 404
|
||||||
if not new_name:
|
if not new_name:
|
||||||
return jsonify({"error": "Invalid new_name"}), 400
|
return jsonify({"error": "Invalid new_name"}), 400
|
||||||
desired_ext = _ext_for_type(script_type) or os.path.splitext(new_name)[1]
|
# Append extension only if none provided
|
||||||
if desired_ext and not new_name.lower().endswith(desired_ext):
|
if not os.path.splitext(new_name)[1]:
|
||||||
new_name = os.path.splitext(new_name)[0] + desired_ext
|
desired_ext = _ext_for_type(script_type)
|
||||||
|
if desired_ext:
|
||||||
|
new_name = os.path.splitext(new_name)[0] + desired_ext
|
||||||
new_abs = os.path.join(os.path.dirname(old_abs), os.path.basename(new_name))
|
new_abs = os.path.join(os.path.dirname(old_abs), os.path.basename(new_name))
|
||||||
try:
|
try:
|
||||||
os.rename(old_abs, new_abs)
|
os.rename(old_abs, new_abs)
|
||||||
|
Reference in New Issue
Block a user