Asish Karthikeya Gogineni commited on
Commit
77bf0e5
Β·
1 Parent(s): 959c192

feat: Implement 3-panel file explorer UI

Browse files

- New components: file_explorer.py (file tree), code_viewer.py (syntax highlighting)
- 3-column layout: File Tree (15%) | Code Viewer (45%) | Chat/Tools (40%)
- Click files in tree to view in center panel
- Folder expand/collapse support
- Updated indexing to return file list for tree display

app.py CHANGED
@@ -424,7 +424,7 @@ with st.sidebar:
424
  # Use the new progress-tracked indexer
425
  from code_chatbot.indexing_progress import index_with_progress
426
 
427
- chat_engine, success = index_with_progress(
428
  source_input=source_input,
429
  source_type=source_type,
430
  provider=provider,
@@ -439,6 +439,8 @@ with st.sidebar:
439
  if success:
440
  st.session_state.chat_engine = chat_engine
441
  st.session_state.processed_files = True
 
 
442
  time.sleep(0.5) # Brief pause to show success
443
  st.rerun()
444
 
@@ -486,11 +488,26 @@ with st.sidebar:
486
  st.session_state.chat_engine = None
487
  st.rerun()
488
 
489
- # Main Chat Interface
 
 
 
490
  st.title("πŸ•·οΈ Code Crawler")
491
 
492
- # Multi-Mode Interface
493
- if st.session_state.processed_files:
 
 
 
 
 
 
 
 
 
 
 
 
494
  from components.multi_mode import (
495
  render_mode_selector,
496
  render_chat_mode,
@@ -499,125 +516,124 @@ if st.session_state.processed_files:
499
  render_generate_mode
500
  )
501
 
502
- # Mode selector at the top
503
- selected_mode = render_mode_selector()
 
 
 
504
 
505
- st.divider()
 
 
 
 
 
 
 
 
 
 
 
 
506
 
507
- # Render appropriate interface based on mode
508
- if selected_mode == "search":
509
- render_search_mode()
510
- elif selected_mode == "refactor":
511
- render_refactor_mode()
512
- elif selected_mode == "generate":
513
- render_generate_mode(st.session_state.chat_engine)
514
- else: # chat mode
515
- # Show chat mode UI
516
- render_chat_mode(st.session_state.chat_engine)
517
 
518
- # Continue with standard chat interface below
519
- st.caption(f"Ask questions about your uploaded project. (Using {provider}, Enhanced with AST)")
520
- else:
521
- st.caption(f"Configure and index your codebase to get started. (Using {provider}, Enhanced with AST)")
522
-
523
- if not st.session_state.processed_files:
524
- st.info("πŸ‘ˆ Please upload and index a ZIP file to start.")
525
- else:
526
- # Display History
527
- for msg in st.session_state.messages:
528
- with st.chat_message(msg["role"]):
529
- # Render Sources if available
530
- if "sources" in msg and msg["sources"]:
531
- unique_sources = {}
532
- for s in msg["sources"]:
533
- # Handle both dictionary and string formats for sources
534
- if isinstance(s, dict):
535
- fp = s.get('file_path', 'Unknown')
536
- else:
537
- fp = str(s)
538
-
539
- if fp not in unique_sources:
540
- unique_sources[fp] = s
541
-
542
- chips_html = '<div class="source-container" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px;">'
543
- for fp in unique_sources:
544
- basename = os.path.basename(fp) if "/" in fp else fp
545
- chips_html += f"""
546
- <div class="source-chip" style="background: rgba(30, 41, 59, 0.4); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 6px; padding: 4px 10px; font-size: 0.85em; color: #cbd5e1; display: flex; align-items: center; gap: 6px;">
547
- <span class="source-icon">πŸ“„</span> {basename}
548
- </div>
549
- """
550
- chips_html += '</div>'
551
- st.markdown(chips_html, unsafe_allow_html=True)
552
 
553
- # Use unsafe_allow_html in case any formatted content exists
554
- st.markdown(msg["content"], unsafe_allow_html=True)
555
-
556
- # Handle pending prompt from suggestion buttons
557
- prompt = None
558
- if st.session_state.get("pending_prompt"):
559
- prompt = st.session_state.pending_prompt
560
- st.session_state.pending_prompt = None # Clear it
561
-
562
- # Input - also check for pending prompt
563
- if not prompt:
564
- prompt = st.chat_input("How does the authentication work?")
565
-
566
- if prompt:
567
- st.session_state.messages.append({"role": "user", "content": prompt})
568
- with st.chat_message("user"):
569
- st.markdown(prompt)
570
-
571
- with st.chat_message("assistant"):
572
- if st.session_state.chat_engine:
573
- with st.spinner("Analyzing (Graph+Vector)..."):
574
- answer_payload = st.session_state.chat_engine.chat(prompt)
575
-
576
- # Robust unpacking
577
- if isinstance(answer_payload, tuple):
578
- answer, sources = answer_payload
579
- else:
580
- answer = answer_payload
581
- sources = []
582
-
583
- if sources:
584
- # Deduplicate sources based on file_path
585
  unique_sources = {}
586
- for s in sources:
587
- fp = s.get('file_path', 'Unknown')
 
 
 
588
  if fp not in unique_sources:
589
  unique_sources[fp] = s
590
-
591
- # Render Source Chips
592
- chips_html = '<div class="source-container">'
593
  for fp in unique_sources:
594
- # Truncate path for display
595
- basename = os.path.basename(fp)
596
  chips_html += f"""
597
- <div class="source-chip">
598
- <span class="source-icon">πŸ“„</span> {basename}
599
  </div>
600
  """
601
  chips_html += '</div>'
602
  st.markdown(chips_html, unsafe_allow_html=True)
603
-
604
- st.markdown(answer)
605
 
606
- # Append full formatted content to history so it persists
607
- # We'll save the raw answer for history but re-render chips on load?
608
- # Actually, for simplicity, let's just save the answer text. Streamlit re-runs the whole script,
609
- # but we are storing manual history. Issues with reconstructing chips from history?
610
- # The current history loop just does st.markdown(msg["content"]).
611
- # We should probably append the chips HTML to the content if we want it to persist.
612
-
613
- # Store structured message in history
614
- # We store the raw answer and the sources list separately
615
- # This avoids baking HTML into the content string which causes rendering issues
616
- msg_data = {
617
- "role": "assistant",
618
- "content": answer,
619
- "sources": sources if sources else []
620
- }
621
- st.session_state.messages.append(msg_data)
622
- else:
623
- st.error("Chat engine not initialized. Please re-index.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  # Use the new progress-tracked indexer
425
  from code_chatbot.indexing_progress import index_with_progress
426
 
427
+ chat_engine, success, repo_files, workspace_root = index_with_progress(
428
  source_input=source_input,
429
  source_type=source_type,
430
  provider=provider,
 
439
  if success:
440
  st.session_state.chat_engine = chat_engine
441
  st.session_state.processed_files = True
442
+ st.session_state.indexed_files = repo_files # For file tree
443
+ st.session_state.workspace_root = workspace_root # For relative paths
444
  time.sleep(0.5) # Brief pause to show success
445
  st.rerun()
446
 
 
488
  st.session_state.chat_engine = None
489
  st.rerun()
490
 
491
+ # ============================================================================
492
+ # MAIN 3-PANEL LAYOUT
493
+ # ============================================================================
494
+
495
  st.title("πŸ•·οΈ Code Crawler")
496
 
497
+ if not st.session_state.processed_files:
498
+ # Show onboarding message when no files are processed
499
+ st.info("πŸ‘ˆ Please upload and index a codebase (ZIP, GitHub, or Web URL) to start.")
500
+ st.markdown("""
501
+ ### πŸš€ Getting Started
502
+ 1. **Configure** your API key in the sidebar
503
+ 2. **Upload** a ZIP file, enter a GitHub URL, or Web documentation URL
504
+ 3. **Index** your codebase with one click
505
+ 4. **Explore** your code with the file explorer and chat interface
506
+ """)
507
+ else:
508
+ # 3-Panel Layout: File Tree | Code Viewer | Chat/Tools
509
+ from components.file_explorer import render_file_tree, get_indexed_files_from_session
510
+ from components.code_viewer import render_code_viewer_simple
511
  from components.multi_mode import (
512
  render_mode_selector,
513
  render_chat_mode,
 
516
  render_generate_mode
517
  )
518
 
519
+ # Initialize session state for file explorer
520
+ if "selected_file" not in st.session_state:
521
+ st.session_state.selected_file = None
522
+ if "indexed_files" not in st.session_state:
523
+ st.session_state.indexed_files = []
524
 
525
+ # Create 3 columns: File Tree (15%) | Code Viewer (45%) | Chat/Tools (40%)
526
+ col_tree, col_viewer, col_chat = st.columns([0.15, 0.45, 0.40])
527
+
528
+ # --- LEFT PANEL: File Tree ---
529
+ with col_tree:
530
+ render_file_tree(
531
+ st.session_state.get("indexed_files", []),
532
+ st.session_state.get("workspace_root", "")
533
+ )
534
+
535
+ # --- CENTER PANEL: Code Viewer ---
536
+ with col_viewer:
537
+ render_code_viewer_simple(st.session_state.get("selected_file"))
538
 
539
+ # --- RIGHT PANEL: Chat/Tools ---
540
+ with col_chat:
541
+ # Mode selector at the top
542
+ selected_mode = render_mode_selector()
 
 
 
 
 
 
543
 
544
+ st.divider()
545
+
546
+ # Render appropriate interface based on mode
547
+ if selected_mode == "search":
548
+ render_search_mode()
549
+ elif selected_mode == "refactor":
550
+ render_refactor_mode()
551
+ elif selected_mode == "generate":
552
+ render_generate_mode(st.session_state.chat_engine)
553
+ else: # chat mode
554
+ # Show chat mode UI
555
+ render_chat_mode(st.session_state.chat_engine)
556
+ st.caption(f"Using {provider}, Enhanced with AST")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
558
+ # Display History
559
+ for msg in st.session_state.messages:
560
+ with st.chat_message(msg["role"]):
561
+ # Render Sources if available
562
+ if "sources" in msg and msg["sources"]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  unique_sources = {}
564
+ for s in msg["sources"]:
565
+ if isinstance(s, dict):
566
+ fp = s.get('file_path', 'Unknown')
567
+ else:
568
+ fp = str(s)
569
  if fp not in unique_sources:
570
  unique_sources[fp] = s
571
+
572
+ chips_html = '<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px;">'
 
573
  for fp in unique_sources:
574
+ basename = os.path.basename(fp) if "/" in fp else fp
 
575
  chips_html += f"""
576
+ <div style="background: rgba(30, 41, 59, 0.4); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 6px; padding: 4px 10px; font-size: 0.85em; color: #cbd5e1;">
577
+ πŸ“„ {basename}
578
  </div>
579
  """
580
  chips_html += '</div>'
581
  st.markdown(chips_html, unsafe_allow_html=True)
 
 
582
 
583
+ st.markdown(msg["content"], unsafe_allow_html=True)
584
+
585
+ # Handle pending prompt from suggestion buttons
586
+ prompt = None
587
+ if st.session_state.get("pending_prompt"):
588
+ prompt = st.session_state.pending_prompt
589
+ st.session_state.pending_prompt = None
590
+
591
+ # Input
592
+ if not prompt:
593
+ prompt = st.chat_input("Ask about your code...")
594
+
595
+ if prompt:
596
+ st.session_state.messages.append({"role": "user", "content": prompt})
597
+ with st.chat_message("user"):
598
+ st.markdown(prompt)
599
+
600
+ with st.chat_message("assistant"):
601
+ if st.session_state.chat_engine:
602
+ with st.spinner("Analyzing..."):
603
+ answer_payload = st.session_state.chat_engine.chat(prompt)
604
+
605
+ if isinstance(answer_payload, tuple):
606
+ answer, sources = answer_payload
607
+ else:
608
+ answer = answer_payload
609
+ sources = []
610
+
611
+ if sources:
612
+ unique_sources = {}
613
+ for s in sources:
614
+ fp = s.get('file_path', 'Unknown')
615
+ if fp not in unique_sources:
616
+ unique_sources[fp] = s
617
+
618
+ chips_html = '<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px;">'
619
+ for fp in unique_sources:
620
+ basename = os.path.basename(fp)
621
+ chips_html += f"""
622
+ <div style="background: rgba(30, 41, 59, 0.4); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 6px; padding: 4px 10px; font-size: 0.85em; color: #cbd5e1;">
623
+ πŸ“„ {basename}
624
+ </div>
625
+ """
626
+ chips_html += '</div>'
627
+ st.markdown(chips_html, unsafe_allow_html=True)
628
+
629
+ st.markdown(answer)
630
+
631
+ msg_data = {
632
+ "role": "assistant",
633
+ "content": answer,
634
+ "sources": sources if sources else []
635
+ }
636
+ st.session_state.messages.append(msg_data)
637
+ else:
638
+ st.error("Chat engine not initialized. Please re-index.")
639
+
code_chatbot/indexing_progress.py CHANGED
@@ -250,11 +250,13 @@ def index_with_progress(
250
  progress_bar.empty()
251
  status_text.empty()
252
 
253
- return chat_engine, True
 
254
 
255
  except Exception as e:
256
  st.error(f"❌ Error during indexing: {e}")
257
  logger.error(f"Indexing failed: {e}", exc_info=True)
258
  progress_bar.empty()
259
  status_text.empty()
260
- return None, False
 
 
250
  progress_bar.empty()
251
  status_text.empty()
252
 
253
+ # Return chat engine and file info for file tree
254
+ return chat_engine, True, repo_files, local_path
255
 
256
  except Exception as e:
257
  st.error(f"❌ Error during indexing: {e}")
258
  logger.error(f"Indexing failed: {e}", exc_info=True)
259
  progress_bar.empty()
260
  status_text.empty()
261
+ return None, False, [], ""
262
+
components/code_viewer.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Code Viewer Component - Displays file content with syntax highlighting.
3
+ """
4
+ import streamlit as st
5
+ from pathlib import Path
6
+ from pygments import highlight
7
+ from pygments.lexers import get_lexer_for_filename, TextLexer
8
+ from pygments.formatters import HtmlFormatter
9
+ from typing import Optional
10
+
11
+
12
+ def get_language_from_extension(filename: str) -> str:
13
+ """Get language name from file extension for display."""
14
+ ext = Path(filename).suffix.lower()
15
+
16
+ languages = {
17
+ ".py": "Python",
18
+ ".js": "JavaScript",
19
+ ".ts": "TypeScript",
20
+ ".jsx": "React JSX",
21
+ ".tsx": "React TSX",
22
+ ".html": "HTML",
23
+ ".css": "CSS",
24
+ ".json": "JSON",
25
+ ".md": "Markdown",
26
+ ".yaml": "YAML",
27
+ ".yml": "YAML",
28
+ ".toml": "TOML",
29
+ ".sql": "SQL",
30
+ ".sh": "Shell",
31
+ ".bash": "Bash",
32
+ ".txt": "Plain Text",
33
+ }
34
+
35
+ return languages.get(ext, "Code")
36
+
37
+
38
+ def read_file_content(file_path: str) -> Optional[str]:
39
+ """Read and return file content."""
40
+ try:
41
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
42
+ return f.read()
43
+ except Exception as e:
44
+ return f"Error reading file: {e}"
45
+
46
+
47
+ def render_code_with_syntax_highlighting(content: str, filename: str):
48
+ """Render code with Pygments syntax highlighting."""
49
+ try:
50
+ lexer = get_lexer_for_filename(filename)
51
+ except:
52
+ lexer = TextLexer()
53
+
54
+ # Custom CSS for dark theme
55
+ formatter = HtmlFormatter(
56
+ style='monokai',
57
+ linenos=True,
58
+ lineanchors='line',
59
+ cssclass='source',
60
+ wrapcode=True
61
+ )
62
+
63
+ highlighted = highlight(content, lexer, formatter)
64
+
65
+ # Custom CSS
66
+ css = """
67
+ <style>
68
+ .source {
69
+ background-color: #1E1E1E !important;
70
+ padding: 10px;
71
+ border-radius: 8px;
72
+ overflow-x: auto;
73
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
74
+ font-size: 13px;
75
+ line-height: 1.5;
76
+ }
77
+ .source pre {
78
+ margin: 0;
79
+ background-color: transparent !important;
80
+ }
81
+ .source .linenos {
82
+ color: #6e7681;
83
+ padding-right: 15px;
84
+ border-right: 1px solid #3d3d3d;
85
+ margin-right: 15px;
86
+ user-select: none;
87
+ }
88
+ .source .code {
89
+ color: #e6e6e6;
90
+ }
91
+ </style>
92
+ """
93
+
94
+ st.markdown(css + highlighted, unsafe_allow_html=True)
95
+
96
+
97
+ def render_code_viewer(file_path: Optional[str] = None):
98
+ """
99
+ Render the code viewer panel.
100
+
101
+ Args:
102
+ file_path: Path to the file to display
103
+ """
104
+ if not file_path:
105
+ # Show placeholder when no file is selected
106
+ st.markdown("### πŸ“ Code Viewer")
107
+ st.info("πŸ‘ˆ Select a file from the tree to view its contents")
108
+ return
109
+
110
+ # File header
111
+ filename = Path(file_path).name
112
+ language = get_language_from_extension(filename)
113
+
114
+ st.markdown(f"### πŸ“ {filename}")
115
+ st.caption(f"πŸ“‚ {file_path} β€’ {language}")
116
+
117
+ # Read and display content
118
+ content = read_file_content(file_path)
119
+
120
+ if content:
121
+ # Add line count
122
+ line_count = content.count('\n') + 1
123
+ st.caption(f"{line_count} lines")
124
+
125
+ # Render with syntax highlighting
126
+ render_code_with_syntax_highlighting(content, filename)
127
+ else:
128
+ st.error("Could not read file contents")
129
+
130
+
131
+ def render_code_viewer_simple(file_path: Optional[str] = None):
132
+ """
133
+ Simpler code viewer using Streamlit's built-in code component.
134
+ More reliable than custom HTML rendering.
135
+ """
136
+ if not file_path:
137
+ st.markdown("### πŸ“ Code Viewer")
138
+ st.info("πŸ‘ˆ Select a file from the tree to view its contents")
139
+ return
140
+
141
+ filename = Path(file_path).name
142
+ language = get_language_from_extension(filename)
143
+ ext = Path(filename).suffix.lower().lstrip('.')
144
+
145
+ st.markdown(f"### πŸ“ {filename}")
146
+ st.caption(f"πŸ“‚ `{file_path}` β€’ {language}")
147
+
148
+ content = read_file_content(file_path)
149
+
150
+ if content:
151
+ line_count = content.count('\n') + 1
152
+ st.caption(f"{line_count} lines")
153
+
154
+ # Use Streamlit's native code component
155
+ # Map extensions to language names for st.code
156
+ lang_map = {
157
+ "py": "python",
158
+ "js": "javascript",
159
+ "ts": "typescript",
160
+ "jsx": "javascript",
161
+ "tsx": "typescript",
162
+ "json": "json",
163
+ "md": "markdown",
164
+ "yaml": "yaml",
165
+ "yml": "yaml",
166
+ "sh": "bash",
167
+ "bash": "bash",
168
+ "sql": "sql",
169
+ "html": "html",
170
+ "css": "css",
171
+ }
172
+
173
+ lang = lang_map.get(ext, "")
174
+ st.code(content, language=lang, line_numbers=True)
175
+ else:
176
+ st.error("Could not read file contents")
components/file_explorer.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ File Explorer Component - Renders file tree sidebar for indexed files.
3
+ """
4
+ import streamlit as st
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+
9
+
10
+ def build_file_tree(file_paths: List[str], base_path: str = "") -> Dict:
11
+ """
12
+ Build a nested dictionary representing the file tree from a list of file paths.
13
+
14
+ Args:
15
+ file_paths: List of file paths (relative or absolute)
16
+ base_path: Base path to make paths relative to
17
+
18
+ Returns:
19
+ Nested dictionary representing folder structure
20
+ """
21
+ tree = {}
22
+
23
+ for file_path in file_paths:
24
+ # Make path relative if base_path is provided
25
+ if base_path:
26
+ try:
27
+ rel_path = os.path.relpath(file_path, base_path)
28
+ except ValueError:
29
+ rel_path = file_path
30
+ else:
31
+ rel_path = file_path
32
+
33
+ # Split path into parts
34
+ parts = Path(rel_path).parts
35
+
36
+ # Navigate/create tree structure
37
+ current = tree
38
+ for i, part in enumerate(parts):
39
+ if i == len(parts) - 1:
40
+ # It's a file
41
+ current[part] = {"_type": "file", "_path": file_path}
42
+ else:
43
+ # It's a directory
44
+ if part not in current:
45
+ current[part] = {"_type": "dir"}
46
+ current = current[part]
47
+
48
+ return tree
49
+
50
+
51
+ def get_file_icon(filename: str) -> str:
52
+ """Get an appropriate icon for a file based on its extension."""
53
+ ext = Path(filename).suffix.lower()
54
+
55
+ icons = {
56
+ ".py": "🐍",
57
+ ".js": "πŸ“œ",
58
+ ".ts": "πŸ“˜",
59
+ ".jsx": "βš›οΈ",
60
+ ".tsx": "βš›οΈ",
61
+ ".html": "🌐",
62
+ ".css": "🎨",
63
+ ".json": "πŸ“‹",
64
+ ".md": "πŸ“",
65
+ ".txt": "πŸ“„",
66
+ ".yaml": "βš™οΈ",
67
+ ".yml": "βš™οΈ",
68
+ ".toml": "βš™οΈ",
69
+ ".sql": "πŸ—ƒοΈ",
70
+ ".sh": "πŸ–₯️",
71
+ ".bash": "πŸ–₯️",
72
+ ".env": "πŸ”",
73
+ ".gitignore": "🚫",
74
+ }
75
+
76
+ return icons.get(ext, "πŸ“„")
77
+
78
+
79
+ def render_tree_node(name: str, node: Dict, path_prefix: str = "", depth: int = 0):
80
+ """Recursively render a tree node (file or directory)."""
81
+
82
+ if node.get("_type") == "file":
83
+ # Render file
84
+ icon = get_file_icon(name)
85
+ file_path = node.get("_path", "")
86
+
87
+ # Create button for file selection
88
+ indent = "&nbsp;" * (depth * 4)
89
+ if st.button(f"{icon} {name}", key=f"file_{file_path}", use_container_width=True):
90
+ st.session_state.selected_file = file_path
91
+ st.rerun()
92
+ else:
93
+ # Render directory
94
+ dir_key = f"dir_{path_prefix}/{name}"
95
+
96
+ # Check if directory is expanded
97
+ if "expanded_dirs" not in st.session_state:
98
+ st.session_state.expanded_dirs = set()
99
+
100
+ is_expanded = dir_key in st.session_state.expanded_dirs
101
+
102
+ # Toggle button for directory
103
+ icon = "πŸ“‚" if is_expanded else "πŸ“"
104
+ if st.button(f"{icon} {name}", key=dir_key, use_container_width=True):
105
+ if is_expanded:
106
+ st.session_state.expanded_dirs.discard(dir_key)
107
+ else:
108
+ st.session_state.expanded_dirs.add(dir_key)
109
+ st.rerun()
110
+
111
+ # Render children if expanded
112
+ if is_expanded:
113
+ # Get children (excluding metadata keys)
114
+ children = {k: v for k, v in node.items() if not k.startswith("_")}
115
+
116
+ # Sort: directories first, then files
117
+ sorted_children = sorted(
118
+ children.items(),
119
+ key=lambda x: (x[1].get("_type") == "file", x[0].lower())
120
+ )
121
+
122
+ for child_name, child_node in sorted_children:
123
+ with st.container():
124
+ render_tree_node(
125
+ child_name,
126
+ child_node,
127
+ f"{path_prefix}/{name}",
128
+ depth + 1
129
+ )
130
+
131
+
132
+ def render_file_tree(indexed_files: List[str], base_path: str = ""):
133
+ """
134
+ Render the file tree sidebar.
135
+
136
+ Args:
137
+ indexed_files: List of indexed file paths
138
+ base_path: Base path to make paths relative to
139
+ """
140
+ st.markdown("### πŸ“ Files")
141
+
142
+ if not indexed_files:
143
+ st.caption("No files indexed yet")
144
+ return
145
+
146
+ st.caption(f"{len(indexed_files)} files indexed")
147
+
148
+ # Build tree structure
149
+ tree = build_file_tree(indexed_files, base_path)
150
+
151
+ # Sort root level: directories first, then files
152
+ sorted_root = sorted(
153
+ tree.items(),
154
+ key=lambda x: (x[1].get("_type") == "file", x[0].lower())
155
+ )
156
+
157
+ # Render tree
158
+ for name, node in sorted_root:
159
+ render_tree_node(name, node)
160
+
161
+
162
+ def get_indexed_files_from_session() -> List[str]:
163
+ """Get the list of indexed files from session state."""
164
+ return st.session_state.get("indexed_files", [])