file explorer, execution engine und editor
This commit is contained in:
parent
2e7e2b4ff9
commit
4c5cbfa035
6
.streamlit/config.toml
Normal file
6
.streamlit/config.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[theme]
|
||||
base = "light"
|
||||
primaryColor = "#4A90D9"
|
||||
backgroundColor = "#ffffff"
|
||||
secondaryBackgroundColor = "#f5f5f5"
|
||||
textColor = "#1a1a1a"
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@ -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:
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user