| 1 | //! File tree data structure |
| 2 | |
| 3 | #![allow(dead_code)] |
| 4 | |
| 5 | use std::path::{Path, PathBuf}; |
| 6 | use std::fs; |
| 7 | use std::process::Command; |
| 8 | use std::collections::HashMap; |
| 9 | |
| 10 | /// Git status for a file |
| 11 | #[derive(Debug, Clone, Default)] |
| 12 | pub struct GitStatus { |
| 13 | /// File is staged (added to index) |
| 14 | pub staged: bool, |
| 15 | /// File has unstaged changes |
| 16 | pub unstaged: bool, |
| 17 | /// File is untracked |
| 18 | pub untracked: bool, |
| 19 | /// File has incoming changes (after fetch) |
| 20 | pub incoming: bool, |
| 21 | /// File is gitignored |
| 22 | pub gitignored: bool, |
| 23 | } |
| 24 | |
| 25 | /// A node in the file tree |
| 26 | #[derive(Debug, Clone)] |
| 27 | pub struct TreeNode { |
| 28 | /// File/directory name |
| 29 | pub name: String, |
| 30 | /// Full path |
| 31 | pub path: PathBuf, |
| 32 | /// Is this a directory? |
| 33 | pub is_dir: bool, |
| 34 | /// Is directory expanded? |
| 35 | pub expanded: bool, |
| 36 | /// Children (only for directories) |
| 37 | pub children: Vec<TreeNode>, |
| 38 | /// Depth in tree (for indentation) |
| 39 | pub depth: usize, |
| 40 | /// Git status for this file |
| 41 | pub git_status: GitStatus, |
| 42 | } |
| 43 | |
| 44 | impl TreeNode { |
| 45 | /// Create a new tree node |
| 46 | pub fn new(path: PathBuf, depth: usize) -> Self { |
| 47 | let name = path |
| 48 | .file_name() |
| 49 | .and_then(|n| n.to_str()) |
| 50 | .unwrap_or("") |
| 51 | .to_string(); |
| 52 | let is_dir = path.is_dir(); |
| 53 | |
| 54 | Self { |
| 55 | name, |
| 56 | path, |
| 57 | is_dir, |
| 58 | expanded: depth == 0, // Root is expanded by default |
| 59 | children: Vec::new(), |
| 60 | depth, |
| 61 | git_status: GitStatus::default(), |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /// Check if this is a hidden file (starts with .) |
| 66 | pub fn is_hidden(&self) -> bool { |
| 67 | self.name.starts_with('.') |
| 68 | } |
| 69 | |
| 70 | /// Load children for a directory |
| 71 | pub fn load_children(&mut self, show_hidden: bool) { |
| 72 | if !self.is_dir { |
| 73 | return; |
| 74 | } |
| 75 | |
| 76 | self.children.clear(); |
| 77 | |
| 78 | if let Ok(entries) = fs::read_dir(&self.path) { |
| 79 | let mut children: Vec<TreeNode> = entries |
| 80 | .filter_map(|e| e.ok()) |
| 81 | .filter(|e| { |
| 82 | let name = e.file_name(); |
| 83 | let name_str = name.to_string_lossy(); |
| 84 | show_hidden || !name_str.starts_with('.') |
| 85 | }) |
| 86 | .map(|e| TreeNode::new(e.path(), self.depth + 1)) |
| 87 | .collect(); |
| 88 | |
| 89 | // Sort: directories first, then alphabetically |
| 90 | children.sort_by(|a, b| { |
| 91 | match (a.is_dir, b.is_dir) { |
| 92 | (true, false) => std::cmp::Ordering::Less, |
| 93 | (false, true) => std::cmp::Ordering::Greater, |
| 94 | _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), |
| 95 | } |
| 96 | }); |
| 97 | |
| 98 | self.children = children; |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /// Toggle expanded state |
| 103 | pub fn toggle_expand(&mut self) { |
| 104 | if self.is_dir { |
| 105 | self.expanded = !self.expanded; |
| 106 | if self.expanded && self.children.is_empty() { |
| 107 | self.load_children(false); |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | /// File tree for the workspace |
| 114 | #[derive(Debug)] |
| 115 | pub struct FileTree { |
| 116 | /// Root node |
| 117 | pub root: TreeNode, |
| 118 | /// Show hidden files |
| 119 | pub show_hidden: bool, |
| 120 | /// Flattened visible items (for rendering and navigation) |
| 121 | visible_items: Vec<VisibleItem>, |
| 122 | } |
| 123 | |
| 124 | /// A visible item in the flattened tree |
| 125 | #[derive(Debug, Clone)] |
| 126 | pub struct VisibleItem { |
| 127 | /// Path to the item |
| 128 | pub path: PathBuf, |
| 129 | /// Display name |
| 130 | pub name: String, |
| 131 | /// Is directory |
| 132 | pub is_dir: bool, |
| 133 | /// Is expanded (for directories) |
| 134 | pub expanded: bool, |
| 135 | /// Indentation depth |
| 136 | pub depth: usize, |
| 137 | /// Git status |
| 138 | pub git_status: GitStatus, |
| 139 | } |
| 140 | |
| 141 | impl FileTree { |
| 142 | /// Create a new file tree rooted at the given path |
| 143 | pub fn new(root_path: &Path) -> Self { |
| 144 | let mut root = TreeNode::new(root_path.to_path_buf(), 0); |
| 145 | root.load_children(false); |
| 146 | |
| 147 | let mut tree = Self { |
| 148 | root, |
| 149 | show_hidden: false, |
| 150 | visible_items: Vec::new(), |
| 151 | }; |
| 152 | tree.rebuild_visible(); |
| 153 | tree |
| 154 | } |
| 155 | |
| 156 | /// Rebuild the flattened visible items list |
| 157 | pub fn rebuild_visible(&mut self) { |
| 158 | self.visible_items.clear(); |
| 159 | self.collect_visible(&self.root.clone()); |
| 160 | } |
| 161 | |
| 162 | fn collect_visible(&mut self, node: &TreeNode) { |
| 163 | // Don't include root in visible items, but process its children |
| 164 | if node.depth > 0 { |
| 165 | self.visible_items.push(VisibleItem { |
| 166 | path: node.path.clone(), |
| 167 | name: node.name.clone(), |
| 168 | is_dir: node.is_dir, |
| 169 | expanded: node.expanded, |
| 170 | depth: node.depth, |
| 171 | git_status: node.git_status.clone(), |
| 172 | }); |
| 173 | } |
| 174 | |
| 175 | if node.is_dir && (node.expanded || node.depth == 0) { |
| 176 | for child in &node.children { |
| 177 | self.collect_visible(child); |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | /// Get visible items |
| 183 | pub fn visible_items(&self) -> &[VisibleItem] { |
| 184 | &self.visible_items |
| 185 | } |
| 186 | |
| 187 | /// Number of visible items |
| 188 | pub fn len(&self) -> usize { |
| 189 | self.visible_items.len() |
| 190 | } |
| 191 | |
| 192 | /// Toggle expand/collapse at index |
| 193 | pub fn toggle_at(&mut self, index: usize) { |
| 194 | if index >= self.visible_items.len() { |
| 195 | return; |
| 196 | } |
| 197 | |
| 198 | let path = self.visible_items[index].path.clone(); |
| 199 | self.toggle_path(&path); |
| 200 | self.rebuild_visible(); |
| 201 | } |
| 202 | |
| 203 | fn toggle_path(&mut self, path: &Path) { |
| 204 | Self::toggle_path_recursive(&mut self.root, path); |
| 205 | } |
| 206 | |
| 207 | fn toggle_path_recursive(node: &mut TreeNode, path: &Path) -> bool { |
| 208 | if node.path == path { |
| 209 | node.toggle_expand(); |
| 210 | return true; |
| 211 | } |
| 212 | |
| 213 | for child in &mut node.children { |
| 214 | if Self::toggle_path_recursive(child, path) { |
| 215 | return true; |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | false |
| 220 | } |
| 221 | |
| 222 | /// Get path at index |
| 223 | pub fn path_at(&self, index: usize) -> Option<&Path> { |
| 224 | self.visible_items.get(index).map(|i| i.path.as_path()) |
| 225 | } |
| 226 | |
| 227 | /// Check if item at index is a directory |
| 228 | pub fn is_dir_at(&self, index: usize) -> bool { |
| 229 | self.visible_items.get(index).map(|i| i.is_dir).unwrap_or(false) |
| 230 | } |
| 231 | |
| 232 | /// Toggle showing hidden files |
| 233 | pub fn toggle_hidden(&mut self) { |
| 234 | self.show_hidden = !self.show_hidden; |
| 235 | self.reload(); |
| 236 | } |
| 237 | |
| 238 | /// Reload tree from disk |
| 239 | pub fn reload(&mut self) { |
| 240 | Self::reload_node(&mut self.root, self.show_hidden); |
| 241 | self.rebuild_visible(); |
| 242 | } |
| 243 | |
| 244 | fn reload_node(node: &mut TreeNode, show_hidden: bool) { |
| 245 | if node.is_dir && node.expanded { |
| 246 | node.load_children(show_hidden); |
| 247 | for child in &mut node.children { |
| 248 | Self::reload_node(child, show_hidden); |
| 249 | } |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | /// Update git status for all files in the tree |
| 254 | pub fn update_git_status(&mut self) { |
| 255 | let root_path = self.root.path.clone(); |
| 256 | let status_map = get_git_status(&root_path); |
| 257 | Self::apply_git_status(&mut self.root, &status_map, &root_path); |
| 258 | // Smart collapse: only expand directories with dirty files |
| 259 | Self::smart_collapse_node(&mut self.root, true); |
| 260 | self.rebuild_visible(); |
| 261 | } |
| 262 | |
| 263 | fn apply_git_status(node: &mut TreeNode, status_map: &HashMap<PathBuf, GitStatus>, root: &Path) { |
| 264 | // Get relative path from root |
| 265 | if let Ok(rel_path) = node.path.strip_prefix(root) { |
| 266 | if let Some(status) = status_map.get(rel_path) { |
| 267 | node.git_status = status.clone(); |
| 268 | } else { |
| 269 | node.git_status = GitStatus::default(); |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | // Recurse into children |
| 274 | for child in &mut node.children { |
| 275 | Self::apply_git_status(child, status_map, root); |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | /// Check if tree has any dirty files (staged, unstaged, or untracked) |
| 280 | pub fn has_dirty_files(&self) -> bool { |
| 281 | Self::node_has_dirty(&self.root) |
| 282 | } |
| 283 | |
| 284 | fn node_has_dirty(node: &TreeNode) -> bool { |
| 285 | if node.git_status.staged || node.git_status.unstaged || node.git_status.untracked { |
| 286 | return true; |
| 287 | } |
| 288 | for child in &node.children { |
| 289 | if Self::node_has_dirty(child) { |
| 290 | return true; |
| 291 | } |
| 292 | } |
| 293 | false |
| 294 | } |
| 295 | |
| 296 | /// Smart collapse: Only expand directories that contain dirty files |
| 297 | /// Root is always expanded |
| 298 | pub fn smart_collapse(&mut self) { |
| 299 | Self::smart_collapse_node(&mut self.root, true); |
| 300 | self.rebuild_visible(); |
| 301 | } |
| 302 | |
| 303 | /// Returns true if node or any descendant has dirty files |
| 304 | fn smart_collapse_node(node: &mut TreeNode, is_root: bool) -> bool { |
| 305 | if !node.is_dir { |
| 306 | // Files: return whether they're dirty |
| 307 | return node.git_status.staged || node.git_status.unstaged || node.git_status.untracked; |
| 308 | } |
| 309 | |
| 310 | // Directory: check all children first |
| 311 | let mut has_dirty_descendant = false; |
| 312 | for child in &mut node.children { |
| 313 | if Self::smart_collapse_node(child, false) { |
| 314 | has_dirty_descendant = true; |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // Root stays expanded, others only expand if they have dirty descendants |
| 319 | if is_root { |
| 320 | node.expanded = true; |
| 321 | } else { |
| 322 | node.expanded = has_dirty_descendant; |
| 323 | } |
| 324 | |
| 325 | has_dirty_descendant |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | /// Parse git status --porcelain output and return a map of file paths to git status |
| 330 | fn get_git_status(root: &Path) -> HashMap<PathBuf, GitStatus> { |
| 331 | let mut status_map = HashMap::new(); |
| 332 | |
| 333 | // Run git status --porcelain |
| 334 | let output = Command::new("git") |
| 335 | .arg("-C") |
| 336 | .arg(root) |
| 337 | .arg("status") |
| 338 | .arg("--porcelain") |
| 339 | .output(); |
| 340 | |
| 341 | if let Ok(output) = output { |
| 342 | if output.status.success() { |
| 343 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 344 | for line in stdout.lines() { |
| 345 | if line.len() < 4 { |
| 346 | continue; |
| 347 | } |
| 348 | |
| 349 | // Format: XY filename |
| 350 | // X = index status, Y = worktree status |
| 351 | let index_status = line.chars().next().unwrap_or(' '); |
| 352 | let worktree_status = line.chars().nth(1).unwrap_or(' '); |
| 353 | let filename = line[3..].trim(); |
| 354 | |
| 355 | // Handle renamed files (format: "R old -> new") |
| 356 | let filename = if filename.contains(" -> ") { |
| 357 | filename.split(" -> ").last().unwrap_or(filename) |
| 358 | } else { |
| 359 | filename |
| 360 | }; |
| 361 | |
| 362 | let path = PathBuf::from(filename); |
| 363 | let mut status = GitStatus::default(); |
| 364 | |
| 365 | // Check for ignored (!! status) |
| 366 | if index_status == '!' && worktree_status == '!' { |
| 367 | status.gitignored = true; |
| 368 | } |
| 369 | // Check for untracked |
| 370 | else if index_status == '?' && worktree_status == '?' { |
| 371 | status.untracked = true; |
| 372 | } else { |
| 373 | // Staged: any non-space, non-? in index position |
| 374 | if index_status != ' ' && index_status != '?' { |
| 375 | status.staged = true; |
| 376 | } |
| 377 | // Unstaged: any non-space, non-? in worktree position |
| 378 | if worktree_status != ' ' && worktree_status != '?' { |
| 379 | status.unstaged = true; |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | status_map.insert(path, status); |
| 384 | } |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | // Also get ignored files using --ignored flag |
| 389 | let output = Command::new("git") |
| 390 | .arg("-C") |
| 391 | .arg(root) |
| 392 | .arg("status") |
| 393 | .arg("--porcelain") |
| 394 | .arg("--ignored") |
| 395 | .output(); |
| 396 | |
| 397 | if let Ok(output) = output { |
| 398 | if output.status.success() { |
| 399 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 400 | for line in stdout.lines() { |
| 401 | if line.len() < 4 { |
| 402 | continue; |
| 403 | } |
| 404 | |
| 405 | let index_status = line.chars().next().unwrap_or(' '); |
| 406 | let worktree_status = line.chars().nth(1).unwrap_or(' '); |
| 407 | |
| 408 | // !! means ignored |
| 409 | if index_status == '!' && worktree_status == '!' { |
| 410 | let filename = line[3..].trim(); |
| 411 | let path = PathBuf::from(filename); |
| 412 | |
| 413 | // Only add if not already in map with other status |
| 414 | status_map.entry(path).or_insert_with(|| { |
| 415 | let mut s = GitStatus::default(); |
| 416 | s.gitignored = true; |
| 417 | s |
| 418 | }); |
| 419 | } |
| 420 | } |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | // Get files with incoming changes (differ from upstream) |
| 425 | // Use git diff --name-only @{u}...HEAD to see files changed in upstream but not in local |
| 426 | let output = Command::new("git") |
| 427 | .arg("-C") |
| 428 | .arg(root) |
| 429 | .arg("diff") |
| 430 | .arg("--name-only") |
| 431 | .arg("HEAD...@{u}") |
| 432 | .output(); |
| 433 | |
| 434 | if let Ok(output) = output { |
| 435 | if output.status.success() { |
| 436 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 437 | for line in stdout.lines() { |
| 438 | let filename = line.trim(); |
| 439 | if filename.is_empty() { |
| 440 | continue; |
| 441 | } |
| 442 | let path = PathBuf::from(filename); |
| 443 | |
| 444 | // Mark as having incoming changes |
| 445 | status_map |
| 446 | .entry(path) |
| 447 | .and_modify(|s| s.incoming = true) |
| 448 | .or_insert_with(|| { |
| 449 | let mut s = GitStatus::default(); |
| 450 | s.incoming = true; |
| 451 | s |
| 452 | }); |
| 453 | } |
| 454 | } |
| 455 | // If command fails (no upstream), that's fine - just no incoming indicators |
| 456 | } |
| 457 | |
| 458 | status_map |
| 459 | } |
| 460 |