Rust · 16718 bytes Raw Blame History
1 //! Fuss mode state management
2
3 #![allow(dead_code)]
4
5 use std::path::{Path, PathBuf};
6 use std::process::Command;
7 use std::time::Instant;
8 use super::tree::FileTree;
9
10 /// Timeout for filter reset (in milliseconds)
11 const FILTER_TIMEOUT_MS: u128 = 500;
12
13 /// Fuss mode state
14 #[derive(Debug)]
15 pub struct FussMode {
16 /// Is fuss mode active?
17 pub active: bool,
18 /// The file tree
19 pub tree: Option<FileTree>,
20 /// Currently selected index
21 pub selected: usize,
22 /// Viewport scroll offset
23 pub scroll: usize,
24 /// Width as percentage of screen (default 30%)
25 pub width_percent: u8,
26 /// Show hints expanded
27 pub hints_expanded: bool,
28 /// Workspace root path
29 root_path: Option<PathBuf>,
30 /// Current fuzzy filter query
31 pub filter: String,
32 /// Last time a filter character was typed
33 filter_last_input: Option<Instant>,
34 /// Whether git mode is active (after pressing Alt+G)
35 pub git_mode: bool,
36 }
37
38 impl Default for FussMode {
39 fn default() -> Self {
40 Self {
41 active: false,
42 tree: None,
43 selected: 0,
44 scroll: 0,
45 width_percent: 30,
46 hints_expanded: false,
47 root_path: None,
48 filter: String::new(),
49 filter_last_input: None,
50 git_mode: false,
51 }
52 }
53 }
54
55 impl FussMode {
56 /// Create new fuss mode state
57 pub fn new() -> Self {
58 Self::default()
59 }
60
61 /// Initialize with a root path
62 pub fn init(&mut self, root_path: &Path) {
63 self.root_path = Some(root_path.to_path_buf());
64 let mut tree = FileTree::new(root_path);
65 tree.update_git_status();
66 self.tree = Some(tree);
67 self.selected = 0;
68 self.scroll = 0;
69 }
70
71 /// Toggle fuss mode on/off
72 pub fn toggle(&mut self) {
73 self.active = !self.active;
74 if self.active && self.tree.is_none() {
75 if let Some(ref path) = self.root_path {
76 self.tree = Some(FileTree::new(path));
77 }
78 }
79 }
80
81 /// Activate fuss mode
82 pub fn activate(&mut self, root_path: &Path) {
83 if self.tree.is_none() || self.root_path.as_deref() != Some(root_path) {
84 self.init(root_path);
85 }
86 self.active = true;
87 }
88
89 /// Deactivate fuss mode
90 pub fn deactivate(&mut self) {
91 self.active = false;
92 }
93
94 /// Move selection up
95 pub fn move_up(&mut self) {
96 if self.selected > 0 {
97 self.selected -= 1;
98 }
99 }
100
101 /// Move selection down
102 pub fn move_down(&mut self) {
103 if let Some(ref tree) = self.tree {
104 if self.selected + 1 < tree.len() {
105 self.selected += 1;
106 }
107 }
108 }
109
110 /// Toggle expand/collapse of selected directory
111 pub fn toggle_expand(&mut self) {
112 if let Some(ref mut tree) = self.tree {
113 if tree.is_dir_at(self.selected) {
114 tree.toggle_at(self.selected);
115 }
116 }
117 }
118
119 /// Get the selected path (if it's a file)
120 pub fn selected_file(&self) -> Option<PathBuf> {
121 if let Some(ref tree) = self.tree {
122 if !tree.is_dir_at(self.selected) {
123 return tree.path_at(self.selected).map(|p| p.to_path_buf());
124 }
125 }
126 None
127 }
128
129 /// Get the selected path (file or directory)
130 pub fn selected_path(&self) -> Option<PathBuf> {
131 if let Some(ref tree) = self.tree {
132 return tree.path_at(self.selected).map(|p| p.to_path_buf());
133 }
134 None
135 }
136
137 /// Check if selected item is a directory
138 pub fn is_dir_selected(&self) -> bool {
139 if let Some(ref tree) = self.tree {
140 return tree.is_dir_at(self.selected);
141 }
142 false
143 }
144
145 /// Collapse the parent directory of the currently selected item
146 /// and move selection to that parent. Returns true if a parent was collapsed.
147 pub fn collapse_parent(&mut self) -> bool {
148 let tree = match &self.tree {
149 Some(t) => t,
150 None => return false,
151 };
152
153 let items = tree.visible_items();
154 if self.selected >= items.len() {
155 return false;
156 }
157
158 let current_depth = items[self.selected].depth;
159
160 // Can't collapse parent if at root level (depth 1)
161 if current_depth <= 1 {
162 return false;
163 }
164
165 // Find the parent directory by scanning backwards for a directory
166 // with depth = current_depth - 1
167 let parent_depth = current_depth - 1;
168 let mut parent_idx = None;
169
170 for i in (0..self.selected).rev() {
171 if items[i].is_dir && items[i].depth == parent_depth {
172 parent_idx = Some(i);
173 break;
174 }
175 }
176
177 if let Some(idx) = parent_idx {
178 // Move selection to parent and collapse it
179 self.selected = idx;
180 self.toggle_expand();
181 true
182 } else {
183 false
184 }
185 }
186
187 /// Toggle showing hidden files
188 pub fn toggle_hidden(&mut self) {
189 if let Some(ref mut tree) = self.tree {
190 tree.toggle_hidden();
191 // Clamp selection
192 if self.selected >= tree.len() && tree.len() > 0 {
193 self.selected = tree.len() - 1;
194 }
195 }
196 }
197
198 /// Toggle hints expanded/collapsed
199 pub fn toggle_hints(&mut self) {
200 self.hints_expanded = !self.hints_expanded;
201 }
202
203 /// Update viewport to keep selection visible
204 pub fn update_viewport(&mut self, visible_rows: usize) {
205 if self.selected < self.scroll {
206 self.scroll = self.selected;
207 } else if self.selected >= self.scroll + visible_rows {
208 self.scroll = self.selected - visible_rows + 1;
209 }
210 }
211
212 /// Get calculated width in columns
213 pub fn width(&self, screen_cols: u16) -> u16 {
214 ((screen_cols as u32 * self.width_percent as u32) / 100) as u16
215 }
216
217 /// Reload tree from disk
218 pub fn reload(&mut self) {
219 if let Some(ref mut tree) = self.tree {
220 tree.reload();
221 tree.update_git_status();
222 }
223 }
224
225 /// Refresh git status without reloading file tree
226 pub fn refresh_git_status(&mut self) {
227 if let Some(ref mut tree) = self.tree {
228 tree.update_git_status();
229 }
230 }
231
232 /// Stage the currently selected file
233 /// Returns true on success, false on failure
234 pub fn stage_selected(&mut self) -> bool {
235 let root = match &self.root_path {
236 Some(p) => p.clone(),
237 None => return false,
238 };
239
240 let path = match self.selected_path() {
241 Some(p) => p,
242 None => return false,
243 };
244
245 // Don't stage directories
246 if self.is_dir_selected() {
247 return false;
248 }
249
250 let output = Command::new("git")
251 .arg("-C")
252 .arg(&root)
253 .arg("add")
254 .arg(&path)
255 .output();
256
257 if let Ok(output) = output {
258 if output.status.success() {
259 self.refresh_git_status();
260 return true;
261 }
262 }
263 false
264 }
265
266 /// Unstage the currently selected file
267 /// Returns true on success, false on failure
268 pub fn unstage_selected(&mut self) -> bool {
269 let root = match &self.root_path {
270 Some(p) => p.clone(),
271 None => return false,
272 };
273
274 let path = match self.selected_path() {
275 Some(p) => p,
276 None => return false,
277 };
278
279 // Don't unstage directories
280 if self.is_dir_selected() {
281 return false;
282 }
283
284 let output = Command::new("git")
285 .arg("-C")
286 .arg(&root)
287 .arg("restore")
288 .arg("--staged")
289 .arg(&path)
290 .output();
291
292 if let Ok(output) = output {
293 if output.status.success() {
294 self.refresh_git_status();
295 return true;
296 }
297 }
298 false
299 }
300
301 /// Get the root path
302 pub fn root_path(&self) -> Option<&Path> {
303 self.root_path.as_deref()
304 }
305
306 /// Push to remote
307 /// Returns (success, message)
308 pub fn git_push(&mut self) -> (bool, String) {
309 let root = match &self.root_path {
310 Some(p) => p.clone(),
311 None => return (false, "No workspace".to_string()),
312 };
313
314 let output = Command::new("git")
315 .arg("-C")
316 .arg(&root)
317 .arg("push")
318 .output();
319
320 match output {
321 Ok(out) if out.status.success() => {
322 self.refresh_git_status();
323 (true, "Pushed".to_string())
324 }
325 Ok(out) => {
326 let stderr = String::from_utf8_lossy(&out.stderr);
327 (false, format!("Push failed: {}", stderr.lines().next().unwrap_or("unknown error")))
328 }
329 Err(e) => (false, format!("Failed to run git: {}", e)),
330 }
331 }
332
333 /// Pull from remote
334 /// Returns (success, message)
335 pub fn git_pull(&mut self) -> (bool, String) {
336 let root = match &self.root_path {
337 Some(p) => p.clone(),
338 None => return (false, "No workspace".to_string()),
339 };
340
341 let output = Command::new("git")
342 .arg("-C")
343 .arg(&root)
344 .arg("pull")
345 .output();
346
347 match output {
348 Ok(out) if out.status.success() => {
349 self.refresh_git_status();
350 (true, "Pulled".to_string())
351 }
352 Ok(out) => {
353 let stderr = String::from_utf8_lossy(&out.stderr);
354 (false, format!("Pull failed: {}", stderr.lines().next().unwrap_or("unknown error")))
355 }
356 Err(e) => (false, format!("Failed to run git: {}", e)),
357 }
358 }
359
360 /// Create a git tag
361 /// Returns (success, message)
362 pub fn git_tag(&mut self, tag_name: &str) -> (bool, String) {
363 let root = match &self.root_path {
364 Some(p) => p.clone(),
365 None => return (false, "No workspace".to_string()),
366 };
367
368 if tag_name.trim().is_empty() {
369 return (false, "Empty tag name".to_string());
370 }
371
372 let output = Command::new("git")
373 .arg("-C")
374 .arg(&root)
375 .arg("tag")
376 .arg(tag_name.trim())
377 .output();
378
379 match output {
380 Ok(out) if out.status.success() => {
381 (true, format!("Created tag: {}", tag_name.trim()))
382 }
383 Ok(out) => {
384 let stderr = String::from_utf8_lossy(&out.stderr);
385 (false, format!("Tag failed: {}", stderr.lines().next().unwrap_or("unknown error")))
386 }
387 Err(e) => (false, format!("Failed to run git: {}", e)),
388 }
389 }
390
391 /// Fetch from remote
392 /// Returns (success, message)
393 pub fn git_fetch(&mut self) -> (bool, String) {
394 let root = match &self.root_path {
395 Some(p) => p.clone(),
396 None => return (false, "No workspace".to_string()),
397 };
398
399 let output = Command::new("git")
400 .arg("-C")
401 .arg(&root)
402 .arg("fetch")
403 .output();
404
405 match output {
406 Ok(out) if out.status.success() => {
407 self.refresh_git_status();
408 (true, "Fetched".to_string())
409 }
410 Ok(out) => {
411 let stderr = String::from_utf8_lossy(&out.stderr);
412 (false, format!("Fetch failed: {}", stderr.lines().next().unwrap_or("unknown error")))
413 }
414 Err(e) => (false, format!("Failed to run git: {}", e)),
415 }
416 }
417
418 /// Commit staged changes with the given message
419 /// Returns (success, message)
420 pub fn git_commit(&mut self, message: &str) -> (bool, String) {
421 let root = match &self.root_path {
422 Some(p) => p.clone(),
423 None => return (false, "No workspace".to_string()),
424 };
425
426 if message.trim().is_empty() {
427 return (false, "Empty commit message".to_string());
428 }
429
430 let output = Command::new("git")
431 .arg("-C")
432 .arg(&root)
433 .arg("commit")
434 .arg("-m")
435 .arg(message)
436 .output();
437
438 match output {
439 Ok(out) if out.status.success() => {
440 self.refresh_git_status();
441 (true, "Committed".to_string())
442 }
443 Ok(out) => {
444 let stderr = String::from_utf8_lossy(&out.stderr);
445 if stderr.contains("nothing to commit") {
446 (false, "Nothing to commit".to_string())
447 } else {
448 (false, format!("Commit failed: {}", stderr.lines().next().unwrap_or("unknown error")))
449 }
450 }
451 Err(e) => (false, format!("Failed to run git: {}", e)),
452 }
453 }
454
455 /// Get git diff for the currently selected file
456 /// Returns (filename, diff_content) or None if no diff
457 pub fn get_diff_for_selected(&self) -> Option<(String, String)> {
458 let root = self.root_path.as_ref()?;
459 let path = self.selected_path()?;
460
461 // Don't diff directories
462 if self.is_dir_selected() {
463 return None;
464 }
465
466 // Get relative path for display
467 let rel_path = path.strip_prefix(root).unwrap_or(&path);
468 let filename = rel_path.to_string_lossy().to_string();
469
470 // Run git diff
471 let output = Command::new("git")
472 .arg("-C")
473 .arg(root)
474 .arg("diff")
475 .arg("HEAD")
476 .arg("--")
477 .arg(&path)
478 .output()
479 .ok()?;
480
481 if output.status.success() {
482 let diff = String::from_utf8_lossy(&output.stdout).to_string();
483 if diff.is_empty() {
484 Some((filename, "(no changes)".to_string()))
485 } else {
486 Some((filename, diff))
487 }
488 } else {
489 None
490 }
491 }
492
493 /// Add a character to the filter and jump to first match
494 /// Resets the filter if too much time has passed since last input
495 pub fn filter_push(&mut self, c: char) {
496 let now = Instant::now();
497
498 // Check if we should reset the filter due to timeout
499 if let Some(last) = self.filter_last_input {
500 if now.duration_since(last).as_millis() > FILTER_TIMEOUT_MS {
501 self.filter.clear();
502 }
503 }
504
505 self.filter.push(c);
506 self.filter_last_input = Some(now);
507 self.jump_to_filter_match();
508 }
509
510 /// Remove last character from filter
511 pub fn filter_pop(&mut self) {
512 self.filter.pop();
513 if !self.filter.is_empty() {
514 self.jump_to_filter_match();
515 }
516 }
517
518 /// Clear the filter
519 pub fn filter_clear(&mut self) {
520 self.filter.clear();
521 self.filter_last_input = None;
522 }
523
524 /// Jump to the first item matching the current filter (fuzzy match)
525 fn jump_to_filter_match(&mut self) {
526 if self.filter.is_empty() {
527 return;
528 }
529
530 let tree = match &self.tree {
531 Some(t) => t,
532 None => return,
533 };
534
535 let items = tree.visible_items();
536 let query = self.filter.to_lowercase();
537
538 // Find best matching item starting from current position + 1
539 // This allows pressing the same keys repeatedly to cycle through matches
540 let start = (self.selected + 1) % items.len().max(1);
541
542 // First try: find match starting from current position
543 for offset in 0..items.len() {
544 let idx = (start + offset) % items.len();
545 let name = items[idx].name.to_lowercase();
546
547 if fuzzy_match(&name, &query) {
548 self.selected = idx;
549 return;
550 }
551 }
552 }
553
554 /// Enter git mode (after Alt+G)
555 pub fn enter_git_mode(&mut self) {
556 self.git_mode = true;
557 }
558
559 /// Exit git mode
560 pub fn exit_git_mode(&mut self) {
561 self.git_mode = false;
562 }
563 }
564
565 /// Simple fuzzy matching: checks if query characters appear in order in the target
566 fn fuzzy_match(target: &str, query: &str) -> bool {
567 let mut query_chars = query.chars().peekable();
568
569 for c in target.chars() {
570 if query_chars.peek() == Some(&c) {
571 query_chars.next();
572 }
573 if query_chars.peek().is_none() {
574 return true;
575 }
576 }
577
578 query_chars.peek().is_none()
579 }
580