implement fuzzy jumping for faster staging. fix commit modalss
- SHA
52128880944c5d54ce5ee3f45c010314637d4029- Parents
-
f782186 - Tree
d18cf37
5212888
52128880944c5d54ce5ee3f45c010314637d4029f782186
d18cf37| Status | File | + | - |
|---|---|---|---|
| M |
src/app.rs
|
203 | 34 |
| M |
src/git.rs
|
30 | 20 |
| M |
src/main.rs
|
110 | 50 |
| M |
src/types.rs
|
10 | 1 |
| M |
src/ui.rs
|
66 | 18 |
src/app.rsmodified@@ -3,6 +3,7 @@ use crate::git::GitRepo; | ||
| 3 | 3 | use crate::tree::{build_tree, flatten_tree, toggle_expanded}; |
| 4 | 4 | use crate::types::{AppMode, FileEntry, InputMode, SelectableItem, TreeNode}; |
| 5 | 5 | use std::path::PathBuf; |
| 6 | +use std::time::Instant; | |
| 6 | 7 | |
| 7 | 8 | /// Main application state |
| 8 | 9 | pub struct App { |
@@ -32,6 +33,10 @@ pub struct App { | ||
| 32 | 33 | pub should_quit: bool, |
| 33 | 34 | /// Status message to display |
| 34 | 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>, | |
| 35 | 40 | } |
| 36 | 41 | |
| 37 | 42 | impl App { |
@@ -55,6 +60,8 @@ impl App { | ||
| 55 | 60 | input_mode: InputMode::Navigation, |
| 56 | 61 | should_quit: false, |
| 57 | 62 | status_message: None, |
| 63 | + search_buffer: String::new(), | |
| 64 | + last_search_time: None, | |
| 58 | 65 | }; |
| 59 | 66 | |
| 60 | 67 | app.refresh_files()?; |
@@ -411,84 +418,246 @@ impl App { | ||
| 411 | 418 | buffer: initial_buffer, |
| 412 | 419 | cursor, |
| 413 | 420 | amend, |
| 421 | + status: crate::types::CommitStatus::Editing, | |
| 414 | 422 | }; |
| 415 | 423 | } |
| 416 | 424 | |
| 417 | - /// Apply commit | |
| 425 | + /// Start the commit process (show "Committing..." state) | |
| 426 | + pub fn start_commit(&mut self) { | |
| 427 | + if let InputMode::Commit { status, .. } = &mut self.input_mode { | |
| 428 | + *status = crate::types::CommitStatus::Committing; | |
| 429 | + } | |
| 430 | + } | |
| 431 | + | |
| 432 | + /// Apply commit and update status | |
| 418 | 433 | pub fn apply_commit(&mut self) -> Result<()> { |
| 419 | - if let InputMode::Commit { buffer, amend, .. } = &self.input_mode { | |
| 420 | - let message = buffer.trim(); | |
| 421 | - if !message.is_empty() { | |
| 422 | - if *amend { | |
| 423 | - self.repo.commit_amend(message)?; | |
| 424 | - self.set_status("Commit amended".to_string()); | |
| 425 | - } else { | |
| 426 | - self.repo.commit(message)?; | |
| 427 | - self.set_status("Committed".to_string()); | |
| 434 | + // Extract values we need before modifying | |
| 435 | + let (message, amend) = if let InputMode::Commit { buffer, amend, .. } = &self.input_mode { | |
| 436 | + (buffer.trim().to_string(), *amend) | |
| 437 | + } else { | |
| 438 | + return Ok(()); | |
| 439 | + }; | |
| 440 | + | |
| 441 | + if message.is_empty() { | |
| 442 | + self.input_mode = InputMode::Navigation; | |
| 443 | + return Ok(()); | |
| 444 | + } | |
| 445 | + | |
| 446 | + // Perform the commit | |
| 447 | + let result = if amend { | |
| 448 | + self.repo.commit_amend(&message) | |
| 449 | + } else { | |
| 450 | + self.repo.commit(&message) | |
| 451 | + }; | |
| 452 | + | |
| 453 | + // Update status based on result | |
| 454 | + match result { | |
| 455 | + Ok(()) => { | |
| 456 | + if let InputMode::Commit { status, .. } = &mut self.input_mode { | |
| 457 | + *status = crate::types::CommitStatus::Success; | |
| 428 | 458 | } |
| 459 | + self.set_status("Committed".to_string()); | |
| 429 | 460 | self.refresh_files()?; |
| 430 | 461 | } |
| 462 | + Err(_) => { | |
| 463 | + if let InputMode::Commit { status, .. } = &mut self.input_mode { | |
| 464 | + *status = crate::types::CommitStatus::Failed; | |
| 465 | + } | |
| 466 | + } | |
| 431 | 467 | } |
| 432 | - self.input_mode = InputMode::Navigation; | |
| 468 | + | |
| 433 | 469 | Ok(()) |
| 434 | 470 | } |
| 435 | 471 | |
| 472 | + /// Close commit modal and return to navigation | |
| 473 | + pub fn close_commit(&mut self) { | |
| 474 | + self.input_mode = InputMode::Navigation; | |
| 475 | + } | |
| 476 | + | |
| 436 | 477 | /// Cancel commit mode |
| 437 | 478 | pub fn cancel_commit(&mut self) { |
| 438 | 479 | self.input_mode = InputMode::Navigation; |
| 439 | 480 | } |
| 440 | 481 | |
| 441 | - /// Fuzzy search and jump to match | |
| 442 | - pub fn fuzzy_jump(&mut self, pattern: &str) { | |
| 443 | - if pattern.is_empty() { | |
| 482 | + /// Check if search timeout has elapsed (0.5 seconds) and reset if needed | |
| 483 | + pub fn check_search_timeout(&mut self) { | |
| 484 | + if let Some(last_time) = self.last_search_time { | |
| 485 | + if last_time.elapsed().as_millis() > 500 { | |
| 486 | + self.search_buffer.clear(); | |
| 487 | + self.last_search_time = None; | |
| 488 | + } | |
| 489 | + } | |
| 490 | + } | |
| 491 | + | |
| 492 | + /// Add a character to the search buffer and jump to match | |
| 493 | + pub fn search_add_char(&mut self, c: char) { | |
| 494 | + // Check timeout first - if elapsed, start fresh | |
| 495 | + self.check_search_timeout(); | |
| 496 | + | |
| 497 | + // Add character to buffer | |
| 498 | + if self.search_buffer.len() < 32 { | |
| 499 | + self.search_buffer.push(c); | |
| 500 | + self.last_search_time = Some(Instant::now()); | |
| 501 | + | |
| 502 | + // Jump to best match | |
| 503 | + self.fuzzy_jump_to_match(); | |
| 504 | + } | |
| 505 | + } | |
| 506 | + | |
| 507 | + /// Remove last character from search buffer | |
| 508 | + pub fn search_backspace(&mut self) { | |
| 509 | + if !self.search_buffer.is_empty() { | |
| 510 | + self.search_buffer.pop(); | |
| 511 | + self.last_search_time = Some(Instant::now()); | |
| 512 | + | |
| 513 | + if !self.search_buffer.is_empty() { | |
| 514 | + self.fuzzy_jump_to_match(); | |
| 515 | + } | |
| 516 | + } | |
| 517 | + } | |
| 518 | + | |
| 519 | + /// Clear the search buffer | |
| 520 | + pub fn clear_search(&mut self) { | |
| 521 | + self.search_buffer.clear(); | |
| 522 | + self.last_search_time = None; | |
| 523 | + } | |
| 524 | + | |
| 525 | + /// Jump to the best matching item using fzf-style scoring | |
| 526 | + fn fuzzy_jump_to_match(&mut self) { | |
| 527 | + if self.search_buffer.is_empty() || self.items.is_empty() { | |
| 444 | 528 | return; |
| 445 | 529 | } |
| 446 | 530 | |
| 447 | - let pattern_lower = pattern.to_lowercase(); | |
| 531 | + let pattern = self.search_buffer.to_lowercase(); | |
| 448 | 532 | let mut best_idx = self.selected; |
| 449 | - let mut best_score = 0; | |
| 533 | + let mut best_score: i32 = 0; | |
| 534 | + | |
| 535 | + // Check current item first - if exact match, stay on it | |
| 536 | + if let Some(item) = self.items.get(self.selected) { | |
| 537 | + let current_score = fuzzy_match_score(&pattern, &item.name.to_lowercase()); | |
| 538 | + if current_score >= 10000 { | |
| 539 | + return; // Exact match - stay here | |
| 540 | + } | |
| 541 | + best_score = current_score; | |
| 542 | + } | |
| 543 | + | |
| 544 | + // PASS 1: Search for basename matches (file/folder names) | |
| 545 | + for (i, item) in self.items.iter().enumerate() { | |
| 546 | + if i == self.selected { | |
| 547 | + continue; | |
| 548 | + } | |
| 549 | + let score = fuzzy_match_score(&pattern, &item.name.to_lowercase()); | |
| 550 | + if score > best_score { | |
| 551 | + best_score = score; | |
| 552 | + best_idx = i; | |
| 553 | + } | |
| 554 | + } | |
| 555 | + | |
| 556 | + // If we found a good basename match (prefix or exact), use it | |
| 557 | + if best_score >= 5000 { | |
| 558 | + self.selected = best_idx; | |
| 559 | + return; | |
| 560 | + } | |
| 450 | 561 | |
| 562 | + // PASS 2: Search full paths if no good basename match | |
| 451 | 563 | for (i, item) in self.items.iter().enumerate() { |
| 452 | - let score = fuzzy_score(&pattern_lower, &item.name.to_lowercase()); | |
| 564 | + if i == self.selected { | |
| 565 | + continue; | |
| 566 | + } | |
| 567 | + let path_str = item.path.to_string_lossy().to_lowercase(); | |
| 568 | + let score = fuzzy_match_score(&pattern, &path_str); | |
| 453 | 569 | if score > best_score { |
| 454 | 570 | best_score = score; |
| 455 | 571 | best_idx = i; |
| 456 | 572 | } |
| 457 | 573 | } |
| 458 | 574 | |
| 575 | + // Jump to best match if any was found | |
| 459 | 576 | if best_score > 0 { |
| 460 | 577 | self.selected = best_idx; |
| 461 | 578 | } |
| 462 | 579 | } |
| 463 | 580 | } |
| 464 | 581 | |
| 465 | -/// Simple fuzzy matching score | |
| 466 | -fn fuzzy_score(pattern: &str, text: &str) -> usize { | |
| 582 | +/// Fuzzy matching with fzf-style scoring | |
| 583 | +/// Returns a score (higher is better), 0 means no match | |
| 584 | +fn fuzzy_match_score(pattern: &str, text: &str) -> i32 { | |
| 585 | + if pattern.is_empty() { | |
| 586 | + return 1; | |
| 587 | + } | |
| 588 | + | |
| 589 | + // Exact match (highest score) | |
| 590 | + if pattern == text { | |
| 591 | + return 10000; | |
| 592 | + } | |
| 593 | + | |
| 594 | + // Prefix match (very high score) | |
| 467 | 595 | if text.starts_with(pattern) { |
| 468 | - return 1000 + (100 - text.len()); // Prefix match bonus | |
| 596 | + return 5000; | |
| 469 | 597 | } |
| 470 | 598 | |
| 471 | - let mut score = 0; | |
| 472 | - let mut pattern_idx = 0; | |
| 599 | + // Fuzzy match with scoring | |
| 473 | 600 | let pattern_chars: Vec<char> = pattern.chars().collect(); |
| 474 | - let mut consecutive = 0; | |
| 601 | + let text_chars: Vec<char> = text.chars().collect(); | |
| 602 | + | |
| 603 | + let mut score: i32 = 0; | |
| 604 | + let mut pattern_idx = 0; | |
| 605 | + let mut consecutive_bonus: i32 = 0; | |
| 606 | + let mut is_consecutive = false; | |
| 607 | + let mut match_started = false; | |
| 608 | + | |
| 609 | + for (text_idx, &c) in text_chars.iter().enumerate() { | |
| 610 | + if pattern_idx >= pattern_chars.len() { | |
| 611 | + break; | |
| 612 | + } | |
| 613 | + | |
| 614 | + if c == pattern_chars[pattern_idx] { | |
| 615 | + match_started = true; | |
| 616 | + | |
| 617 | + // Base score for each matched character | |
| 618 | + score += 100; | |
| 619 | + | |
| 620 | + // Bonus for consecutive characters | |
| 621 | + if is_consecutive { | |
| 622 | + consecutive_bonus += 1; | |
| 623 | + score += consecutive_bonus * 50; | |
| 624 | + } else { | |
| 625 | + consecutive_bonus = 1; | |
| 626 | + is_consecutive = true; | |
| 627 | + } | |
| 475 | 628 | |
| 476 | - for (i, c) in text.chars().enumerate() { | |
| 477 | - if pattern_idx < pattern_chars.len() && c == pattern_chars[pattern_idx] { | |
| 478 | - score += 10 + consecutive * 5; | |
| 479 | - if i == 0 { | |
| 480 | - score += 20; // Start match bonus | |
| 629 | + // Bonus for matching at start of text | |
| 630 | + if text_idx == 0 { | |
| 631 | + score += 200; | |
| 632 | + } | |
| 633 | + | |
| 634 | + // Bonus for matching after separator (word boundary) | |
| 635 | + if text_idx > 0 { | |
| 636 | + let prev = text_chars[text_idx - 1]; | |
| 637 | + if prev == '/' || prev == '_' || prev == '-' || prev == '.' { | |
| 638 | + score += 150; | |
| 639 | + } | |
| 481 | 640 | } |
| 482 | - consecutive += 1; | |
| 641 | + | |
| 483 | 642 | pattern_idx += 1; |
| 484 | 643 | } else { |
| 485 | - consecutive = 0; | |
| 644 | + // Reset consecutive bonus | |
| 645 | + is_consecutive = false; | |
| 646 | + consecutive_bonus = 0; | |
| 647 | + // Small penalty for gaps | |
| 648 | + if match_started { | |
| 649 | + score -= 1; | |
| 650 | + } | |
| 486 | 651 | } |
| 487 | 652 | } |
| 488 | 653 | |
| 489 | - if pattern_idx == pattern_chars.len() { | |
| 490 | - score | |
| 491 | - } else { | |
| 492 | - 0 // Not all chars matched | |
| 654 | + // No match if we didn't find all pattern characters | |
| 655 | + if pattern_idx < pattern_chars.len() { | |
| 656 | + return 0; | |
| 493 | 657 | } |
| 658 | + | |
| 659 | + // Penalty for longer strings (prefer concise matches) | |
| 660 | + score -= text.len() as i32; | |
| 661 | + | |
| 662 | + score.max(1) // Ensure positive score if we matched | |
| 494 | 663 | } |
src/git.rsmodified@@ -276,26 +276,30 @@ impl GitRepo { | ||
| 276 | 276 | Ok(()) |
| 277 | 277 | } |
| 278 | 278 | |
| 279 | - /// Create a commit with the given message | |
| 279 | + /// Create a commit with the given message (captures output to not corrupt TUI) | |
| 280 | 280 | pub fn commit(&self, message: &str) -> Result<()> { |
| 281 | - let status = Command::new("git") | |
| 281 | + let output = Command::new("git") | |
| 282 | 282 | .args(["commit", "-m", message]) |
| 283 | - .status()?; | |
| 283 | + .stdout(std::process::Stdio::piped()) | |
| 284 | + .stderr(std::process::Stdio::piped()) | |
| 285 | + .output()?; | |
| 284 | 286 | |
| 285 | - if status.success() { | |
| 287 | + if output.status.success() { | |
| 286 | 288 | Ok(()) |
| 287 | 289 | } else { |
| 288 | 290 | Err(FussrError::Git(git2::Error::from_str("Failed to commit"))) |
| 289 | 291 | } |
| 290 | 292 | } |
| 291 | 293 | |
| 292 | - /// Amend the last commit | |
| 294 | + /// Amend the last commit (captures output to not corrupt TUI) | |
| 293 | 295 | pub fn commit_amend(&self, message: &str) -> Result<()> { |
| 294 | - let status = Command::new("git") | |
| 296 | + let output = Command::new("git") | |
| 295 | 297 | .args(["commit", "--amend", "-m", message]) |
| 296 | - .status()?; | |
| 298 | + .stdout(std::process::Stdio::piped()) | |
| 299 | + .stderr(std::process::Stdio::piped()) | |
| 300 | + .output()?; | |
| 297 | 301 | |
| 298 | - if status.success() { | |
| 302 | + if output.status.success() { | |
| 299 | 303 | Ok(()) |
| 300 | 304 | } else { |
| 301 | 305 | Err(FussrError::Git(git2::Error::from_str("Failed to amend commit"))) |
@@ -316,39 +320,45 @@ impl GitRepo { | ||
| 316 | 320 | } |
| 317 | 321 | } |
| 318 | 322 | |
| 319 | - /// Fetch from remote | |
| 323 | + /// Fetch from remote (captures output to not corrupt TUI) | |
| 320 | 324 | pub fn fetch(&self) -> Result<()> { |
| 321 | - let status = Command::new("git") | |
| 325 | + let output = Command::new("git") | |
| 322 | 326 | .args(["fetch"]) |
| 323 | - .status()?; | |
| 327 | + .stdout(std::process::Stdio::piped()) | |
| 328 | + .stderr(std::process::Stdio::piped()) | |
| 329 | + .output()?; | |
| 324 | 330 | |
| 325 | - if status.success() { | |
| 331 | + if output.status.success() { | |
| 326 | 332 | Ok(()) |
| 327 | 333 | } else { |
| 328 | 334 | Err(FussrError::Git(git2::Error::from_str("Failed to fetch"))) |
| 329 | 335 | } |
| 330 | 336 | } |
| 331 | 337 | |
| 332 | - /// Pull from remote | |
| 338 | + /// Pull from remote (captures output to not corrupt TUI) | |
| 333 | 339 | pub fn pull(&self) -> Result<()> { |
| 334 | - let status = Command::new("git") | |
| 340 | + let output = Command::new("git") | |
| 335 | 341 | .args(["pull"]) |
| 336 | - .status()?; | |
| 342 | + .stdout(std::process::Stdio::piped()) | |
| 343 | + .stderr(std::process::Stdio::piped()) | |
| 344 | + .output()?; | |
| 337 | 345 | |
| 338 | - if status.success() { | |
| 346 | + if output.status.success() { | |
| 339 | 347 | Ok(()) |
| 340 | 348 | } else { |
| 341 | 349 | Err(FussrError::Git(git2::Error::from_str("Failed to pull"))) |
| 342 | 350 | } |
| 343 | 351 | } |
| 344 | 352 | |
| 345 | - /// Push to remote | |
| 353 | + /// Push to remote (captures output to not corrupt TUI) | |
| 346 | 354 | pub fn push(&self) -> Result<()> { |
| 347 | - let status = Command::new("git") | |
| 355 | + let output = Command::new("git") | |
| 348 | 356 | .args(["push"]) |
| 349 | - .status()?; | |
| 357 | + .stdout(std::process::Stdio::piped()) | |
| 358 | + .stderr(std::process::Stdio::piped()) | |
| 359 | + .output()?; | |
| 350 | 360 | |
| 351 | - if status.success() { | |
| 361 | + if output.status.success() { | |
| 352 | 362 | Ok(()) |
| 353 | 363 | } else { |
| 354 | 364 | Err(FussrError::Git(git2::Error::from_str("Failed to push"))) |
src/main.rsmodified@@ -106,6 +106,9 @@ fn run_event_loop( | ||
| 106 | 106 | |
| 107 | 107 | /// Handle keys in navigation mode |
| 108 | 108 | fn handle_navigation_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> Result<()> { |
| 109 | + // Check search timeout on every key | |
| 110 | + app.check_search_timeout(); | |
| 111 | + | |
| 109 | 112 | // Check for Alt+key combinations |
| 110 | 113 | if modifiers.contains(KeyModifiers::ALT) { |
| 111 | 114 | match code { |
@@ -116,13 +119,54 @@ fn handle_navigation_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) | ||
| 116 | 119 | return Ok(()); |
| 117 | 120 | } |
| 118 | 121 | |
| 122 | + // In normal mode, handle fuzzy search for printable characters | |
| 123 | + if app.mode == AppMode::Normal { | |
| 124 | + match code { | |
| 125 | + // Fuzzy search - letters, numbers, and common filename chars | |
| 126 | + KeyCode::Char(c) if c.is_ascii_alphanumeric() || c == '_' || c == '-' => { | |
| 127 | + app.search_add_char(c); | |
| 128 | + return Ok(()); | |
| 129 | + } | |
| 130 | + // Backspace removes from search buffer | |
| 131 | + KeyCode::Backspace => { | |
| 132 | + if !app.search_buffer.is_empty() { | |
| 133 | + app.search_backspace(); | |
| 134 | + return Ok(()); | |
| 135 | + } | |
| 136 | + } | |
| 137 | + // ESC clears search buffer in normal mode | |
| 138 | + KeyCode::Esc => { | |
| 139 | + if !app.search_buffer.is_empty() { | |
| 140 | + app.clear_search(); | |
| 141 | + return Ok(()); | |
| 142 | + } | |
| 143 | + } | |
| 144 | + _ => {} | |
| 145 | + } | |
| 146 | + } | |
| 147 | + | |
| 119 | 148 | match code { |
| 120 | - // Navigation | |
| 121 | - KeyCode::Char('j') | KeyCode::Down => app.navigate_down(), | |
| 122 | - KeyCode::Char('k') | KeyCode::Up => app.navigate_up(), | |
| 123 | - KeyCode::Left => app.navigate_left(), | |
| 124 | - KeyCode::Right => app.navigate_right(), | |
| 125 | - KeyCode::Char(' ') => app.toggle_selected(), | |
| 149 | + // Navigation (clears search buffer) | |
| 150 | + KeyCode::Char('j') | KeyCode::Down => { | |
| 151 | + app.clear_search(); | |
| 152 | + app.navigate_down(); | |
| 153 | + } | |
| 154 | + KeyCode::Char('k') | KeyCode::Up => { | |
| 155 | + app.clear_search(); | |
| 156 | + app.navigate_up(); | |
| 157 | + } | |
| 158 | + KeyCode::Left => { | |
| 159 | + app.clear_search(); | |
| 160 | + app.navigate_left(); | |
| 161 | + } | |
| 162 | + KeyCode::Right => { | |
| 163 | + app.clear_search(); | |
| 164 | + app.navigate_right(); | |
| 165 | + } | |
| 166 | + KeyCode::Char(' ') => { | |
| 167 | + app.clear_search(); | |
| 168 | + app.toggle_selected(); | |
| 169 | + } | |
| 126 | 170 | KeyCode::Char('.') => app.toggle_dotfiles(), |
| 127 | 171 | |
| 128 | 172 | // Mode switching |
@@ -153,13 +197,22 @@ fn handle_navigation_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) | ||
| 153 | 197 | app.delete_selected()?; |
| 154 | 198 | } |
| 155 | 199 | KeyCode::Char('f') if app.mode == AppMode::Git => { |
| 156 | - app.fetch()?; | |
| 200 | + match app.fetch() { | |
| 201 | + Ok(()) => {} | |
| 202 | + Err(e) => app.set_status(format!("Fetch failed: {}", e)), | |
| 203 | + } | |
| 157 | 204 | } |
| 158 | 205 | KeyCode::Char('l') if app.mode == AppMode::Git => { |
| 159 | - app.pull()?; | |
| 206 | + match app.pull() { | |
| 207 | + Ok(()) => {} | |
| 208 | + Err(e) => app.set_status(format!("Pull failed: {}", e)), | |
| 209 | + } | |
| 160 | 210 | } |
| 161 | 211 | KeyCode::Char('p') if app.mode == AppMode::Git => { |
| 162 | - app.push()?; | |
| 212 | + match app.push() { | |
| 213 | + Ok(()) => {} | |
| 214 | + Err(e) => app.set_status(format!("Push failed: {}", e)), | |
| 215 | + } | |
| 163 | 216 | } |
| 164 | 217 | KeyCode::Char('m') if app.mode == AppMode::Git => { |
| 165 | 218 | app.enter_commit_mode(false); |
@@ -214,71 +267,78 @@ fn handle_rename_key(app: &mut App, code: KeyCode) -> Result<()> { | ||
| 214 | 267 | |
| 215 | 268 | /// Handle keys in commit mode |
| 216 | 269 | fn handle_commit_key(app: &mut App, code: KeyCode) -> Result<()> { |
| 217 | - match code { | |
| 218 | - KeyCode::Esc => app.cancel_commit(), | |
| 219 | - KeyCode::Enter => app.apply_commit()?, | |
| 220 | - KeyCode::Backspace => { | |
| 221 | - if let InputMode::Commit { buffer, cursor, .. } = &mut app.input_mode { | |
| 222 | - if *cursor > 0 { | |
| 223 | - buffer.remove(*cursor - 1); | |
| 224 | - *cursor -= 1; | |
| 270 | + use crate::types::CommitStatus; | |
| 271 | + | |
| 272 | + // Get current status | |
| 273 | + let status = if let InputMode::Commit { status, .. } = &app.input_mode { | |
| 274 | + status.clone() | |
| 275 | + } else { | |
| 276 | + return Ok(()); | |
| 277 | + }; | |
| 278 | + | |
| 279 | + match status { | |
| 280 | + CommitStatus::Editing => { | |
| 281 | + // Normal editing mode | |
| 282 | + match code { | |
| 283 | + KeyCode::Esc => app.cancel_commit(), | |
| 284 | + KeyCode::Enter => app.apply_commit()?, | |
| 285 | + KeyCode::Backspace => { | |
| 286 | + if let InputMode::Commit { buffer, cursor, .. } = &mut app.input_mode { | |
| 287 | + if *cursor > 0 { | |
| 288 | + buffer.remove(*cursor - 1); | |
| 289 | + *cursor -= 1; | |
| 290 | + } | |
| 291 | + } | |
| 225 | 292 | } |
| 226 | - } | |
| 227 | - } | |
| 228 | - KeyCode::Left => { | |
| 229 | - if let InputMode::Commit { cursor, .. } = &mut app.input_mode { | |
| 230 | - if *cursor > 0 { | |
| 231 | - *cursor -= 1; | |
| 293 | + KeyCode::Left => { | |
| 294 | + if let InputMode::Commit { cursor, .. } = &mut app.input_mode { | |
| 295 | + if *cursor > 0 { | |
| 296 | + *cursor -= 1; | |
| 297 | + } | |
| 298 | + } | |
| 232 | 299 | } |
| 233 | - } | |
| 234 | - } | |
| 235 | - KeyCode::Right => { | |
| 236 | - if let InputMode::Commit { buffer, cursor, .. } = &mut app.input_mode { | |
| 237 | - if *cursor < buffer.len() { | |
| 238 | - *cursor += 1; | |
| 300 | + KeyCode::Right => { | |
| 301 | + if let InputMode::Commit { buffer, cursor, .. } = &mut app.input_mode { | |
| 302 | + if *cursor < buffer.len() { | |
| 303 | + *cursor += 1; | |
| 304 | + } | |
| 305 | + } | |
| 306 | + } | |
| 307 | + KeyCode::Char(c) => { | |
| 308 | + if let InputMode::Commit { buffer, cursor, .. } = &mut app.input_mode { | |
| 309 | + buffer.insert(*cursor, c); | |
| 310 | + *cursor += 1; | |
| 311 | + } | |
| 239 | 312 | } |
| 313 | + _ => {} | |
| 240 | 314 | } |
| 241 | 315 | } |
| 242 | - KeyCode::Char(c) => { | |
| 243 | - if let InputMode::Commit { buffer, cursor, .. } = &mut app.input_mode { | |
| 244 | - buffer.insert(*cursor, c); | |
| 245 | - *cursor += 1; | |
| 246 | - } | |
| 316 | + CommitStatus::Committing => { | |
| 317 | + // Don't respond to keys while committing | |
| 318 | + } | |
| 319 | + CommitStatus::Success | CommitStatus::Failed => { | |
| 320 | + // Any key closes the modal | |
| 321 | + app.close_commit(); | |
| 247 | 322 | } |
| 248 | - _ => {} | |
| 249 | 323 | } |
| 250 | 324 | Ok(()) |
| 251 | 325 | } |
| 252 | 326 | |
| 253 | -/// Handle keys in search mode | |
| 327 | +/// Handle keys in search mode (legacy - not currently used) | |
| 254 | 328 | fn handle_search_key(app: &mut App, code: KeyCode) -> Result<()> { |
| 255 | 329 | match code { |
| 256 | 330 | KeyCode::Esc => { |
| 257 | 331 | app.input_mode = InputMode::Navigation; |
| 258 | 332 | } |
| 259 | 333 | KeyCode::Backspace => { |
| 260 | - // Extract, modify, and reassign to avoid borrow conflicts | |
| 261 | 334 | if let InputMode::Search { buffer } = &mut app.input_mode { |
| 262 | 335 | buffer.pop(); |
| 263 | 336 | } |
| 264 | - // Now do fuzzy jump with cloned buffer | |
| 265 | - if let InputMode::Search { buffer } = &app.input_mode { | |
| 266 | - if !buffer.is_empty() { | |
| 267 | - let pattern = buffer.clone(); | |
| 268 | - app.fuzzy_jump(&pattern); | |
| 269 | - } | |
| 270 | - } | |
| 271 | 337 | } |
| 272 | 338 | KeyCode::Char(c) => { |
| 273 | - // Extract, modify, and reassign to avoid borrow conflicts | |
| 274 | 339 | if let InputMode::Search { buffer } = &mut app.input_mode { |
| 275 | 340 | buffer.push(c); |
| 276 | 341 | } |
| 277 | - // Now do fuzzy jump with cloned buffer | |
| 278 | - if let InputMode::Search { buffer } = &app.input_mode { | |
| 279 | - let pattern = buffer.clone(); | |
| 280 | - app.fuzzy_jump(&pattern); | |
| 281 | - } | |
| 282 | 342 | } |
| 283 | 343 | _ => {} |
| 284 | 344 | } |
src/types.rsmodified@@ -171,13 +171,22 @@ impl AppMode { | ||
| 171 | 171 | } |
| 172 | 172 | } |
| 173 | 173 | |
| 174 | +/// Status of commit operation | |
| 175 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 176 | +pub enum CommitStatus { | |
| 177 | + Editing, | |
| 178 | + Committing, | |
| 179 | + Success, | |
| 180 | + Failed, | |
| 181 | +} | |
| 182 | + | |
| 174 | 183 | /// Input mode for special states |
| 175 | 184 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 176 | 185 | pub enum InputMode { |
| 177 | 186 | Navigation, |
| 178 | 187 | Rename { buffer: String, cursor: usize }, |
| 179 | 188 | Search { buffer: String }, |
| 180 | - Commit { buffer: String, cursor: usize, amend: bool }, | |
| 189 | + Commit { buffer: String, cursor: usize, amend: bool, status: CommitStatus }, | |
| 181 | 190 | Confirm { message: String, action: ConfirmAction }, |
| 182 | 191 | } |
| 183 | 192 | |
src/ui.rsmodified@@ -1,5 +1,5 @@ | ||
| 1 | 1 | use crate::app::App; |
| 2 | -use crate::types::{AppMode, InputMode, SelectableItem}; | |
| 2 | +use crate::types::{AppMode, CommitStatus, InputMode, SelectableItem}; | |
| 3 | 3 | use ratatui::{ |
| 4 | 4 | layout::{Constraint, Direction, Layout, Rect}, |
| 5 | 5 | style::{Color, Modifier, Style}, |
@@ -24,13 +24,13 @@ pub fn draw(frame: &mut Frame, app: &App) { | ||
| 24 | 24 | draw_help(frame, app, chunks[2]); |
| 25 | 25 | |
| 26 | 26 | // Draw modal overlay if in commit mode |
| 27 | - if let InputMode::Commit { buffer, cursor, amend } = &app.input_mode { | |
| 28 | - draw_commit_modal(frame, buffer, *cursor, *amend); | |
| 27 | + if let InputMode::Commit { buffer, cursor, amend, status } = &app.input_mode { | |
| 28 | + draw_commit_modal(frame, buffer, *cursor, *amend, status); | |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | 32 | /// Draw commit message modal |
| 33 | -fn draw_commit_modal(frame: &mut Frame, buffer: &str, cursor: usize, amend: bool) { | |
| 33 | +fn draw_commit_modal(frame: &mut Frame, buffer: &str, cursor: usize, amend: bool, status: &CommitStatus) { | |
| 34 | 34 | let area = frame.area(); |
| 35 | 35 | |
| 36 | 36 | // Center the modal |
@@ -44,27 +44,66 @@ fn draw_commit_modal(frame: &mut Frame, buffer: &str, cursor: usize, amend: bool | ||
| 44 | 44 | // Clear area behind modal |
| 45 | 45 | frame.render_widget(Clear, modal_area); |
| 46 | 46 | |
| 47 | - // Title based on amend | |
| 48 | - let title = if amend { " Amend Commit " } else { " Commit " }; | |
| 47 | + // Title and border color based on status | |
| 48 | + let (title, border_color) = match status { | |
| 49 | + CommitStatus::Editing => { | |
| 50 | + let t = if amend { " Amend Commit " } else { " Commit " }; | |
| 51 | + (t, Color::Green) | |
| 52 | + } | |
| 53 | + CommitStatus::Committing => (" Committing... ", Color::Yellow), | |
| 54 | + CommitStatus::Success => (" ✓ Committed ", Color::Green), | |
| 55 | + CommitStatus::Failed => (" ✗ Failed ", Color::Red), | |
| 56 | + }; | |
| 49 | 57 | |
| 50 | 58 | let block = Block::default() |
| 51 | 59 | .title(title) |
| 52 | 60 | .borders(Borders::ALL) |
| 53 | - .border_style(Style::default().fg(Color::Green)); | |
| 61 | + .border_style(Style::default().fg(border_color)); | |
| 54 | 62 | |
| 55 | - // Build input line with cursor | |
| 56 | - let display_text = if cursor >= buffer.len() { | |
| 57 | - format!("{}█", buffer) | |
| 58 | - } else { | |
| 59 | - format!("{}█{}", &buffer[..cursor], &buffer[cursor..]) | |
| 63 | + // Content based on status | |
| 64 | + let content = match status { | |
| 65 | + CommitStatus::Editing => { | |
| 66 | + // Build input line with cursor | |
| 67 | + let display_text = if cursor >= buffer.len() { | |
| 68 | + format!("{}█", buffer) | |
| 69 | + } else { | |
| 70 | + format!("{}█{}", &buffer[..cursor], &buffer[cursor..]) | |
| 71 | + }; | |
| 72 | + vec![ | |
| 73 | + Line::from(""), | |
| 74 | + Line::from(Span::raw(display_text)), | |
| 75 | + ] | |
| 76 | + } | |
| 77 | + CommitStatus::Committing => { | |
| 78 | + vec![ | |
| 79 | + Line::from(""), | |
| 80 | + Line::from(Span::styled( | |
| 81 | + " Committing changes...", | |
| 82 | + Style::default().fg(Color::Yellow), | |
| 83 | + )), | |
| 84 | + ] | |
| 85 | + } | |
| 86 | + CommitStatus::Success => { | |
| 87 | + vec![ | |
| 88 | + Line::from(""), | |
| 89 | + Line::from(Span::styled( | |
| 90 | + " ✓ Changes committed successfully!", | |
| 91 | + Style::default().fg(Color::Green), | |
| 92 | + )), | |
| 93 | + ] | |
| 94 | + } | |
| 95 | + CommitStatus::Failed => { | |
| 96 | + vec![ | |
| 97 | + Line::from(""), | |
| 98 | + Line::from(Span::styled( | |
| 99 | + " ✗ Commit failed (nothing staged?)", | |
| 100 | + Style::default().fg(Color::Red), | |
| 101 | + )), | |
| 102 | + ] | |
| 103 | + } | |
| 60 | 104 | }; |
| 61 | 105 | |
| 62 | - let input = Paragraph::new(vec![ | |
| 63 | - Line::from(""), | |
| 64 | - Line::from(Span::raw(display_text)), | |
| 65 | - ]) | |
| 66 | - .block(block); | |
| 67 | - | |
| 106 | + let input = Paragraph::new(content).block(block); | |
| 68 | 107 | frame.render_widget(input, modal_area); |
| 69 | 108 | } |
| 70 | 109 | |
@@ -85,6 +124,15 @@ fn draw_header(frame: &mut Frame, app: &App, area: Rect) { | ||
| 85 | 124 | )); |
| 86 | 125 | } |
| 87 | 126 | |
| 127 | + // Show search buffer if active | |
| 128 | + if !app.search_buffer.is_empty() { | |
| 129 | + spans.push(Span::raw(" ")); | |
| 130 | + spans.push(Span::styled( | |
| 131 | + format!("/{}", &app.search_buffer), | |
| 132 | + Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), | |
| 133 | + )); | |
| 134 | + } | |
| 135 | + | |
| 88 | 136 | // Show status message if any |
| 89 | 137 | if let Some(ref msg) = app.status_message { |
| 90 | 138 | spans.push(Span::raw(" - ")); |