Rust · 31340 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 /// Show confirmation dialog for discard
301 pub fn confirm_discard(&mut self) {
302 if let Some(item) = self.selected_item().cloned() {
303 if item.is_file && item.status.is_dirty() {
304 let path = item.path.clone();
305 let filename = path.file_name()
306 .and_then(|n| n.to_str())
307 .unwrap_or("file")
308 .to_string();
309 self.input_mode = InputMode::Confirm {
310 message: format!("Discard changes to '{}'?", filename),
311 action: crate::types::ConfirmAction::Discard(path),
312 };
313 }
314 }
315 }
316
317 /// Discard changes to selected file
318 pub fn discard_selected(&mut self) -> Result<()> {
319 if let Some(item) = self.selected_item().cloned() {
320 if item.is_file && item.status.is_dirty() {
321 self.repo.discard_changes(&item.path, item.status.is_untracked)?;
322 self.set_status(format!("Discarded changes: {}", item.path.display()));
323 self.refresh_files()?;
324 }
325 }
326 Ok(())
327 }
328
329 /// Execute a confirmed action
330 pub fn execute_confirm_action(&mut self, action: &crate::types::ConfirmAction) -> Result<()> {
331 match action {
332 crate::types::ConfirmAction::Discard(path) => {
333 // Find status for this path
334 let is_untracked = self.items.iter()
335 .find(|i| &i.path == path)
336 .map(|i| i.status.is_untracked)
337 .unwrap_or(false);
338 self.repo.discard_changes(path, is_untracked)?;
339 self.set_status(format!("Discarded: {}", path.display()));
340 self.refresh_files()?;
341 }
342 crate::types::ConfirmAction::Delete(path) => {
343 let is_untracked = self.items.iter()
344 .find(|i| &i.path == path)
345 .map(|i| i.status.is_untracked)
346 .unwrap_or(false);
347 self.repo.delete_file(path, is_untracked)?;
348 self.set_status(format!("Deleted: {}", path.display()));
349 self.refresh_files()?;
350 }
351 crate::types::ConfirmAction::StageAll => {
352 self.stage_all()?;
353 }
354 crate::types::ConfirmAction::UnstageAll => {
355 self.unstage_all()?;
356 }
357 }
358 Ok(())
359 }
360
361 /// Delete selected file
362 pub fn delete_selected(&mut self) -> Result<()> {
363 if let Some(item) = self.selected_item().cloned() {
364 if item.is_file {
365 self.repo.delete_file(&item.path, item.status.is_untracked)?;
366 self.set_status(format!("Deleted: {}", item.path.display()));
367 self.refresh_files()?;
368 }
369 }
370 Ok(())
371 }
372
373 /// Fetch from remote - shows remote selector if multiple remotes
374 pub fn fetch(&mut self) -> Result<()> {
375 let remotes = self.repo.get_remotes();
376 if remotes.len() <= 1 {
377 // Single or no remote - just fetch
378 self.set_status("Fetching...".to_string());
379 self.repo.fetch()?;
380 self.set_status("Fetch complete".to_string());
381 self.refresh_files()
382 } else {
383 // Multiple remotes - show selector
384 self.input_mode = InputMode::Fetch {
385 remotes,
386 selected: 0,
387 status: crate::types::FetchStatus::SelectRemote,
388 };
389 Ok(())
390 }
391 }
392
393 /// Execute fetch from selected remote
394 pub fn fetch_from_remote(&mut self, remote: &str) -> Result<()> {
395 if let InputMode::Fetch { status, .. } = &mut self.input_mode {
396 *status = crate::types::FetchStatus::Fetching;
397 }
398
399 match self.repo.fetch_from_remote(remote) {
400 Ok(()) => {
401 if let InputMode::Fetch { status, .. } = &mut self.input_mode {
402 *status = crate::types::FetchStatus::Success;
403 }
404 self.set_status(format!("Fetched from {}", remote));
405 self.refresh_files()?;
406 Ok(())
407 }
408 Err(e) => {
409 let msg = e.to_string();
410 if let InputMode::Fetch { status, .. } = &mut self.input_mode {
411 *status = crate::types::FetchStatus::Failed(msg);
412 }
413 Ok(())
414 }
415 }
416 }
417
418 /// Close fetch modal
419 pub fn close_fetch(&mut self) {
420 self.input_mode = InputMode::Navigation;
421 }
422
423 /// Force show fetch modal (for testing)
424 pub fn show_fetch_modal(&mut self) {
425 let remotes = self.repo.get_remotes();
426 if remotes.is_empty() {
427 self.set_status("No remotes configured".to_string());
428 return;
429 }
430 self.input_mode = InputMode::Fetch {
431 remotes,
432 selected: 0,
433 status: crate::types::FetchStatus::SelectRemote,
434 };
435 }
436
437 /// Pull from remote - shows remote selector if no upstream configured
438 pub fn pull(&mut self) -> Result<()> {
439 if self.repo.has_upstream() {
440 self.set_status("Pulling...".to_string());
441 self.repo.pull()?;
442 self.set_status("Pull complete".to_string());
443 self.refresh_files()
444 } else {
445 let remotes = self.repo.get_remotes();
446 if remotes.is_empty() {
447 return Err(crate::error::FussrError::Git(
448 git2::Error::from_str("No remotes configured. Add with: git remote add origin <url>")
449 ));
450 }
451 self.input_mode = InputMode::Pull {
452 remotes,
453 selected: 0,
454 status: crate::types::PullStatus::SelectRemote,
455 };
456 Ok(())
457 }
458 }
459
460 /// Execute pull with selected remote
461 pub fn pull_from_remote(&mut self, remote: &str) -> Result<()> {
462 if let InputMode::Pull { status, .. } = &mut self.input_mode {
463 *status = crate::types::PullStatus::Pulling;
464 }
465
466 match self.repo.pull_from_remote(remote) {
467 Ok(()) => {
468 if let InputMode::Pull { status, .. } = &mut self.input_mode {
469 *status = crate::types::PullStatus::Success;
470 }
471 self.set_status(format!("Pulled from {}/{}", remote, self.branch_name));
472 self.refresh_files()?;
473 Ok(())
474 }
475 Err(e) => {
476 let msg = e.to_string();
477 if let InputMode::Pull { status, .. } = &mut self.input_mode {
478 *status = crate::types::PullStatus::Failed(msg);
479 }
480 Ok(())
481 }
482 }
483 }
484
485 /// Close pull modal
486 pub fn close_pull(&mut self) {
487 self.input_mode = InputMode::Navigation;
488 }
489
490 /// Force show pull modal (for testing)
491 pub fn show_pull_modal(&mut self) {
492 let remotes = self.repo.get_remotes();
493 if remotes.is_empty() {
494 self.set_status("No remotes configured".to_string());
495 return;
496 }
497 self.input_mode = InputMode::Pull {
498 remotes,
499 selected: 0,
500 status: crate::types::PullStatus::SelectRemote,
501 };
502 }
503
504 /// Push to remote - shows remote selector if no upstream configured
505 pub fn push(&mut self) -> Result<()> {
506 // Check if upstream is configured
507 if self.repo.has_upstream() {
508 // Normal push
509 self.set_status("Pushing...".to_string());
510 self.repo.push()?;
511 self.set_status("Push complete".to_string());
512 Ok(())
513 } else {
514 // No upstream - show remote selector
515 let remotes = self.repo.get_remotes();
516 if remotes.is_empty() {
517 return Err(crate::error::FussrError::Git(
518 git2::Error::from_str("No remotes configured. Add with: git remote add origin <url>")
519 ));
520 }
521 self.input_mode = InputMode::Push {
522 remotes,
523 selected: 0,
524 status: crate::types::PushStatus::SelectRemote,
525 };
526 Ok(())
527 }
528 }
529
530 /// Execute push with selected remote
531 pub fn push_to_remote(&mut self, remote: &str) -> Result<()> {
532 // Update status to pushing
533 if let InputMode::Push { status, .. } = &mut self.input_mode {
534 *status = crate::types::PushStatus::Pushing;
535 }
536
537 match self.repo.push_with_upstream(remote) {
538 Ok(()) => {
539 if let InputMode::Push { status, .. } = &mut self.input_mode {
540 *status = crate::types::PushStatus::Success;
541 }
542 self.set_status(format!("Pushed to {}/{}", remote, self.branch_name));
543 Ok(())
544 }
545 Err(e) => {
546 let msg = e.to_string();
547 if let InputMode::Push { status, .. } = &mut self.input_mode {
548 *status = crate::types::PushStatus::Failed(msg);
549 }
550 Ok(()) // Don't propagate - show in modal
551 }
552 }
553 }
554
555 /// Close push modal
556 pub fn close_push(&mut self) {
557 self.input_mode = InputMode::Navigation;
558 }
559
560 /// Force show push modal (for testing)
561 pub fn show_push_modal(&mut self) {
562 let remotes = self.repo.get_remotes();
563 if remotes.is_empty() {
564 self.set_status("No remotes configured".to_string());
565 return;
566 }
567 self.input_mode = InputMode::Push {
568 remotes,
569 selected: 0,
570 status: crate::types::PushStatus::SelectRemote,
571 };
572 }
573
574 /// Enter tag mode - fetches tags and shows modal
575 pub fn enter_tag_mode(&mut self) {
576 // Fetch tags from remote first (non-blocking, ignore errors)
577 let _ = self.repo.fetch_tags();
578
579 // Get existing tags
580 let existing_tags = self.repo.get_tags();
581
582 self.input_mode = InputMode::Tag {
583 name: String::new(),
584 message: String::new(),
585 cursor: 0,
586 existing_tags,
587 step: crate::types::TagStep::EnterName,
588 };
589 }
590
591 /// Create the tag with current name/message
592 pub fn create_tag(&mut self) -> Result<()> {
593 let (name, message) = if let InputMode::Tag { name, message, .. } = &self.input_mode {
594 (name.clone(), message.clone())
595 } else {
596 return Ok(());
597 };
598
599 if let InputMode::Tag { step, .. } = &mut self.input_mode {
600 *step = crate::types::TagStep::Creating;
601 }
602
603 match self.repo.create_tag(&name, &message) {
604 Ok(()) => {
605 if let InputMode::Tag { step, .. } = &mut self.input_mode {
606 *step = crate::types::TagStep::AskPush;
607 }
608 Ok(())
609 }
610 Err(e) => {
611 let msg = e.to_string();
612 if let InputMode::Tag { step, .. } = &mut self.input_mode {
613 *step = crate::types::TagStep::Failed(msg);
614 }
615 Ok(())
616 }
617 }
618 }
619
620 /// Push the tag to origin
621 pub fn push_tag(&mut self) -> Result<()> {
622 let name = if let InputMode::Tag { name, .. } = &self.input_mode {
623 name.clone()
624 } else {
625 return Ok(());
626 };
627
628 if let InputMode::Tag { step, .. } = &mut self.input_mode {
629 *step = crate::types::TagStep::Pushing;
630 }
631
632 match self.repo.push_tag(&name) {
633 Ok(()) => {
634 if let InputMode::Tag { step, .. } = &mut self.input_mode {
635 *step = crate::types::TagStep::Success;
636 }
637 self.set_status(format!("Tag '{}' pushed to origin", name));
638 Ok(())
639 }
640 Err(e) => {
641 let msg = e.to_string();
642 if let InputMode::Tag { step, .. } = &mut self.input_mode {
643 *step = crate::types::TagStep::Failed(msg);
644 }
645 Ok(())
646 }
647 }
648 }
649
650 /// Close tag modal (with success message if tag was created)
651 pub fn close_tag(&mut self, was_created: bool) {
652 if was_created {
653 if let InputMode::Tag { name, .. } = &self.input_mode {
654 self.set_status(format!("Tag '{}' created", name));
655 }
656 }
657 self.input_mode = InputMode::Navigation;
658 }
659
660 /// Create a commit
661 pub fn commit(&mut self, message: &str) -> Result<()> {
662 self.repo.commit(message)?;
663 self.set_status("Committed successfully".to_string());
664 self.refresh_files()
665 }
666
667 /// Amend the last commit
668 pub fn commit_amend(&mut self, message: &str) -> Result<()> {
669 self.repo.commit_amend(message)?;
670 self.set_status("Commit amended".to_string());
671 self.refresh_files()
672 }
673
674 /// Get last commit message
675 pub fn last_commit_message(&self) -> Option<String> {
676 self.repo.last_commit_message()
677 }
678
679 /// Set status message
680 pub fn set_status(&mut self, message: String) {
681 self.status_message = Some(message);
682 }
683
684 /// Clear status message
685 pub fn clear_status(&mut self) {
686 self.status_message = None;
687 }
688
689 /// Enter rename mode for selected item
690 pub fn enter_rename_mode(&mut self) {
691 if let Some(item) = self.selected_item() {
692 self.input_mode = InputMode::Rename {
693 buffer: item.name.clone(),
694 cursor: item.name.len(),
695 };
696 }
697 }
698
699 /// Exit rename mode and apply rename
700 pub fn apply_rename(&mut self) -> Result<()> {
701 if let InputMode::Rename { buffer, .. } = &self.input_mode {
702 if let Some(item) = self.selected_item().cloned() {
703 let new_name = buffer.trim();
704 if !new_name.is_empty() && new_name != item.name {
705 let new_path = item.path.with_file_name(new_name);
706 self.repo.rename_file(&item.path, &new_path)?;
707 self.set_status(format!("Renamed to: {}", new_name));
708 self.refresh_files()?;
709 }
710 }
711 }
712 self.input_mode = InputMode::Navigation;
713 Ok(())
714 }
715
716 /// Cancel rename mode
717 pub fn cancel_rename(&mut self) {
718 self.input_mode = InputMode::Navigation;
719 }
720
721 /// Enter commit mode
722 pub fn enter_commit_mode(&mut self, amend: bool) {
723 let initial_buffer = if amend {
724 self.repo.last_commit_message().unwrap_or_default()
725 } else {
726 String::new()
727 };
728 let cursor = initial_buffer.len();
729 self.input_mode = InputMode::Commit {
730 buffer: initial_buffer,
731 cursor,
732 amend,
733 status: crate::types::CommitStatus::Editing,
734 };
735 }
736
737 /// Start the commit process (show "Committing..." state)
738 pub fn start_commit(&mut self) {
739 if let InputMode::Commit { status, .. } = &mut self.input_mode {
740 *status = crate::types::CommitStatus::Committing;
741 }
742 }
743
744 /// Apply commit and update status
745 pub fn apply_commit(&mut self) -> Result<()> {
746 // Extract values we need before modifying
747 let (message, amend) = if let InputMode::Commit { buffer, amend, .. } = &self.input_mode {
748 (buffer.trim().to_string(), *amend)
749 } else {
750 return Ok(());
751 };
752
753 if message.is_empty() {
754 self.input_mode = InputMode::Navigation;
755 return Ok(());
756 }
757
758 // Perform the commit
759 let result = if amend {
760 self.repo.commit_amend(&message)
761 } else {
762 self.repo.commit(&message)
763 };
764
765 // Update status based on result
766 match result {
767 Ok(()) => {
768 if let InputMode::Commit { status, .. } = &mut self.input_mode {
769 *status = crate::types::CommitStatus::Success;
770 }
771 self.set_status("Committed".to_string());
772 self.refresh_files()?;
773 }
774 Err(_) => {
775 if let InputMode::Commit { status, .. } = &mut self.input_mode {
776 *status = crate::types::CommitStatus::Failed;
777 }
778 }
779 }
780
781 Ok(())
782 }
783
784 /// Close commit modal and return to navigation
785 pub fn close_commit(&mut self) {
786 self.input_mode = InputMode::Navigation;
787 }
788
789 /// Cancel commit mode
790 pub fn cancel_commit(&mut self) {
791 self.input_mode = InputMode::Navigation;
792 }
793
794 /// Check if search timeout has elapsed (0.5 seconds) and reset if needed
795 pub fn check_search_timeout(&mut self) {
796 if let Some(last_time) = self.last_search_time {
797 if last_time.elapsed().as_millis() > 500 {
798 self.search_buffer.clear();
799 self.last_search_time = None;
800 }
801 }
802 }
803
804 /// Add a character to the search buffer and jump to match
805 pub fn search_add_char(&mut self, c: char) {
806 // Check timeout first - if elapsed, start fresh
807 self.check_search_timeout();
808
809 // Add character to buffer
810 if self.search_buffer.len() < 32 {
811 self.search_buffer.push(c);
812 self.last_search_time = Some(Instant::now());
813
814 // Jump to best match
815 self.fuzzy_jump_to_match();
816 }
817 }
818
819 /// Remove last character from search buffer
820 pub fn search_backspace(&mut self) {
821 if !self.search_buffer.is_empty() {
822 self.search_buffer.pop();
823 self.last_search_time = Some(Instant::now());
824
825 if !self.search_buffer.is_empty() {
826 self.fuzzy_jump_to_match();
827 }
828 }
829 }
830
831 /// Clear the search buffer
832 pub fn clear_search(&mut self) {
833 self.search_buffer.clear();
834 self.last_search_time = None;
835 }
836
837 /// Jump to the best matching item using fzf-style scoring
838 fn fuzzy_jump_to_match(&mut self) {
839 if self.search_buffer.is_empty() || self.items.is_empty() {
840 return;
841 }
842
843 let pattern = self.search_buffer.to_lowercase();
844 let mut best_idx = self.selected;
845 let mut best_score: i32 = 0;
846
847 // Check current item first - if exact match, stay on it
848 if let Some(item) = self.items.get(self.selected) {
849 let current_score = fuzzy_match_score(&pattern, &item.name.to_lowercase());
850 if current_score >= 10000 {
851 return; // Exact match - stay here
852 }
853 best_score = current_score;
854 }
855
856 // PASS 1: Search for basename matches (file/folder names)
857 for (i, item) in self.items.iter().enumerate() {
858 if i == self.selected {
859 continue;
860 }
861 let score = fuzzy_match_score(&pattern, &item.name.to_lowercase());
862 if score > best_score {
863 best_score = score;
864 best_idx = i;
865 }
866 }
867
868 // If we found a good basename match (prefix or exact), use it
869 if best_score >= 5000 {
870 self.selected = best_idx;
871 return;
872 }
873
874 // PASS 2: Search full paths if no good basename match
875 for (i, item) in self.items.iter().enumerate() {
876 if i == self.selected {
877 continue;
878 }
879 let path_str = item.path.to_string_lossy().to_lowercase();
880 let score = fuzzy_match_score(&pattern, &path_str);
881 if score > best_score {
882 best_score = score;
883 best_idx = i;
884 }
885 }
886
887 // Jump to best match if any was found
888 if best_score > 0 {
889 self.selected = best_idx;
890 }
891 }
892 }
893
894 /// Fuzzy matching with fzf-style scoring
895 /// Returns a score (higher is better), 0 means no match
896 fn fuzzy_match_score(pattern: &str, text: &str) -> i32 {
897 if pattern.is_empty() {
898 return 1;
899 }
900
901 // Exact match (highest score)
902 if pattern == text {
903 return 10000;
904 }
905
906 // Prefix match (very high score)
907 if text.starts_with(pattern) {
908 return 5000;
909 }
910
911 // Fuzzy match with scoring
912 let pattern_chars: Vec<char> = pattern.chars().collect();
913 let text_chars: Vec<char> = text.chars().collect();
914
915 let mut score: i32 = 0;
916 let mut pattern_idx = 0;
917 let mut consecutive_bonus: i32 = 0;
918 let mut is_consecutive = false;
919 let mut match_started = false;
920
921 for (text_idx, &c) in text_chars.iter().enumerate() {
922 if pattern_idx >= pattern_chars.len() {
923 break;
924 }
925
926 if c == pattern_chars[pattern_idx] {
927 match_started = true;
928
929 // Base score for each matched character
930 score += 100;
931
932 // Bonus for consecutive characters
933 if is_consecutive {
934 consecutive_bonus += 1;
935 score += consecutive_bonus * 50;
936 } else {
937 consecutive_bonus = 1;
938 is_consecutive = true;
939 }
940
941 // Bonus for matching at start of text
942 if text_idx == 0 {
943 score += 200;
944 }
945
946 // Bonus for matching after separator (word boundary)
947 if text_idx > 0 {
948 let prev = text_chars[text_idx - 1];
949 if prev == '/' || prev == '_' || prev == '-' || prev == '.' {
950 score += 150;
951 }
952 }
953
954 pattern_idx += 1;
955 } else {
956 // Reset consecutive bonus
957 is_consecutive = false;
958 consecutive_bonus = 0;
959 // Small penalty for gaps
960 if match_started {
961 score -= 1;
962 }
963 }
964 }
965
966 // No match if we didn't find all pattern characters
967 if pattern_idx < pattern_chars.len() {
968 return 0;
969 }
970
971 // Penalty for longer strings (prefer concise matches)
972 score -= text.len() as i32;
973
974 score.max(1) // Ensure positive score if we matched
975 }
976