file explorer, execution engine und editor

This commit is contained in:
leart-ramushi 2026-05-26 20:58:25 +02:00
parent 2e7e2b4ff9
commit 4c5cbfa035
6 changed files with 287 additions and 47 deletions

6
.streamlit/config.toml Normal file
View File

@ -0,0 +1,6 @@
[theme]
base = "light"
primaryColor = "#4A90D9"
backgroundColor = "#ffffff"
secondaryBackgroundColor = "#f5f5f5"
textColor = "#1a1a1a"

Binary file not shown.

View File

@ -5,18 +5,102 @@ from pathlib import Path
from backend.debug_logger import DebugLogger
# ---------------- BLOCKED PATTERNS ----------------
# These AST node patterns are refused before execution
BLOCKED_IMPORTS = {
"winreg", "ctypes", "msvcrt", # Windows internals
}
BLOCKED_CALLS = {
("os", "system"), ("os", "popen"),
("os", "remove"), ("os", "unlink"),
("os", "rmdir"), ("os", "removedirs"),
("shutil", "rmtree"), ("shutil", "move"),
("subprocess", "call"), ("subprocess", "Popen"),
("subprocess", "run"),
}
BLOCKED_BUILTINS = {"__import__", "eval", "exec", "compile"}
# Paths that are never allowed as cwd or in file arguments
BLOCKED_PATH_PREFIXES = [
"C:\\Windows",
"C:\\System32",
"/etc",
"/bin",
"/sbin",
"/usr/bin",
"/usr/sbin",
"/boot",
"/sys",
"/proc",
]
class SafetyError(Exception):
pass
class ExecutionEngine:
def __init__(self):
self.logger = DebugLogger()
# ---------------- SAFETY CHECK ----------------
def _check_safe(self, filepath: str) -> None:
"""
Raises SafetyError if the file contains dangerous patterns.
Only runs on .py files (other types are blocked outright).
"""
path = Path(filepath)
source = path.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(path))
except SyntaxError:
return # syntax errors handled separately
for node in ast.walk(tree):
# Block dangerous imports
if isinstance(node, (ast.Import, ast.ImportFrom)):
names = (
[a.name for a in node.names]
if isinstance(node, ast.Import)
else [node.module or ""]
)
for name in names:
root = name.split(".")[0]
if root in BLOCKED_IMPORTS:
raise SafetyError(f"Blocked import: '{name}'")
# Block dangerous attribute calls like os.remove(...)
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Attribute):
if isinstance(func.value, ast.Name):
pair = (func.value.id, func.attr)
if pair in BLOCKED_CALLS:
raise SafetyError(
f"Blocked call: '{func.value.id}.{func.attr}()'"
)
# Block dangerous builtins: eval(), exec(), __import__()
if isinstance(func, ast.Name) and func.id in BLOCKED_BUILTINS:
raise SafetyError(f"Blocked builtin: '{func.id}()'")
# Block absolute paths pointing at system directories in string literals
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
for prefix in BLOCKED_PATH_PREFIXES:
if node.value.lower().startswith(prefix.lower()):
raise SafetyError(
f"Blocked system path in code: '{node.value}'"
)
# ---------------- SYNTAX CHECK ----------------
def check_syntax(self, filepath: str) -> bool:
"""Parse the file with ast. Returns True if valid, False if not."""
path = Path(filepath)
if path.suffix.lower() != ".py":
return True # skip non-Python files
return True
try:
source = path.read_text(encoding="utf-8")
ast.parse(source, filename=str(path))
@ -34,27 +118,43 @@ class ExecutionEngine:
self.logger.clear()
path = Path(filepath)
# --- guards ---
# --- file exists ---
if not path.exists():
self.logger.log_stderr(f"File not found: {filepath}")
return self._result("", f"File not found: {filepath}", -1)
ext = path.suffix.lower()
if ext == ".py":
# syntax check before running
if not self.check_syntax(filepath):
err = self.logger.format()
return self._result("", err, -1)
cmd = [sys.executable, str(path)]
elif ext == ".js":
cmd = ["node", str(path)]
elif ext == ".sh":
cmd = ["bash", str(path)]
else:
# --- only allow known safe types ---
if ext not in (".py", ".js", ".sh"):
msg = f"Unsupported file type: {ext}"
self.logger.log_stderr(msg)
return self._result("", msg, -1)
# --- block shell scripts on Windows (too risky) ---
if ext == ".sh" and sys.platform == "win32":
msg = "Shell scripts are not supported on Windows."
self.logger.log_stderr(msg)
return self._result("", msg, -1)
# --- Python: syntax + safety check ---
if ext == ".py":
if not self.check_syntax(filepath):
return self._result("", self.logger.format(), -1)
try:
self._check_safe(filepath)
except SafetyError as e:
msg = f"Safety check failed: {e}"
self.logger.log_stderr(msg)
return self._result("", msg, -1)
cmd = [sys.executable, str(path)]
elif ext == ".js":
cmd = ["node", str(path)]
elif ext == ".sh":
cmd = ["bash", str(path)]
# --- execute ---
self.logger.log(f"Running: {path.name}")
try:
@ -68,7 +168,6 @@ class ExecutionEngine:
self.logger.log_stdout(result.stdout)
self.logger.log_stderr(result.stderr)
self.logger.log_result(result.returncode)
return self._result(result.stdout, result.stderr, result.returncode)
except subprocess.TimeoutExpired:

View File

@ -3,23 +3,70 @@ from pathlib import Path
class FileManager:
"""
Handles file reading and writing
Handles all filesystem operations: read, write, create, delete, and change detection.
"""
def __init__(self, root_path="."):
self.root_path = Path(root_path).resolve()
def read_file(self, file_path):
SUPPORTED_EXTENSIONS = {'.py', '.js', '.html', '.css', '.json', '.md'}
# ---------------- READ / WRITE ----------------
def read_file(self, file_path: str) -> str:
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def save_file(self, file_path, content):
def save_file(self, file_path: str, content: str) -> bool:
try:
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
return True
except Exception:
return False
return False
# ---------------- CREATE ----------------
def create_file(self, target_dir: str, name: str) -> tuple:
try:
path = Path(target_dir) / name.strip()
if path.exists():
return False, f"'{name}' already exists."
if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
return False, f"Unsupported extension. Use: {', '.join(sorted(self.SUPPORTED_EXTENSIONS))}"
path.touch()
return True, f"Created '{name}'."
except Exception as e:
return False, str(e)
def create_folder(self, target_dir: str, name: str) -> tuple:
try:
path = Path(target_dir) / name.strip()
if path.exists():
return False, f"'{name}' already exists."
path.mkdir(parents=False)
return True, f"Created folder '{name}'."
except Exception as e:
return False, str(e)
# ---------------- LISTING ----------------
def list_all_dirs(self, root_folder: str) -> list:
"""Return all directories (root first, then subdirs sorted)."""
root = Path(root_folder)
dirs = [root]
for p in sorted(root.rglob("*")):
if p.is_dir():
dirs.append(p)
return dirs
# ---------------- CHANGE DETECTION ----------------
def snapshot(self, root_folder: str) -> frozenset:
"""Snapshot of all paths for change detection."""
try:
return frozenset(
(str(p), p.is_dir())
for p in Path(root_folder).rglob("*")
)
except Exception:
return frozenset()

View File

@ -32,7 +32,7 @@ class CodeEditor:
st.caption(f" {filename.split('/')[-1] or filename}" if filename else "")
with run_col:
run_clicked = st.button(
"▶ Run",
"▶ RUN CODE",
key=f"run_{filename}",
type="primary",
use_container_width=True

View File

@ -2,21 +2,23 @@ import tkinter as tk
from tkinter import filedialog
from pathlib import Path
import streamlit as st
from streamlit_tree_select import tree_select
import streamlit_antd_components as sac
from backend.file_manager import FileManager
class FileNavigation:
"""
Builds a VS Code-like file tree for streamlit_tree_select
and handles folder picking.
Builds a VS Code-like file tree using streamlit-antd-components.
All filesystem ops delegated to FileManager.
"""
def __init__(self):
self.supported_extensions = {'.py', '.js', '.html', '.css', '.json', '.md'}
self.file_manager = FileManager()
self.supported_extensions = FileManager.SUPPORTED_EXTENSIONS
# ---------------- FOLDER PICKER ----------------
def pick_folder(self) -> str | None:
"""Open a native OS folder picker and return the selected path."""
root = tk.Tk()
root.withdraw()
root.wm_attributes("-topmost", 1)
@ -26,18 +28,12 @@ class FileNavigation:
# ---------------- TREE BUILDER ----------------
def build_tree(self, path=None) -> list:
"""Build a VS Code-style tree structure for streamlit_tree_select."""
if path is None:
path = Path.cwd()
path = Path(path)
def build_node(current_path: Path) -> dict:
def build_node(current_path: Path) -> sac.TreeItem:
is_dir = current_path.is_dir()
node = {
"label": ("📁 " if is_dir else "📄 ") + current_path.name,
"value": str(current_path),
}
if is_dir:
children = []
try:
@ -49,20 +45,92 @@ class FileNavigation:
children.append(build_node(item))
except PermissionError:
pass
node["children"] = children
return node
return sac.TreeItem(
label=current_path.name,
icon="folder",
children=children if children else None,
)
else:
return sac.TreeItem(
label=current_path.name,
icon="file-earmark-code",
)
return [build_node(path)]
# ---------------- DIALOG ----------------
@st.dialog("New file or folder")
def _new_item_dialog(self, root_folder: str):
root = Path(root_folder)
all_dirs = self.file_manager.list_all_dirs(root_folder)
def dir_label(p: Path) -> str:
rel = p.relative_to(root)
return "/ (root)" if str(rel) == "." else str(rel)
dir_labels = [dir_label(d) for d in all_dirs]
dir_paths = [str(d) for d in all_dirs]
mode = st.segmented_control(
"Type",
options=["📄 File", "📁 Folder"],
default="📄 File",
label_visibility="collapsed"
)
is_file = mode == "📄 File"
st.markdown("---")
selected_label = st.selectbox("Location", options=dir_labels, index=0)
target_dir = dir_paths[dir_labels.index(selected_label)]
name = st.text_input(
"File name" if is_file else "Folder name",
placeholder="e.g. main.py" if is_file else "e.g. utils",
)
if name and name.strip():
sep = "" if target_dir.endswith("/") else "/"
st.caption(f"`{target_dir}{sep}{name.strip()}`")
st.markdown("---")
col_cancel, col_create = st.columns(2)
with col_cancel:
if st.button("Cancel", use_container_width=True):
st.rerun()
with col_create:
if st.button(
"Create file" if is_file else "Create folder",
type="primary",
use_container_width=True,
disabled=not (name and name.strip())
):
if is_file:
ok, msg = self.file_manager.create_file(target_dir, name)
else:
ok, msg = self.file_manager.create_folder(target_dir, name)
if ok:
st.success(msg)
st.rerun()
else:
st.error(msg)
# ---------------- SIDEBAR RENDERER ----------------
def render_sidebar(self) -> str | None:
"""
Renders the full sidebar: folder button, caption, divider, and tree.
Returns the selected file path or None.
"""
st.sidebar.title("File Explorer")
root_folder = st.session_state.get("root_folder")
# --- Auto-refresh: detect external filesystem changes ---
if root_folder:
current_snapshot = self.file_manager.snapshot(root_folder)
last_snapshot = st.session_state.get("_folder_snapshot")
if last_snapshot is None:
st.session_state._folder_snapshot = current_snapshot
elif current_snapshot != last_snapshot:
st.session_state._folder_snapshot = current_snapshot
st.rerun()
with st.sidebar:
btn_left, btn_right = st.columns(2)
with btn_left:
@ -70,6 +138,7 @@ class FileNavigation:
picked = self.pick_folder()
if picked:
st.session_state.root_folder = picked
st.session_state._folder_snapshot = None
st.rerun()
with btn_right:
if st.button("💬 Ask AI", key="toggle_chat", use_container_width=True):
@ -79,22 +148,41 @@ class FileNavigation:
st.markdown("---")
if root_folder:
if st.button("Add new File/Folder", key="new_item_btn", use_container_width=True):
self._new_item_dialog(root_folder)
try:
root_path = Path(root_folder)
if root_path.exists() and root_path.is_dir():
tree_data = self.build_tree(root_path)
selection = tree_select(
tree_data,
check_model="leaf",
only_leaf_checkboxes=True
selected = sac.tree(
items=tree_data,
label=" ",
index=None,
align="left",
size="sm",
icon="folder2",
open_all=False,
checkbox=False,
show_line=False,
key=f"tree_{root_folder}",
)
if selection and selection.get("checked"):
return selection["checked"][0]
if selected:
return self._resolve_path(root_path, selected)
else:
st.warning("Folder not found.")
except Exception:
st.error("Could not load folder.")
except Exception as e:
st.error(f"Could not load folder: {e}")
else:
st.text("No folder selected yet.")
return None
# ---------------- PATH RESOLVER ----------------
def _resolve_path(self, root_path: Path, selected_label: str) -> str | None:
for p in root_path.rglob("*"):
if p.name == selected_label and p.suffix in self.supported_extensions:
return str(p)
return None