285 lines
12 KiB
Python
285 lines
12 KiB
Python
"""Code Editor view — renders the Ace editor, file tabs, and execution output."""
|
|
|
|
import ast
|
|
|
|
import streamlit as st
|
|
import streamlit_ace as st_ace
|
|
from pathlib import Path
|
|
|
|
from backend.managers.file_manager import FileManager
|
|
from backend.managers.execution_engine import ExecutionEngine
|
|
from backend.managers.debug_logger import get_logger, DebugLogger
|
|
logger = get_logger(__name__)
|
|
|
|
# Maps file extensions to Ace editor language modes for syntax highlighting.
|
|
LANG_MAP = {
|
|
".py": "python", ".js": "javascript",
|
|
".html": "html", ".css": "css", ".sh": "bash",
|
|
".json": "json", ".yaml": "yaml", ".yml": "yaml"
|
|
}
|
|
|
|
|
|
# ── Modals ────────────────────────────────────────────────────────────────────
|
|
|
|
@st.dialog("Rename File")
|
|
def _rename_dialog(file_path: str):
|
|
"""Dialog for renaming the given file.
|
|
Updates open_files, files_content and active_file in session_state so
|
|
all tabs and the editor reference the new path immediately.
|
|
"""
|
|
fm = FileManager()
|
|
st.write(f"Current name: **{Path(file_path).name}**")
|
|
new_name = st.text_input("New name:", value=Path(file_path).stem)
|
|
|
|
col1, col2 = st.columns(2)
|
|
with col1:
|
|
if st.button("Confirm", type="primary", use_container_width=True):
|
|
if not new_name.strip():
|
|
st.warning("Please enter a name.")
|
|
elif "/" in new_name or "\\" in new_name:
|
|
st.warning("Name must not contain slashes.")
|
|
else:
|
|
rel_path = str(Path(file_path).relative_to(fm.base_path))
|
|
if fm.rename_file(rel_path, new_name.strip()):
|
|
ext = Path(file_path).suffix
|
|
new_file_path = str(Path(file_path).parent / (Path(new_name.strip()).stem + ext))
|
|
# Update the open-files list in place so the tab order is preserved.
|
|
i = st.session_state.open_files.index(file_path)
|
|
st.session_state.open_files[i] = new_file_path
|
|
# Transfer cached editor content to the new path key.
|
|
st.session_state.files_content[new_file_path] = \
|
|
st.session_state.files_content.pop(file_path)
|
|
# Update active_file if the renamed file was the active one.
|
|
if st.session_state.active_file == file_path:
|
|
st.session_state.active_file = new_file_path
|
|
st.rerun()
|
|
logger.info("Rename file %s to %s successfull", file_path, new_name )
|
|
else:
|
|
logger.warning("Rename failed.")
|
|
st.error("Rename failed. Check that the file %s still exists.", file_path)
|
|
with col2:
|
|
if st.button("Cancel", use_container_width=True):
|
|
st.rerun()
|
|
|
|
|
|
@st.dialog("Delete File")
|
|
def _delete_dialog(abs_file_path: str):
|
|
"""Confirmation dialog before permanently deleting the given file.
|
|
|
|
Removes the file from disk and also cleans up the editor's open-files list,
|
|
content cache, and active_file pointer so the UI stays consistent.
|
|
|
|
Args:
|
|
abs_file_path: Absolute path to the file that should be deleted.
|
|
"""
|
|
fm = FileManager()
|
|
file_name = Path(abs_file_path).name
|
|
relative_path = str(Path(abs_file_path).relative_to(fm.base_path))
|
|
st.warning(f"Delete **{file_name}**? This cannot be undone.")
|
|
|
|
col1, col2 = st.columns(2)
|
|
with col1:
|
|
if st.button("Delete", type="primary", use_container_width=True):
|
|
if fm.delete_file(relative_path):
|
|
st.session_state.open_files.remove(abs_file_path)
|
|
st.session_state.files_content.pop(abs_file_path, None)
|
|
if st.session_state.active_file == abs_file_path:
|
|
st.session_state.active_file = (
|
|
st.session_state.open_files[0]
|
|
if st.session_state.open_files else None
|
|
)
|
|
st.rerun()
|
|
logger.info("Deleting file %s successfull.", abs_file_path)
|
|
else:
|
|
st.error("Delete failed. Check that the file still exists.")
|
|
logger.warning("Deleting file %s failed.", abs_file_path)
|
|
with col2:
|
|
if st.button("Cancel", use_container_width=True):
|
|
st.rerun()
|
|
|
|
|
|
def run_active_file():
|
|
"""Execute the currently active file and store the result in exec_results[file_path].
|
|
|
|
Runs an ast.parse() check first — if the syntax is invalid the file is not
|
|
executed and ast_error=True is stored so the UI can show a targeted warning.
|
|
|
|
Returns:
|
|
The result dict, or None if no active file is set.
|
|
"""
|
|
active_file = st.session_state.active_file
|
|
|
|
if not active_file:
|
|
st.warning("No active file to run.")
|
|
return
|
|
|
|
execution_engine = ExecutionEngine()
|
|
|
|
logger.info("Executing code from %s...", active_file)
|
|
|
|
# ast check — only for Python files
|
|
if Path(active_file).suffix == ".py":
|
|
source = st.session_state.get("files_content", {}).get(active_file, "")
|
|
try:
|
|
ast.parse(source)
|
|
except SyntaxError as e:
|
|
result = {"stdout": "", "stderr": str(e), "return_code": -1, "ast_error": True}
|
|
st.session_state.exec_results[active_file] = result
|
|
logger.error("Syntax error: %s", e)
|
|
return result
|
|
|
|
with st.spinner(f"Running {Path(active_file).name}..."):
|
|
output = execution_engine.run_code(Path(active_file))
|
|
|
|
if output["rc"] == 0:
|
|
logger.info("Execution completed successfully.")
|
|
else:
|
|
logger.error("Execution failed with exit code %s.", output['rc'])
|
|
|
|
result = {
|
|
"stdout": output["stdout"],
|
|
"stderr": output["stderr"],
|
|
"return_code": output["rc"],
|
|
"ast_error": False,
|
|
}
|
|
st.session_state.exec_results[active_file] = result
|
|
return result
|
|
|
|
class FileViewer:
|
|
"""UI component that renders the code editor tabs, Ace editor, and run output."""
|
|
|
|
def __init__(self):
|
|
self.fm = FileManager()
|
|
|
|
def render(self):
|
|
"""Render the full Code Editor view with tabs, Ace editor, and run output."""
|
|
st.subheader("Code Editor")
|
|
|
|
if not st.session_state.open_files:
|
|
st.info("Please select a file to edit.")
|
|
return
|
|
|
|
# ── Tab bar via st.tabs() ─────────────────────────────────────────────
|
|
tab_names = [Path(f).name for f in st.session_state.open_files]
|
|
tabs = st.tabs(tab_names)
|
|
|
|
# Tab-Sprung via JavaScript — pop() verhindert Loop bei jedem Rerun.
|
|
jump_target = st.session_state.pop("_jump_to_tab", None)
|
|
if jump_target and jump_target in st.session_state.open_files:
|
|
idx = st.session_state.open_files.index(jump_target)
|
|
st.components.v1.html(
|
|
f"""<script>
|
|
(function() {{
|
|
setTimeout(function() {{
|
|
const tabs = window.parent.document
|
|
.querySelectorAll('button[data-baseweb="tab"]');
|
|
if (tabs[{idx}]) tabs[{idx}].click();
|
|
}}, 100);
|
|
}})();
|
|
</script>""",
|
|
height=0,
|
|
)
|
|
|
|
for idx, file_path in enumerate(st.session_state.open_files):
|
|
with tabs[idx]:
|
|
# Load file content from disk on first open; use cached version afterwards.
|
|
if file_path not in st.session_state.files_content:
|
|
st.session_state.files_content[file_path] = self.fm.read_file(Path(file_path))
|
|
|
|
file_language = LANG_MAP.get(Path(file_path).suffix, "text")
|
|
|
|
code = st_ace.st_ace(
|
|
value=st.session_state.files_content[file_path],
|
|
language=file_language,
|
|
theme="monokai",
|
|
key=f"code_editor_{file_path}",
|
|
auto_update=True,
|
|
height=400,
|
|
)
|
|
|
|
if code != st.session_state.files_content[file_path]:
|
|
st.session_state.files_content[file_path] = code
|
|
|
|
cols = st.columns([1, 1, 1, 1, 1])
|
|
with cols[0]:
|
|
if st.button("Save Changes", key=f"save_{file_path}"):
|
|
if self.fm.save_file(file_path, code):
|
|
st.success("File saved successfully!")
|
|
|
|
with cols[1]:
|
|
st.download_button(
|
|
label="Download",
|
|
data=st.session_state.files_content.get(file_path, ""),
|
|
file_name=Path(file_path).name,
|
|
mime="text/plain",
|
|
key=f"download_{file_path}",
|
|
)
|
|
|
|
with cols[2]:
|
|
if st.button("Close File", key=f"close_{file_path}"):
|
|
st.session_state.open_files.remove(file_path)
|
|
st.session_state.files_content.pop(file_path, None)
|
|
st.session_state.active_file = (
|
|
st.session_state.open_files[0]
|
|
if st.session_state.open_files else None
|
|
)
|
|
st.rerun()
|
|
|
|
with cols[3]:
|
|
if st.button("Rename File", key=f"rename_{file_path}"):
|
|
_rename_dialog(file_path)
|
|
|
|
with cols[4]:
|
|
if st.button("Delete File", key=f"delete_{file_path}"):
|
|
_delete_dialog(file_path)
|
|
|
|
# ── Run + Output ──────────────────────────────────────────────
|
|
if st.button("▶ Run Code", key=f"run_code_{file_path}", type="primary"):
|
|
run_active_file()
|
|
st.rerun()
|
|
|
|
result = st.session_state.get("exec_results", {}).get(file_path)
|
|
if result:
|
|
st.subheader("Execution Output")
|
|
|
|
if result.get("ast_error"):
|
|
st.warning("⚠️ Syntax Error detected before execution — code was not run.")
|
|
elif result["return_code"] == 0:
|
|
st.success("✅ Exit code: 0")
|
|
else:
|
|
st.error(f"❌ Exit code: {result['return_code']}")
|
|
|
|
if result["return_code"] != 0 or result.get("stderr"):
|
|
if st.button("🐛 Debug with AI", key=f"debug_with_ai_{file_path}", type="primary"):
|
|
file_name = Path(file_path).name
|
|
code_content = st.session_state.files_content.get(file_path, "")
|
|
lang = LANG_MAP.get(Path(file_path).suffix, "python")
|
|
formatted_output = DebugLogger.format_debug_output(result)
|
|
debug_message = (
|
|
f"I got an error while running **{file_name}**:\n\n"
|
|
f"```\n{formatted_output}\n```\n\n"
|
|
f"**Here is the code:**\n```{lang}\n{code_content}\n```\n\n"
|
|
f"Can you help me fix this?"
|
|
)
|
|
st.session_state.pending_debug_message = debug_message
|
|
st.session_state["_navigate_to_chat"] = True
|
|
st.rerun()
|
|
|
|
if result.get("stdout"):
|
|
st.text_area("Standard Output", value=result["stdout"], height=200,
|
|
disabled=True, key=f"run_stdout_{file_path}")
|
|
if result.get("stderr"):
|
|
st.text_area("Standard Error", value=result["stderr"], height=200,
|
|
disabled=True, key=f"run_stderr_{file_path}")
|
|
if not result.get("stdout") and not result.get("stderr"):
|
|
st.info("No output produced by the code execution.")
|
|
|
|
|
|
def render_editor():
|
|
"""Entry point for app.py — delegates to FileViewer."""
|
|
FileViewer().render()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
render_editor()
|