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