Rust · 22062 bytes Raw Blame History
1 use crate::error::Result;
2 use crate::git::GitRepo;
3 use crate::tree::{build_tree, flatten_tree, toggle_expanded};
4 use crate::types::{AppMode, FileEntry, InputMode, SelectableItem, TreeNode};
5 use std::path::PathBuf;
6 use std::time::Instant;
7
8 /// Main application state
9 pub struct App {
10 /// Git repository
11 pub repo: GitRepo,
12 /// Repository name
13 pub repo_name: String,
14 /// Current branch name
15 pub branch_name: String,
16 /// Whether to show all files or just dirty ones
17 pub show_all: bool,
18 /// Whether to hide dotfiles
19 pub hide_dotfiles: bool,
20 /// Tree root
21 pub tree: TreeNode,
22 /// Flattened items for display
23 pub items: Vec<SelectableItem>,
24 /// Currently selected item index
25 pub selected: usize,
26 /// Viewport offset for scrolling
27 pub viewport_offset: usize,
28 /// Application mode (Normal or Git)
29 pub mode: AppMode,
30 /// Input mode (Navigation, Rename, Search, etc.)
31 pub input_mode: InputMode,
32 /// Whether the app should quit
33 pub should_quit: bool,
34 /// Status message to display
35 pub status_message: Option<String>,
36 /// Fuzzy search buffer
37 pub search_buffer: String,
38 /// Last search keystroke time (for timeout)
39 pub last_search_time: Option<Instant>,
40 }
41
42 impl App {
43 /// Create a new application instance
44 pub fn new(show_all: bool) -> Result<Self> {
45 let repo = GitRepo::open()?;
46 let repo_name = repo.repo_name();
47 let branch_name = repo.branch_name();
48
49 let mut app = Self {
50 repo,
51 repo_name,
52 branch_name,
53 show_all,
54 hide_dotfiles: false,
55 tree: TreeNode::root(),
56 items: Vec::new(),
57 selected: 0,
58 viewport_offset: 0,
59 mode: AppMode::Normal,
60 input_mode: InputMode::Navigation,
61 should_quit: false,
62 status_message: None,
63 search_buffer: String::new(),
64 last_search_time: None,
65 };
66
67 app.refresh_files()?;
68 Ok(app)
69 }
70
71 /// Refresh file list and rebuild tree
72 pub fn refresh_files(&mut self) -> Result<()> {
73 let mut files = if self.show_all {
74 self.repo.get_all_files()?
75 } else {
76 self.repo.get_dirty_files()?
77 };
78
79 // Mark incoming changes
80 let _ = self.repo.mark_incoming_changes(&mut files);
81
82 self.rebuild_tree(&files);
83 self.update_branch_info();
84 Ok(())
85 }
86
87 /// Rebuild tree from file entries, preserving expanded state
88 fn rebuild_tree(&mut self, files: &[FileEntry]) {
89 // Save collapsed paths
90 let collapsed_paths = self.get_collapsed_paths();
91
92 // Build new tree
93 self.tree = build_tree(files, self.hide_dotfiles);
94
95 // Restore collapsed state
96 self.restore_collapsed_paths(&collapsed_paths);
97
98 // Flatten for display
99 self.items = flatten_tree(&self.tree, self.hide_dotfiles);
100
101 // Adjust selection if needed
102 if self.selected >= self.items.len() && !self.items.is_empty() {
103 self.selected = self.items.len() - 1;
104 }
105 }
106
107 /// Get paths of all collapsed directories
108 fn get_collapsed_paths(&self) -> Vec<PathBuf> {
109 let mut paths = Vec::new();
110 Self::collect_collapsed(&self.tree, &mut paths);
111 paths
112 }
113
114 fn collect_collapsed(node: &TreeNode, paths: &mut Vec<PathBuf>) {
115 if !node.is_file && !node.is_expanded && node.name != "." {
116 paths.push(node.full_path.clone());
117 }
118 for child in &node.children {
119 Self::collect_collapsed(child, paths);
120 }
121 }
122
123 /// Restore collapsed state from saved paths
124 fn restore_collapsed_paths(&mut self, paths: &[PathBuf]) {
125 for path in paths {
126 Self::set_collapsed(&mut self.tree, path);
127 }
128 }
129
130 fn set_collapsed(node: &mut TreeNode, path: &PathBuf) {
131 if &node.full_path == path {
132 node.is_expanded = false;
133 return;
134 }
135 for child in &mut node.children {
136 Self::set_collapsed(child, path);
137 }
138 }
139
140 /// Update branch info
141 pub fn update_branch_info(&mut self) {
142 self.repo_name = self.repo.repo_name();
143 self.branch_name = self.repo.branch_name();
144 }
145
146 /// Get currently selected item
147 pub fn selected_item(&self) -> Option<&SelectableItem> {
148 self.items.get(self.selected)
149 }
150
151 /// Navigate down in the list
152 pub fn navigate_down(&mut self) {
153 if self.items.is_empty() {
154 return;
155 }
156 if self.selected + 1 < self.items.len() {
157 self.selected += 1;
158 }
159 }
160
161 /// Navigate up in the list
162 pub fn navigate_up(&mut self) {
163 if self.items.is_empty() {
164 return;
165 }
166 if self.selected > 0 {
167 self.selected -= 1;
168 }
169 }
170
171 /// Navigate left (collapse current directory or go to parent)
172 pub fn navigate_left(&mut self) {
173 if self.items.is_empty() {
174 return;
175 }
176
177 let item = &self.items[self.selected];
178
179 // If it's an expanded directory, collapse it
180 if !item.is_file && item.is_expanded {
181 self.toggle_selected();
182 return;
183 }
184
185 // Otherwise, go to parent directory
186 let current_depth = item.depth;
187 if current_depth == 0 {
188 return; // Already at root level
189 }
190
191 // Find parent (previous item at depth - 1)
192 for i in (0..self.selected).rev() {
193 if self.items[i].depth == current_depth - 1 {
194 self.selected = i;
195 return;
196 }
197 }
198 }
199
200 /// Navigate right (expand directory or go into it)
201 pub fn navigate_right(&mut self) {
202 if self.items.is_empty() {
203 return;
204 }
205
206 let item = &self.items[self.selected];
207 if item.is_file {
208 return; // Can't enter a file
209 }
210
211 // If collapsed, expand
212 if !item.is_expanded {
213 self.toggle_selected();
214 return;
215 }
216
217 // If expanded, move to first child
218 let target_depth = item.depth + 1;
219 for i in (self.selected + 1)..self.items.len() {
220 if self.items[i].depth == target_depth {
221 self.selected = i;
222 return;
223 }
224 }
225 }
226
227 /// Toggle expanded state of selected directory
228 pub fn toggle_selected(&mut self) {
229 if let Some(item) = self.items.get(self.selected) {
230 if !item.is_file {
231 let path = item.path.clone();
232 if toggle_expanded(&mut self.tree, &path) {
233 self.items = flatten_tree(&self.tree, self.hide_dotfiles);
234 // Keep selection valid
235 if self.selected >= self.items.len() && !self.items.is_empty() {
236 self.selected = self.items.len() - 1;
237 }
238 }
239 }
240 }
241 }
242
243 /// Toggle dotfile visibility
244 pub fn toggle_dotfiles(&mut self) {
245 self.hide_dotfiles = !self.hide_dotfiles;
246 self.items = flatten_tree(&self.tree, self.hide_dotfiles);
247 if self.selected >= self.items.len() && !self.items.is_empty() {
248 self.selected = self.items.len() - 1;
249 }
250 }
251
252 /// Toggle app mode (Normal <-> Git)
253 pub fn toggle_mode(&mut self) {
254 self.mode.toggle();
255 }
256
257 /// Stage selected file or directory
258 pub fn stage_selected(&mut self) -> Result<()> {
259 if let Some(item) = self.selected_item().cloned() {
260 if item.is_file {
261 if item.status.is_unstaged || item.status.is_untracked {
262 self.repo.stage_file(&item.path)?;
263 self.set_status(format!("Staged: {}", item.path.display()));
264 }
265 } else {
266 self.repo.stage_directory(&item.path)?;
267 self.set_status(format!("Staged directory: {}", item.path.display()));
268 }
269 self.refresh_files()?;
270 }
271 Ok(())
272 }
273
274 /// Unstage selected file
275 pub fn unstage_selected(&mut self) -> Result<()> {
276 if let Some(item) = self.selected_item().cloned() {
277 if item.is_file && item.status.is_staged {
278 self.repo.unstage_file(&item.path)?;
279 self.set_status(format!("Unstaged: {}", item.path.display()));
280 self.refresh_files()?;
281 }
282 }
283 Ok(())
284 }
285
286 /// Stage all files
287 pub fn stage_all(&mut self) -> Result<()> {
288 self.repo.stage_all()?;
289 self.set_status("Staged all changes".to_string());
290 self.refresh_files()
291 }
292
293 /// Unstage all files
294 pub fn unstage_all(&mut self) -> Result<()> {
295 self.repo.unstage_all()?;
296 self.set_status("Unstaged all files".to_string());
297 self.refresh_files()
298 }
299
300 /// Discard changes to selected file
301 pub fn discard_selected(&mut self) -> Result<()> {
302 if let Some(item) = self.selected_item().cloned() {
303 if item.is_file && item.status.is_dirty() {
304 self.repo.discard_changes(&item.path, item.status.is_untracked)?;
305 self.set_status(format!("Discarded changes: {}", item.path.display()));
306 self.refresh_files()?;
307 }
308 }
309 Ok(())
310 }
311
312 /// Delete selected file
313 pub fn delete_selected(&mut self) -> Result<()> {
314 if let Some(item) = self.selected_item().cloned() {
315 if item.is_file {
316 self.repo.delete_file(&item.path, item.status.is_untracked)?;
317 self.set_status(format!("Deleted: {}", item.path.display()));
318 self.refresh_files()?;
319 }
320 }
321 Ok(())
322 }
323
324 /// Fetch from remote
325 pub fn fetch(&mut self) -> Result<()> {
326 self.set_status("Fetching...".to_string());
327 self.repo.fetch()?;
328 self.set_status("Fetch complete".to_string());
329 self.refresh_files()
330 }
331
332 /// Pull from remote
333 pub fn pull(&mut self) -> Result<()> {
334 self.set_status("Pulling...".to_string());
335 self.repo.pull()?;
336 self.set_status("Pull complete".to_string());
337 self.refresh_files()
338 }
339
340 /// Push to remote - shows remote selector if no upstream configured
341 pub fn push(&mut self) -> Result<()> {
342 // Check if upstream is configured
343 if self.repo.has_upstream() {
344 // Normal push
345 self.set_status("Pushing...".to_string());
346 self.repo.push()?;
347 self.set_status("Push complete".to_string());
348 Ok(())
349 } else {
350 // No upstream - show remote selector
351 let remotes = self.repo.get_remotes();
352 if remotes.is_empty() {
353 return Err(crate::error::FussrError::Git(
354 git2::Error::from_str("No remotes configured. Add with: git remote add origin <url>")
355 ));
356 }
357 self.input_mode = InputMode::Push {
358 remotes,
359 selected: 0,
360 status: crate::types::PushStatus::SelectRemote,
361 };
362 Ok(())
363 }
364 }
365
366 /// Execute push with selected remote
367 pub fn push_to_remote(&mut self, remote: &str) -> Result<()> {
368 // Update status to pushing
369 if let InputMode::Push { status, .. } = &mut self.input_mode {
370 *status = crate::types::PushStatus::Pushing;
371 }
372
373 match self.repo.push_with_upstream(remote) {
374 Ok(()) => {
375 if let InputMode::Push { status, .. } = &mut self.input_mode {
376 *status = crate::types::PushStatus::Success;
377 }
378 self.set_status(format!("Pushed to {}/{}", remote, self.branch_name));
379 Ok(())
380 }
381 Err(e) => {
382 let msg = e.to_string();
383 if let InputMode::Push { status, .. } = &mut self.input_mode {
384 *status = crate::types::PushStatus::Failed(msg);
385 }
386 Ok(()) // Don't propagate - show in modal
387 }
388 }
389 }
390
391 /// Close push modal
392 pub fn close_push(&mut self) {
393 self.input_mode = InputMode::Navigation;
394 }
395
396 /// Create a commit
397 pub fn commit(&mut self, message: &str) -> Result<()> {
398 self.repo.commit(message)?;
399 self.set_status("Committed successfully".to_string());
400 self.refresh_files()
401 }
402
403 /// Amend the last commit
404 pub fn commit_amend(&mut self, message: &str) -> Result<()> {
405 self.repo.commit_amend(message)?;
406 self.set_status("Commit amended".to_string());
407 self.refresh_files()
408 }
409
410 /// Get last commit message
411 pub fn last_commit_message(&self) -> Option<String> {
412 self.repo.last_commit_message()
413 }
414
415 /// Set status message
416 pub fn set_status(&mut self, message: String) {
417 self.status_message = Some(message);
418 }
419
420 /// Clear status message
421 pub fn clear_status(&mut self) {
422 self.status_message = None;
423 }
424
425 /// Enter rename mode for selected item
426 pub fn enter_rename_mode(&mut self) {
427 if let Some(item) = self.selected_item() {
428 self.input_mode = InputMode::Rename {
429 buffer: item.name.clone(),
430 cursor: item.name.len(),
431 };
432 }
433 }
434
435 /// Exit rename mode and apply rename
436 pub fn apply_rename(&mut self) -> Result<()> {
437 if let InputMode::Rename { buffer, .. } = &self.input_mode {
438 if let Some(item) = self.selected_item().cloned() {
439 let new_name = buffer.trim();
440 if !new_name.is_empty() && new_name != item.name {
441 let new_path = item.path.with_file_name(new_name);
442 self.repo.rename_file(&item.path, &new_path)?;
443 self.set_status(format!("Renamed to: {}", new_name));
444 self.refresh_files()?;
445 }
446 }
447 }
448 self.input_mode = InputMode::Navigation;
449 Ok(())
450 }
451
452 /// Cancel rename mode
453 pub fn cancel_rename(&mut self) {
454 self.input_mode = InputMode::Navigation;
455 }
456
457 /// Enter commit mode
458 pub fn enter_commit_mode(&mut self, amend: bool) {
459 let initial_buffer = if amend {
460 self.repo.last_commit_message().unwrap_or_default()
461 } else {
462 String::new()
463 };
464 let cursor = initial_buffer.len();
465 self.input_mode = InputMode::Commit {
466 buffer: initial_buffer,
467 cursor,
468 amend,
469 status: crate::types::CommitStatus::Editing,
470 };
471 }
472
473 /// Start the commit process (show "Committing..." state)
474 pub fn start_commit(&mut self) {
475 if let InputMode::Commit { status, .. } = &mut self.input_mode {
476 *status = crate::types::CommitStatus::Committing;
477 }
478 }
479
480 /// Apply commit and update status
481 pub fn apply_commit(&mut self) -> Result<()> {
482 // Extract values we need before modifying
483 let (message, amend) = if let InputMode::Commit { buffer, amend, .. } = &self.input_mode {
484 (buffer.trim().to_string(), *amend)
485 } else {
486 return Ok(());
487 };
488
489 if message.is_empty() {
490 self.input_mode = InputMode::Navigation;
491 return Ok(());
492 }
493
494 // Perform the commit
495 let result = if amend {
496 self.repo.commit_amend(&message)
497 } else {
498 self.repo.commit(&message)
499 };
500
501 // Update status based on result
502 match result {
503 Ok(()) => {
504 if let InputMode::Commit { status, .. } = &mut self.input_mode {
505 *status = crate::types::CommitStatus::Success;
506 }
507 self.set_status("Committed".to_string());
508 self.refresh_files()?;
509 }
510 Err(_) => {
511 if let InputMode::Commit { status, .. } = &mut self.input_mode {
512 *status = crate::types::CommitStatus::Failed;
513 }
514 }
515 }
516
517 Ok(())
518 }
519
520 /// Close commit modal and return to navigation
521 pub fn close_commit(&mut self) {
522 self.input_mode = InputMode::Navigation;
523 }
524
525 /// Cancel commit mode
526 pub fn cancel_commit(&mut self) {
527 self.input_mode = InputMode::Navigation;
528 }
529
530 /// Check if search timeout has elapsed (0.5 seconds) and reset if needed
531 pub fn check_search_timeout(&mut self) {
532 if let Some(last_time) = self.last_search_time {
533 if last_time.elapsed().as_millis() > 500 {
534 self.search_buffer.clear();
535 self.last_search_time = None;
536 }
537 }
538 }
539
540 /// Add a character to the search buffer and jump to match
541 pub fn search_add_char(&mut self, c: char) {
542 // Check timeout first - if elapsed, start fresh
543 self.check_search_timeout();
544
545 // Add character to buffer
546 if self.search_buffer.len() < 32 {
547 self.search_buffer.push(c);
548 self.last_search_time = Some(Instant::now());
549
550 // Jump to best match
551 self.fuzzy_jump_to_match();
552 }
553 }
554
555 /// Remove last character from search buffer
556 pub fn search_backspace(&mut self) {
557 if !self.search_buffer.is_empty() {
558 self.search_buffer.pop();
559 self.last_search_time = Some(Instant::now());
560
561 if !self.search_buffer.is_empty() {
562 self.fuzzy_jump_to_match();
563 }
564 }
565 }
566
567 /// Clear the search buffer
568 pub fn clear_search(&mut self) {
569 self.search_buffer.clear();
570 self.last_search_time = None;
571 }
572
573 /// Jump to the best matching item using fzf-style scoring
574 fn fuzzy_jump_to_match(&mut self) {
575 if self.search_buffer.is_empty() || self.items.is_empty() {
576 return;
577 }
578
579 let pattern = self.search_buffer.to_lowercase();
580 let mut best_idx = self.selected;
581 let mut best_score: i32 = 0;
582
583 // Check current item first - if exact match, stay on it
584 if let Some(item) = self.items.get(self.selected) {
585 let current_score = fuzzy_match_score(&pattern, &item.name.to_lowercase());
586 if current_score >= 10000 {
587 return; // Exact match - stay here
588 }
589 best_score = current_score;
590 }
591
592 // PASS 1: Search for basename matches (file/folder names)
593 for (i, item) in self.items.iter().enumerate() {
594 if i == self.selected {
595 continue;
596 }
597 let score = fuzzy_match_score(&pattern, &item.name.to_lowercase());
598 if score > best_score {
599 best_score = score;
600 best_idx = i;
601 }
602 }
603
604 // If we found a good basename match (prefix or exact), use it
605 if best_score >= 5000 {
606 self.selected = best_idx;
607 return;
608 }
609
610 // PASS 2: Search full paths if no good basename match
611 for (i, item) in self.items.iter().enumerate() {
612 if i == self.selected {
613 continue;
614 }
615 let path_str = item.path.to_string_lossy().to_lowercase();
616 let score = fuzzy_match_score(&pattern, &path_str);
617 if score > best_score {
618 best_score = score;
619 best_idx = i;
620 }
621 }
622
623 // Jump to best match if any was found
624 if best_score > 0 {
625 self.selected = best_idx;
626 }
627 }
628 }
629
630 /// Fuzzy matching with fzf-style scoring
631 /// Returns a score (higher is better), 0 means no match
632 fn fuzzy_match_score(pattern: &str, text: &str) -> i32 {
633 if pattern.is_empty() {
634 return 1;
635 }
636
637 // Exact match (highest score)
638 if pattern == text {
639 return 10000;
640 }
641
642 // Prefix match (very high score)
643 if text.starts_with(pattern) {
644 return 5000;
645 }
646
647 // Fuzzy match with scoring
648 let pattern_chars: Vec<char> = pattern.chars().collect();
649 let text_chars: Vec<char> = text.chars().collect();
650
651 let mut score: i32 = 0;
652 let mut pattern_idx = 0;
653 let mut consecutive_bonus: i32 = 0;
654 let mut is_consecutive = false;
655 let mut match_started = false;
656
657 for (text_idx, &c) in text_chars.iter().enumerate() {
658 if pattern_idx >= pattern_chars.len() {
659 break;
660 }
661
662 if c == pattern_chars[pattern_idx] {
663 match_started = true;
664
665 // Base score for each matched character
666 score += 100;
667
668 // Bonus for consecutive characters
669 if is_consecutive {
670 consecutive_bonus += 1;
671 score += consecutive_bonus * 50;
672 } else {
673 consecutive_bonus = 1;
674 is_consecutive = true;
675 }
676
677 // Bonus for matching at start of text
678 if text_idx == 0 {
679 score += 200;
680 }
681
682 // Bonus for matching after separator (word boundary)
683 if text_idx > 0 {
684 let prev = text_chars[text_idx - 1];
685 if prev == '/' || prev == '_' || prev == '-' || prev == '.' {
686 score += 150;
687 }
688 }
689
690 pattern_idx += 1;
691 } else {
692 // Reset consecutive bonus
693 is_consecutive = false;
694 consecutive_bonus = 0;
695 // Small penalty for gaps
696 if match_started {
697 score -= 1;
698 }
699 }
700 }
701
702 // No match if we didn't find all pattern characters
703 if pattern_idx < pattern_chars.len() {
704 return 0;
705 }
706
707 // Penalty for longer strings (prefer concise matches)
708 score -= text.len() as i32;
709
710 score.max(1) // Ensure positive score if we matched
711 }
712