""" RoadPad File Tree - Sidebar file explorer. Features: - Directory tree view - Expand/collapse - File icons - Git status """ import os from dataclasses import dataclass, field from typing import List, Dict, Set @dataclass class TreeNode: """A node in the file tree.""" name: str path: str is_dir: bool depth: int = 0 expanded: bool = False children: List["TreeNode"] = field(default_factory=list) git_status: str = "" # M, A, D, ?, etc. @property def icon(self) -> str: if self.is_dir: return "▼" if self.expanded else "▶" ext = os.path.splitext(self.name)[1].lower() icons = { ".py": "◇", ".js": "◇", ".ts": "◇", ".go": "◇", ".rs": "◇", ".md": "◆", ".txt": "◆", ".road": "◆", ".json": "○", ".yaml": "○", ".toml": "○", ".sh": "▪", ".bash": "▪", ".git": "●", } return icons.get(ext, "·") @property def display_name(self) -> str: suffix = "/" if self.is_dir else "" status = f" [{self.git_status}]" if self.git_status else "" return f"{self.name}{suffix}{status}" class FileTree: """File tree sidebar.""" def __init__(self, root_path: str | None = None): self.root_path = root_path or os.getcwd() self.root: TreeNode | None = None self.flat_list: List[TreeNode] = [] self.selected_index: int = 0 self.scroll_offset: int = 0 self.width: int = 30 self.visible: bool = False # Filtering self.show_hidden: bool = False self.show_ignored: bool = False # Ignore patterns self.ignore_patterns: Set[str] = { ".git", "__pycache__", "node_modules", ".venv", ".pytest_cache", ".mypy_cache", "dist", "build", ".DS_Store", "*.pyc", "*.pyo" } self.refresh() def refresh(self) -> None: """Refresh the tree.""" self.root = self._build_node(self.root_path, 0) if self.root: self.root.expanded = True self._flatten() def _build_node(self, path: str, depth: int) -> TreeNode | None: """Build tree node for path.""" name = os.path.basename(path) or path is_dir = os.path.isdir(path) # Check ignore if not self.show_hidden and name.startswith("."): return None if not self.show_ignored and self._should_ignore(name): return None node = TreeNode(name=name, path=path, is_dir=is_dir, depth=depth) if is_dir and depth < 10: # Limit depth try: entries = sorted(os.listdir(path)) dirs = [] files = [] for entry in entries: child_path = os.path.join(path, entry) child_node = self._build_node(child_path, depth + 1) if child_node: if child_node.is_dir: dirs.append(child_node) else: files.append(child_node) node.children = dirs + files except PermissionError: pass return node def _should_ignore(self, name: str) -> bool: """Check if name should be ignored.""" for pattern in self.ignore_patterns: if pattern.startswith("*"): if name.endswith(pattern[1:]): return True elif name == pattern: return True return False def _flatten(self) -> None: """Flatten tree to list for display.""" self.flat_list = [] if self.root: self._flatten_node(self.root) if self.selected_index >= len(self.flat_list): self.selected_index = max(0, len(self.flat_list) - 1) def _flatten_node(self, node: TreeNode) -> None: """Recursively flatten node.""" self.flat_list.append(node) if node.is_dir and node.expanded: for child in node.children: self._flatten_node(child) def toggle_expand(self) -> None: """Toggle expand/collapse of selected node.""" if not self.flat_list: return node = self.flat_list[self.selected_index] if node.is_dir: node.expanded = not node.expanded self._flatten() def expand_all(self) -> None: """Expand all directories.""" def expand(node: TreeNode): if node.is_dir: node.expanded = True for child in node.children: expand(child) if self.root: expand(self.root) self._flatten() def collapse_all(self) -> None: """Collapse all directories.""" def collapse(node: TreeNode): if node.is_dir: node.expanded = False for child in node.children: collapse(child) if self.root: collapse(self.root) self.root.expanded = True self._flatten() def select_up(self) -> None: """Move selection up.""" if self.selected_index > 0: self.selected_index -= 1 def select_down(self) -> None: """Move selection down.""" if self.selected_index < len(self.flat_list) - 1: self.selected_index += 1 def select_parent(self) -> None: """Move to parent directory.""" if not self.flat_list: return node = self.flat_list[self.selected_index] parent_path = os.path.dirname(node.path) for i, n in enumerate(self.flat_list): if n.path == parent_path: self.selected_index = i break def selected_node(self) -> TreeNode | None: """Get selected node.""" if 0 <= self.selected_index < len(self.flat_list): return self.flat_list[self.selected_index] return None def selected_path(self) -> str | None: """Get selected file path (if file).""" node = self.selected_node() if node and not node.is_dir: return node.path return None def enter_selected(self) -> str | None: """Enter directory or return file path.""" node = self.selected_node() if not node: return None if node.is_dir: node.expanded = not node.expanded self._flatten() return None return node.path def set_root(self, path: str) -> None: """Change root directory.""" if os.path.isdir(path): self.root_path = path self.selected_index = 0 self.refresh() def toggle_hidden(self) -> None: """Toggle showing hidden files.""" self.show_hidden = not self.show_hidden self.refresh() def toggle(self) -> None: """Toggle visibility.""" self.visible = not self.visible def show(self) -> None: """Show tree.""" self.visible = True def hide(self) -> None: """Hide tree.""" self.visible = False def update_git_status(self) -> None: """Update git status for files.""" # Run git status import subprocess try: result = subprocess.run( ["git", "status", "--porcelain"], cwd=self.root_path, capture_output=True, text=True, timeout=5 ) if result.returncode != 0: return status_map = {} for line in result.stdout.split("\n"): if len(line) >= 3: status = line[:2].strip() filepath = line[3:] full_path = os.path.join(self.root_path, filepath) status_map[full_path] = status # Apply to nodes def update_node(node: TreeNode): if node.path in status_map: node.git_status = status_map[node.path] for child in node.children: update_node(child) if self.root: update_node(self.root) except: pass def format_tree(self, height: int) -> List[str]: """Format tree for display.""" lines = [] # Header root_name = os.path.basename(self.root_path) or self.root_path lines.append(f" {root_name}") lines.append(" " + "─" * (self.width - 2)) # Visible range visible_start = self.scroll_offset visible_end = min(len(self.flat_list), visible_start + height - 3) # Adjust scroll if self.selected_index < visible_start: self.scroll_offset = self.selected_index visible_start = self.scroll_offset visible_end = min(len(self.flat_list), visible_start + height - 3) elif self.selected_index >= visible_end: self.scroll_offset = self.selected_index - height + 4 visible_start = self.scroll_offset visible_end = min(len(self.flat_list), visible_start + height - 3) for i in range(visible_start, visible_end): node = self.flat_list[i] prefix = ">" if i == self.selected_index else " " indent = " " * node.depth icon = node.icon name = node.display_name # Truncate to width line = f"{prefix}{indent}{icon} {name}" if len(line) > self.width: line = line[:self.width - 1] + "…" lines.append(line) return lines # Global tree _tree: FileTree | None = None def get_file_tree() -> FileTree: """Get global file tree.""" global _tree if _tree is None: _tree = FileTree() return _tree