dev-leart #1
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
|
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:
|
class ExecutionEngine:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.logger = DebugLogger()
|
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 ----------------
|
# ---------------- SYNTAX CHECK ----------------
|
||||||
def check_syntax(self, filepath: str) -> bool:
|
def check_syntax(self, filepath: str) -> bool:
|
||||||
"""Parse the file with ast. Returns True if valid, False if not."""
|
|
||||||
path = Path(filepath)
|
path = Path(filepath)
|
||||||
if path.suffix.lower() != ".py":
|
if path.suffix.lower() != ".py":
|
||||||
return True # skip non-Python files
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
source = path.read_text(encoding="utf-8")
|
source = path.read_text(encoding="utf-8")
|
||||||
ast.parse(source, filename=str(path))
|
ast.parse(source, filename=str(path))
|
||||||
@ -34,27 +118,43 @@ class ExecutionEngine:
|
|||||||
self.logger.clear()
|
self.logger.clear()
|
||||||
path = Path(filepath)
|
path = Path(filepath)
|
||||||
|
|
||||||
# --- guards ---
|
# --- file exists ---
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
self.logger.log_stderr(f"File not found: {filepath}")
|
self.logger.log_stderr(f"File not found: {filepath}")
|
||||||
return self._result("", f"File not found: {filepath}", -1)
|
return self._result("", f"File not found: {filepath}", -1)
|
||||||
|
|
||||||
ext = path.suffix.lower()
|
ext = path.suffix.lower()
|
||||||
if ext == ".py":
|
|
||||||
# syntax check before running
|
# --- only allow known safe types ---
|
||||||
if not self.check_syntax(filepath):
|
if ext not in (".py", ".js", ".sh"):
|
||||||
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:
|
|
||||||
msg = f"Unsupported file type: {ext}"
|
msg = f"Unsupported file type: {ext}"
|
||||||
self.logger.log_stderr(msg)
|
self.logger.log_stderr(msg)
|
||||||
return self._result("", msg, -1)
|
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 ---
|
# --- execute ---
|
||||||
self.logger.log(f"Running: {path.name}")
|
self.logger.log(f"Running: {path.name}")
|
||||||
try:
|
try:
|
||||||
@ -68,7 +168,6 @@ class ExecutionEngine:
|
|||||||
self.logger.log_stdout(result.stdout)
|
self.logger.log_stdout(result.stdout)
|
||||||
self.logger.log_stderr(result.stderr)
|
self.logger.log_stderr(result.stderr)
|
||||||
self.logger.log_result(result.returncode)
|
self.logger.log_result(result.returncode)
|
||||||
|
|
||||||
return self._result(result.stdout, result.stderr, result.returncode)
|
return self._result(result.stdout, result.stderr, result.returncode)
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
|||||||
@ -3,23 +3,70 @@ from pathlib import Path
|
|||||||
|
|
||||||
class FileManager:
|
class FileManager:
|
||||||
"""
|
"""
|
||||||
Handles file reading and writing
|
Handles all filesystem operations: read, write, create, delete, and change detection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, root_path="."):
|
def __init__(self, root_path="."):
|
||||||
self.root_path = Path(root_path).resolve()
|
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:
|
try:
|
||||||
with open(file_path, "r", encoding="utf-8") as f:
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def save_file(self, file_path, content):
|
def save_file(self, file_path: str, content: str) -> bool:
|
||||||
try:
|
try:
|
||||||
with open(file_path, "w", encoding="utf-8") as f:
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
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 "")
|
st.caption(f" {filename.split('/')[-1] or filename}" if filename else "")
|
||||||
with run_col:
|
with run_col:
|
||||||
run_clicked = st.button(
|
run_clicked = st.button(
|
||||||
"▶ Run",
|
"▶ RUN CODE",
|
||||||
key=f"run_{filename}",
|
key=f"run_{filename}",
|
||||||
type="primary",
|
type="primary",
|
||||||
use_container_width=True
|
use_container_width=True
|
||||||
|
|||||||
@ -2,21 +2,23 @@ import tkinter as tk
|
|||||||
from tkinter import filedialog
|
from tkinter import filedialog
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import streamlit as st
|
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:
|
class FileNavigation:
|
||||||
"""
|
"""
|
||||||
Builds a VS Code-like file tree for streamlit_tree_select
|
Builds a VS Code-like file tree using streamlit-antd-components.
|
||||||
and handles folder picking.
|
All filesystem ops delegated to FileManager.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.supported_extensions = {'.py', '.js', '.html', '.css', '.json', '.md'}
|
self.file_manager = FileManager()
|
||||||
|
self.supported_extensions = FileManager.SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
# ---------------- FOLDER PICKER ----------------
|
# ---------------- FOLDER PICKER ----------------
|
||||||
def pick_folder(self) -> str | None:
|
def pick_folder(self) -> str | None:
|
||||||
"""Open a native OS folder picker and return the selected path."""
|
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
root.wm_attributes("-topmost", 1)
|
root.wm_attributes("-topmost", 1)
|
||||||
@ -26,18 +28,12 @@ class FileNavigation:
|
|||||||
|
|
||||||
# ---------------- TREE BUILDER ----------------
|
# ---------------- TREE BUILDER ----------------
|
||||||
def build_tree(self, path=None) -> list:
|
def build_tree(self, path=None) -> list:
|
||||||
"""Build a VS Code-style tree structure for streamlit_tree_select."""
|
|
||||||
if path is None:
|
if path is None:
|
||||||
path = Path.cwd()
|
path = Path.cwd()
|
||||||
|
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
|
|
||||||
def build_node(current_path: Path) -> dict:
|
def build_node(current_path: Path) -> sac.TreeItem:
|
||||||
is_dir = current_path.is_dir()
|
is_dir = current_path.is_dir()
|
||||||
node = {
|
|
||||||
"label": ("📁 " if is_dir else "📄 ") + current_path.name,
|
|
||||||
"value": str(current_path),
|
|
||||||
}
|
|
||||||
if is_dir:
|
if is_dir:
|
||||||
children = []
|
children = []
|
||||||
try:
|
try:
|
||||||
@ -49,20 +45,92 @@ class FileNavigation:
|
|||||||
children.append(build_node(item))
|
children.append(build_node(item))
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
pass
|
pass
|
||||||
node["children"] = children
|
return sac.TreeItem(
|
||||||
return node
|
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)]
|
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 ----------------
|
# ---------------- SIDEBAR RENDERER ----------------
|
||||||
def render_sidebar(self) -> str | None:
|
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")
|
st.sidebar.title("File Explorer")
|
||||||
root_folder = st.session_state.get("root_folder")
|
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:
|
with st.sidebar:
|
||||||
btn_left, btn_right = st.columns(2)
|
btn_left, btn_right = st.columns(2)
|
||||||
with btn_left:
|
with btn_left:
|
||||||
@ -70,6 +138,7 @@ class FileNavigation:
|
|||||||
picked = self.pick_folder()
|
picked = self.pick_folder()
|
||||||
if picked:
|
if picked:
|
||||||
st.session_state.root_folder = picked
|
st.session_state.root_folder = picked
|
||||||
|
st.session_state._folder_snapshot = None
|
||||||
st.rerun()
|
st.rerun()
|
||||||
with btn_right:
|
with btn_right:
|
||||||
if st.button("💬 Ask AI", key="toggle_chat", use_container_width=True):
|
if st.button("💬 Ask AI", key="toggle_chat", use_container_width=True):
|
||||||
@ -79,22 +148,41 @@ class FileNavigation:
|
|||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
|
|
||||||
if root_folder:
|
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:
|
try:
|
||||||
root_path = Path(root_folder)
|
root_path = Path(root_folder)
|
||||||
if root_path.exists() and root_path.is_dir():
|
if root_path.exists() and root_path.is_dir():
|
||||||
tree_data = self.build_tree(root_path)
|
tree_data = self.build_tree(root_path)
|
||||||
selection = tree_select(
|
|
||||||
tree_data,
|
selected = sac.tree(
|
||||||
check_model="leaf",
|
items=tree_data,
|
||||||
only_leaf_checkboxes=True
|
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:
|
else:
|
||||||
st.warning("Folder not found.")
|
st.warning("Folder not found.")
|
||||||
except Exception:
|
except Exception as e:
|
||||||
st.error("Could not load folder.")
|
st.error(f"Could not load folder: {e}")
|
||||||
else:
|
else:
|
||||||
st.text("No folder selected yet.")
|
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
|
return None
|
||||||
Loading…
x
Reference in New Issue
Block a user