2026-05-25 19:02:20 +02:00

399 lines
16 KiB
Python

"""Sidebar — navigation radio, logo, and the workspace file explorer."""
import streamlit as st
from streamlit_arborist import tree_view
from pathlib import Path
from backend.managers.file_manager import FileManager
# Shared FileManager instance for all sidebar operations.
fm = FileManager()
# Maps file extensions (and special keys "folder"/"default") to display emojis
# shown next to each entry in the file tree.
SUFFIX_MAP = {
".py": "🐍", # Python
".js": "🟨", # JavaScript (Gelbes Quadrat/Logo)
".html": "🌐", # HTML (Web)
".css": "🎨", # CSS (Styling)
".json": "📦", # JSON (Datenpaket)
".yaml": "⚙️", # YAML (Konfiguration)
".yml": "⚙️", # YAML
".sh": "🐚", # Bash/Shell (Shell-Icon)
".md": "📝", # Markdown
".txt": "📄", # Text
".tex": "📑", # LaTeX
".c": "🔵", # C (Blaues Icon)
".cpp": "🔷", # C++
".java": "", # Java
"folder": "📁", # Ordner
"default": "📄" # Unbekannt
}
# ── Modals ────────────────────────────────────────────────────────────────────
@st.dialog("Delete Folder")
def _delete_folder_dialog(folder_rel: str, folder_name: str):
"""Confirmation dialog before permanently deleting a folder and its contents."""
st.warning(f"Delete **{folder_name}** and all its contents? This cannot be undone.")
col1, col2 = st.columns(2)
with col1:
if st.button("Delete", type="primary", use_container_width=True):
if fm.delete_folder(folder_rel):
# Clear the selected-folder state so the action bar disappears.
st.session_state.selected_folder = None
st.session_state.selected_folder_rel = None
st.rerun()
else:
st.error("Delete failed.")
with col2:
if st.button("Cancel", use_container_width=True):
st.rerun()
@st.dialog("Add File")
def _add_file_dialog(parent_path: str = ""):
"""Dialog for creating a new file inside the given folder (or workspace root).
Args:
parent_path: Workspace-relative path of the parent folder. Pass an
empty string to create the file at the workspace root.
"""
with st.form("add_file_form"):
name = st.text_input("File name:", placeholder="e.g. script.py")
col1, col2 = st.columns(2)
with col1:
submitted = st.form_submit_button("Create", type="primary", use_container_width=True)
with col2:
cancel = st.form_submit_button("Cancel", use_container_width=True)
if submitted:
if not name.strip():
st.warning("Please enter a file name.")
elif "/" in name or "\\" in name:
st.warning("Name must not contain slashes.")
else:
if fm.create_file(parent_path, name.strip()):
st.rerun()
if cancel:
st.rerun()
@st.dialog("Add Folder")
def _add_folder_dialog(parent_path: str = ""):
"""Dialog for creating a new subfolder inside the given folder (or workspace root).
Args:
parent_path: Workspace-relative path of the parent folder. Pass an
empty string to create the folder at the workspace root.
"""
with st.form("add_folder_form"):
name = st.text_input("Folder name:", placeholder="e.g. utils")
col1, col2 = st.columns(2)
with col1:
submitted = st.form_submit_button("Create", type="primary", use_container_width=True)
with col2:
cancel = st.form_submit_button("Cancel", use_container_width=True)
if submitted:
if not name.strip():
st.warning("Please enter a folder name.")
elif "/" in name or "\\" in name:
st.warning("Name must not contain slashes.")
else:
if fm.create_folder(parent_path, name.strip()):
st.rerun()
if cancel:
st.rerun()
@st.dialog("Rename File")
def _rename_file_dialog(relative_file_path: str, file_name: str):
"""
Dialog to rename a file.
Args:
relative_file_path (str): The current relative path (without base path) to the file to rename, including the file name.
file_name (str): The current name of the file, including the extension.
"""
st.write(f"Current name: **{file_name}**")
with st.form("rename_file_form"):
new_name = st.text_input(
"New name:",
value=Path(relative_file_path).stem
)
col1, col2 = st.columns(2)
with col1:
submitted = st.form_submit_button(
"Confirm",
type="primary",
use_container_width=True
)
with col2:
cancel = st.form_submit_button(
"Cancel",
use_container_width=True
)
if submitted:
if not new_name.strip():
st.warning("Please enter a name.")
elif "/" in new_name or "\\" in new_name or "." in new_name:
st.warning("Name must not contain slashes.")
else:
if fm.rename_file(relative_file_path, new_name.strip()):
absolute_file_path = str(Path(fm.base_path / relative_file_path))
ext = Path(absolute_file_path).suffix
new_file_path = str(
Path(absolute_file_path).parent / (Path(new_name.strip()).stem + ext)
)
if absolute_file_path in st.session_state.open_files:
i = st.session_state.open_files.index(absolute_file_path)
st.session_state.open_files[i] = new_file_path
if absolute_file_path in st.session_state.files_content:
st.session_state.files_content[new_file_path] = \
st.session_state.files_content.pop(absolute_file_path)
if st.session_state.active_file == absolute_file_path:
st.session_state.active_file = new_file_path
st.rerun()
else:
st.error("Rename failed. Check that the file still exists.")
st.error(f"Attempted to rename: {relative_file_path} to {new_name.strip()}")
if cancel:
st.rerun()
@st.dialog("Delete File")
def _delete_file_dialog(relative_file_path: str, file_name: str):
"""Confirmation dialog before permanently deleting a file.
After a successful delete the file is also removed from the editor's
open-files list and content cache so it cannot be saved back to disk.
Args:
relative_file_path: Workspace-relative path to the file (used by FileManager).
file_name: Display name shown in the warning message.
"""
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_file_path):
abs_file_path = str(Path(fm.base_path) / relative_file_path)
st.session_state.open_files.remove(abs_file_path)
st.session_state.files_content.pop(abs_file_path, None)
# Fall back to the first remaining open file, or None if all tabs are closed.
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()
else:
st.error("Delete failed. Check that the file still exists.")
with col2:
if st.button("Cancel", use_container_width=True):
st.rerun()
# ── File tree ─────────────────────────────────────────────────────────────────
def build_arborist_tree(tree, parent_path=Path()):
"""Convert the FileManager dict tree into the node format expected by streamlit-arborist.
Folders become nodes with a "children" list; files become leaf nodes
with an emoji prefix derived from their extension.
Args:
tree: Nested dict from FileManager.get_file_tree()
parent_path: Accumulates the relative path while recursing.
Returns:
List of node dicts accepted by tree_view().
"""
nodes = []
for name, content in sorted(tree.items()):
full_path = parent_path / name
node_id = str(full_path.as_posix()) # forward-slash IDs work cross-platform
if isinstance(content, dict):
# Directory — recurse to build child nodes.
nodes.append({
"id": node_id,
"name": f"{name}",
"children": build_arborist_tree(content, full_path)
})
else:
# File — pick an emoji based on extension, fall back to default.
suffix = Path(name).suffix
icon = SUFFIX_MAP.get(suffix, SUFFIX_MAP["default"])
nodes.append({
"id": node_id,
"name": f"{icon} {name}",
"key": f"key_{node_id}"
})
return nodes
def render_filetree_arborist(tree):
"""Render the interactive file tree and return the currently selected node dict.
Passes the active file's relative path as ``selection`` so the tree always
highlights whichever file is currently open in the editor, even when the
user switches tabs instead of clicking the tree.
"""
data = build_arborist_tree(tree)
# Compute the node-ID of the currently active file (posix relative path)
# so the tree highlights it regardless of how the tab was opened.
active_selection = None
active_file = st.session_state.get("active_file")
if active_file:
try:
active_selection = str(Path(active_file).relative_to(fm.base_path).as_posix())
except ValueError:
pass
selected = tree_view(
data=data,
icons={"open": "📂", "closed": "📁"},
height=400,
selection=active_selection,
select_internal_nodes=True, # allow clicking folder names, not just files
open_by_default=True
)
return selected
# ── Sidebar ───────────────────────────────────────────────────────────────────
def render_sidebar():
"""Render the full sidebar: navigation radio and workspace file explorer.
Tree click handling:
- Clicking a file → appended to open_files, set as active_file
- Clicking a folder → stored in selected_folder so the action bar appears
Navigation flags (_navigate_to_editor, _navigate_to_chat) are consumed here
at the very top — before any widget is rendered — to avoid StreamlitAPIException.
"""
# Consume navigation flags before any widget renders.
if st.session_state.pop("_navigate_to_editor", False):
st.session_state.radio_interface_options = "Code Editor"
if st.session_state.pop("_navigate_to_chat", False):
st.session_state.radio_interface_options = "Chat with AI Assistant"
st.sidebar.title("Navigation")
navigation_section = st.sidebar.container()
file_explorer_section = st.sidebar.container()
with navigation_section:
st.radio(
"Options",
["Chat with AI Assistant", "Code Editor"],
key="radio_interface_options")
st.markdown("---")
with file_explorer_section:
st.subheader("File Explorer")
tree = fm.get_file_tree()
workspace = st.container()
add_more = st.container()
with workspace:
with st.container(border=False):
if not tree:
st.info("Workspace is empty.")
else:
selected = render_filetree_arborist(tree)
if selected:
selected_path = selected.get("id")
# Only react when the user clicks a *different* node to
# avoid re-running on every Streamlit rerender.
if st.session_state.last_selected != selected_path:
st.session_state.last_selected = selected_path
abs_path = fm.base_path / selected_path
if abs_path.is_file():
# Open the file in the editor, jump to its tab,
# and switch the view to the Editor pane.
st.session_state.selected_folder = None
st.session_state.selected_folder_rel = None
file_str = str(abs_path)
if file_str not in st.session_state.open_files:
st.session_state.open_files.append(file_str)
st.session_state.active_file = file_str
st.session_state._jump_to_tab = file_str
st.session_state._navigate_to_editor = True
st.rerun()
elif abs_path.is_dir():
# Select the folder so its action bar appears below.
st.session_state.selected_folder = str(abs_path)
st.session_state.selected_folder_rel = selected_path
st.rerun()
# Folder action bar — rendered unconditionally outside the selection
# block so it persists across reruns even when no new click happens.
if st.session_state.get("selected_folder"):
folder_name = Path(st.session_state.selected_folder).name
folder_rel = st.session_state.selected_folder_rel
with st.container(border=True):
st.write(f"**Folder actions:** 📁 {folder_name}")
if st.button("Add File", key="btn_add_file_in_folder", use_container_width=True):
_add_file_dialog(folder_rel)
if st.button("Add Folder", key="btn_add_folder_in_folder", use_container_width=True):
_add_folder_dialog(folder_rel)
if st.button("Delete Folder", key="btn_delete_folder", use_container_width=True):
_delete_folder_dialog(folder_rel, folder_name)
if st.session_state.get("active_file"):
active_file_name = Path(st.session_state.active_file).name
file_rel = str(Path(st.session_state.active_file).relative_to(fm.base_path))
with st.container(border=True):
st.write(f"**File actions:** {active_file_name}")
if st.button("Rename File", key="btn_rename_file", use_container_width=True):
_rename_file_dialog(file_rel, active_file_name)
if st.button("Delete File", key="btn_delete_active_file", use_container_width=True):
_delete_file_dialog(file_rel, active_file_name)
with add_more:
# Popover for workspace-root actions (not tied to any selected folder).
with st.popover("⚙️ Explorer Options", key="popover_options", use_container_width=True):
if st.button("Add File", key="btn_add_file", use_container_width=True):
_add_file_dialog("")
if st.button("Add Folder", key="btn_add_folder", use_container_width=True):
_add_folder_dialog("")
# REVIEW: bare `return` at end of void function — no-op; can be removed.
return
if __name__ == "__main__":
render_sidebar()