301 lines
12 KiB
Python
301 lines
12 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()
|
|
|
|
# ── 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=350,
|
|
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)
|
|
|
|
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("")
|
|
|
|
st.divider()
|
|
|
|
uploaded = st.file_uploader(
|
|
"Upload File",
|
|
type=["py", "js", "html", "css", "json", "yaml", "txt", "md"],
|
|
key="sidebar_file_upload",
|
|
)
|
|
if uploaded is not None:
|
|
if uploaded.size > 1_000_000:
|
|
st.error("File is too large (max 1 MB).")
|
|
else:
|
|
content = uploaded.getvalue().decode("utf-8", errors="replace")
|
|
dest = str(fm.base_path / uploaded.name)
|
|
if fm.save_file(dest, content):
|
|
st.success(f"'{uploaded.name}' uploaded successfully.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
render_sidebar()
|