|
1 | 1 | use anyhow::Result; |
2 | 2 | use colored::*; |
3 | 3 |
|
4 | | -use crate::constants::{section_header, WARNING_NO_WORKTREES}; |
| 4 | +use crate::constants::{ |
| 5 | + section_header, CURRENT_MARKER, ICON_CURRENT_WORKTREE, ICON_OTHER_WORKTREE, MODIFIED_STATUS_NO, |
| 6 | + MODIFIED_STATUS_YES, TABLE_HEADER_BRANCH, TABLE_HEADER_MODIFIED, TABLE_HEADER_NAME, |
| 7 | + TABLE_HEADER_PATH, TABLE_SEPARATOR, WARNING_NO_WORKTREES, |
| 8 | +}; |
5 | 9 | use crate::git::{GitWorktreeManager, WorktreeInfo}; |
| 10 | +use crate::repository_info::get_repository_info; |
6 | 11 | use crate::ui::{DialoguerUI, UserInterface}; |
7 | 12 | use crate::utils::press_any_key_to_continue; |
8 | 13 |
|
@@ -92,56 +97,98 @@ pub fn list_worktrees() -> Result<()> { |
92 | 97 | pub fn list_worktrees_with_ui(manager: &GitWorktreeManager, _ui: &dyn UserInterface) -> Result<()> { |
93 | 98 | let worktrees = manager.list_worktrees()?; |
94 | 99 |
|
95 | | - println!(); |
96 | | - let header = section_header("Worktrees"); |
97 | | - println!("{header}"); |
98 | | - println!(); |
99 | | - |
100 | 100 | if worktrees.is_empty() { |
| 101 | + println!(); |
101 | 102 | let msg = WARNING_NO_WORKTREES.yellow(); |
102 | 103 | println!("{msg}"); |
103 | 104 | println!(); |
104 | 105 | press_any_key_to_continue()?; |
105 | 106 | return Ok(()); |
106 | 107 | } |
107 | 108 |
|
108 | | - // Print table header |
| 109 | + // Sort worktrees: current first, then alphabetically |
| 110 | + let mut sorted_worktrees = worktrees; |
| 111 | + sorted_worktrees.sort_by(|a, b| { |
| 112 | + if a.is_current && !b.is_current { |
| 113 | + std::cmp::Ordering::Less |
| 114 | + } else if !a.is_current && b.is_current { |
| 115 | + std::cmp::Ordering::Greater |
| 116 | + } else { |
| 117 | + a.name.cmp(&b.name) |
| 118 | + } |
| 119 | + }); |
| 120 | + |
| 121 | + // Print header |
| 122 | + println!(); |
| 123 | + let header = section_header("Worktrees"); |
| 124 | + println!("{header}"); |
| 125 | + println!(); |
| 126 | + |
| 127 | + // Display repository info |
| 128 | + let repo_info = get_repository_info(); |
| 129 | + println!("Repository: {}", repo_info.bright_cyan()); |
| 130 | + println!(); |
| 131 | + |
| 132 | + // Calculate column widths |
| 133 | + let max_name_len = sorted_worktrees |
| 134 | + .iter() |
| 135 | + .map(|w| w.name.len()) |
| 136 | + .max() |
| 137 | + .unwrap_or(0) |
| 138 | + .max(10); |
| 139 | + let max_branch_len = sorted_worktrees |
| 140 | + .iter() |
| 141 | + .map(|w| w.branch.len()) |
| 142 | + .max() |
| 143 | + .unwrap_or(0) |
| 144 | + .max(10) |
| 145 | + + 10; // Extra space for [current] marker |
| 146 | + |
| 147 | + println!(); |
109 | 148 | println!( |
110 | | - " {:<27} {:<37} {} {}", |
111 | | - "Name".bold(), |
112 | | - "Branch".bold(), |
113 | | - "Modified".bold(), |
114 | | - "Path".bold() |
| 149 | + " {:<name_width$} {:<branch_width$} {:<8} {}", |
| 150 | + TABLE_HEADER_NAME.bold(), |
| 151 | + TABLE_HEADER_BRANCH.bold(), |
| 152 | + TABLE_HEADER_MODIFIED.bold(), |
| 153 | + TABLE_HEADER_PATH.bold(), |
| 154 | + name_width = max_name_len, |
| 155 | + branch_width = max_branch_len |
115 | 156 | ); |
116 | 157 | println!( |
117 | | - " {} {} {} {}", |
118 | | - "-".repeat(27).dimmed(), |
119 | | - "-".repeat(37).dimmed(), |
120 | | - "-".repeat(8).dimmed(), |
121 | | - "-".repeat(40).dimmed() |
| 158 | + " {TABLE_SEPARATOR:-<max_name_len$} {TABLE_SEPARATOR:-<max_branch_len$} {TABLE_SEPARATOR:-<8} {TABLE_SEPARATOR:-<40}" |
122 | 159 | ); |
123 | 160 |
|
124 | 161 | // Display worktrees in table format |
125 | | - for worktree in &worktrees { |
126 | | - let current_indicator = if worktree.is_current { "▸ " } else { " " }; |
127 | | - |
128 | | - let name = if worktree.is_current { |
129 | | - worktree.name.bright_white().bold().to_string() |
| 162 | + for worktree in &sorted_worktrees { |
| 163 | + let icon = if worktree.is_current { |
| 164 | + ICON_CURRENT_WORKTREE.bright_green().bold() |
130 | 165 | } else { |
131 | | - worktree.name.clone() |
| 166 | + ICON_OTHER_WORKTREE.bright_blue() |
| 167 | + }; |
| 168 | + let branch_display = if worktree.is_current { |
| 169 | + format!("{} {}", worktree.branch, CURRENT_MARKER).bright_green() |
| 170 | + } else { |
| 171 | + worktree.branch.yellow() |
| 172 | + }; |
| 173 | + let modified = if worktree.has_changes { |
| 174 | + MODIFIED_STATUS_YES.bright_yellow() |
| 175 | + } else { |
| 176 | + MODIFIED_STATUS_NO.bright_black() |
132 | 177 | }; |
133 | | - |
134 | | - let branch = &worktree.branch; |
135 | | - let modified = if worktree.has_changes { "Yes" } else { "No" }; |
136 | | - let path = worktree.path.display(); |
137 | 178 |
|
138 | 179 | println!( |
139 | | - "{}{:<27} {:<37} {:<8} {}", |
140 | | - current_indicator.green(), |
141 | | - name, |
142 | | - branch.yellow(), |
| 180 | + "{} {:<name_width$} {:<branch_width$} {:<8} {}", |
| 181 | + icon, |
| 182 | + if worktree.is_current { |
| 183 | + worktree.name.bright_green().bold() |
| 184 | + } else { |
| 185 | + worktree.name.normal() |
| 186 | + }, |
| 187 | + branch_display, |
143 | 188 | modified, |
144 | | - path.to_string().dimmed() |
| 189 | + worktree.path.display().to_string().dimmed(), |
| 190 | + name_width = max_name_len, |
| 191 | + branch_width = max_branch_len |
145 | 192 | ); |
146 | 193 | } |
147 | 194 |
|
@@ -408,4 +455,223 @@ mod tests { |
408 | 455 | Some(no_match_filter) |
409 | 456 | )); |
410 | 457 | } |
| 458 | + |
| 459 | + // Tests to protect the current table display implementation |
| 460 | + #[test] |
| 461 | + fn test_table_display_current_worktree_first() { |
| 462 | + // Create test worktrees with one being current |
| 463 | + let worktree1 = WorktreeInfo { |
| 464 | + name: "zebra".to_string(), |
| 465 | + path: PathBuf::from("/tmp/zebra"), |
| 466 | + branch: "zebra".to_string(), |
| 467 | + is_current: false, |
| 468 | + has_changes: false, |
| 469 | + last_commit: None, |
| 470 | + ahead_behind: None, |
| 471 | + is_locked: false, |
| 472 | + }; |
| 473 | + let worktree2 = WorktreeInfo { |
| 474 | + name: "alpha".to_string(), |
| 475 | + path: PathBuf::from("/tmp/alpha"), |
| 476 | + branch: "alpha".to_string(), |
| 477 | + is_current: true, |
| 478 | + has_changes: false, |
| 479 | + last_commit: None, |
| 480 | + ahead_behind: None, |
| 481 | + is_locked: false, |
| 482 | + }; |
| 483 | + let worktree3 = WorktreeInfo { |
| 484 | + name: "beta".to_string(), |
| 485 | + path: PathBuf::from("/tmp/beta"), |
| 486 | + branch: "beta".to_string(), |
| 487 | + is_current: false, |
| 488 | + has_changes: false, |
| 489 | + last_commit: None, |
| 490 | + ahead_behind: None, |
| 491 | + is_locked: false, |
| 492 | + }; |
| 493 | + |
| 494 | + let mut worktrees = vec![worktree1, worktree2, worktree3]; |
| 495 | + |
| 496 | + // Apply the same sorting logic as the main function |
| 497 | + worktrees.sort_by(|a, b| { |
| 498 | + if a.is_current && !b.is_current { |
| 499 | + std::cmp::Ordering::Less |
| 500 | + } else if !a.is_current && b.is_current { |
| 501 | + std::cmp::Ordering::Greater |
| 502 | + } else { |
| 503 | + a.name.cmp(&b.name) |
| 504 | + } |
| 505 | + }); |
| 506 | + |
| 507 | + // Current worktree should be first |
| 508 | + assert_eq!(worktrees[0].name, "alpha"); |
| 509 | + assert!(worktrees[0].is_current); |
| 510 | + // Others should be alphabetically sorted |
| 511 | + assert_eq!(worktrees[1].name, "beta"); |
| 512 | + assert_eq!(worktrees[2].name, "zebra"); |
| 513 | + } |
| 514 | + |
| 515 | + #[test] |
| 516 | + fn test_table_display_column_width_calculation() { |
| 517 | + let worktrees = vec![ |
| 518 | + WorktreeInfo { |
| 519 | + name: "short".to_string(), |
| 520 | + path: PathBuf::from("/tmp/short"), |
| 521 | + branch: "main".to_string(), |
| 522 | + is_current: false, |
| 523 | + has_changes: false, |
| 524 | + last_commit: None, |
| 525 | + ahead_behind: None, |
| 526 | + is_locked: false, |
| 527 | + }, |
| 528 | + WorktreeInfo { |
| 529 | + name: "very-long-worktree-name".to_string(), |
| 530 | + path: PathBuf::from("/tmp/very-long-worktree-name"), |
| 531 | + branch: "feature-with-very-long-branch-name".to_string(), |
| 532 | + is_current: true, |
| 533 | + has_changes: false, |
| 534 | + last_commit: None, |
| 535 | + ahead_behind: None, |
| 536 | + is_locked: false, |
| 537 | + }, |
| 538 | + ]; |
| 539 | + |
| 540 | + let max_name_len = worktrees |
| 541 | + .iter() |
| 542 | + .map(|w| w.name.len()) |
| 543 | + .max() |
| 544 | + .unwrap_or(0) |
| 545 | + .max(10); |
| 546 | + let max_branch_len = worktrees |
| 547 | + .iter() |
| 548 | + .map(|w| w.branch.len()) |
| 549 | + .max() |
| 550 | + .unwrap_or(0) |
| 551 | + .max(10) |
| 552 | + + 10; // Extra space for [current] marker |
| 553 | + |
| 554 | + assert_eq!(max_name_len, "very-long-worktree-name".len()); |
| 555 | + assert_eq!( |
| 556 | + max_branch_len, |
| 557 | + "feature-with-very-long-branch-name".len() + 10 |
| 558 | + ); |
| 559 | + } |
| 560 | + |
| 561 | + #[test] |
| 562 | + fn test_table_display_icon_selection() { |
| 563 | + let current_worktree = WorktreeInfo { |
| 564 | + name: "current".to_string(), |
| 565 | + path: PathBuf::from("/tmp/current"), |
| 566 | + branch: "main".to_string(), |
| 567 | + is_current: true, |
| 568 | + has_changes: false, |
| 569 | + last_commit: None, |
| 570 | + ahead_behind: None, |
| 571 | + is_locked: false, |
| 572 | + }; |
| 573 | + let other_worktree = WorktreeInfo { |
| 574 | + name: "other".to_string(), |
| 575 | + path: PathBuf::from("/tmp/other"), |
| 576 | + branch: "feature".to_string(), |
| 577 | + is_current: false, |
| 578 | + has_changes: false, |
| 579 | + last_commit: None, |
| 580 | + ahead_behind: None, |
| 581 | + is_locked: false, |
| 582 | + }; |
| 583 | + |
| 584 | + // Test icon selection logic |
| 585 | + let current_icon = if current_worktree.is_current { |
| 586 | + "▸" |
| 587 | + } else { |
| 588 | + " " |
| 589 | + }; |
| 590 | + let other_icon = if other_worktree.is_current { |
| 591 | + "▸" |
| 592 | + } else { |
| 593 | + " " |
| 594 | + }; |
| 595 | + |
| 596 | + assert_eq!(current_icon, "▸"); |
| 597 | + assert_eq!(other_icon, " "); |
| 598 | + } |
| 599 | + |
| 600 | + #[test] |
| 601 | + fn test_table_display_branch_formatting() { |
| 602 | + let current_worktree = WorktreeInfo { |
| 603 | + name: "current".to_string(), |
| 604 | + path: PathBuf::from("/tmp/current"), |
| 605 | + branch: "main".to_string(), |
| 606 | + is_current: true, |
| 607 | + has_changes: false, |
| 608 | + last_commit: None, |
| 609 | + ahead_behind: None, |
| 610 | + is_locked: false, |
| 611 | + }; |
| 612 | + let other_worktree = WorktreeInfo { |
| 613 | + name: "other".to_string(), |
| 614 | + path: PathBuf::from("/tmp/other"), |
| 615 | + branch: "feature".to_string(), |
| 616 | + is_current: false, |
| 617 | + has_changes: false, |
| 618 | + last_commit: None, |
| 619 | + ahead_behind: None, |
| 620 | + is_locked: false, |
| 621 | + }; |
| 622 | + |
| 623 | + // Test branch display formatting |
| 624 | + let current_branch_display = if current_worktree.is_current { |
| 625 | + format!("{} [current]", current_worktree.branch) |
| 626 | + } else { |
| 627 | + current_worktree.branch.clone() |
| 628 | + }; |
| 629 | + let other_branch_display = if other_worktree.is_current { |
| 630 | + format!("{} [current]", other_worktree.branch) |
| 631 | + } else { |
| 632 | + other_worktree.branch.clone() |
| 633 | + }; |
| 634 | + |
| 635 | + assert_eq!(current_branch_display, "main [current]"); |
| 636 | + assert_eq!(other_branch_display, "feature"); |
| 637 | + } |
| 638 | + |
| 639 | + #[test] |
| 640 | + fn test_table_display_modified_status() { |
| 641 | + let clean_worktree = WorktreeInfo { |
| 642 | + name: "clean".to_string(), |
| 643 | + path: PathBuf::from("/tmp/clean"), |
| 644 | + branch: "main".to_string(), |
| 645 | + is_current: false, |
| 646 | + has_changes: false, |
| 647 | + last_commit: None, |
| 648 | + ahead_behind: None, |
| 649 | + is_locked: false, |
| 650 | + }; |
| 651 | + let dirty_worktree = WorktreeInfo { |
| 652 | + name: "dirty".to_string(), |
| 653 | + path: PathBuf::from("/tmp/dirty"), |
| 654 | + branch: "feature".to_string(), |
| 655 | + is_current: false, |
| 656 | + has_changes: true, |
| 657 | + last_commit: None, |
| 658 | + ahead_behind: None, |
| 659 | + is_locked: false, |
| 660 | + }; |
| 661 | + |
| 662 | + // Test modified status display |
| 663 | + let clean_modified = if clean_worktree.has_changes { |
| 664 | + "Yes" |
| 665 | + } else { |
| 666 | + "No" |
| 667 | + }; |
| 668 | + let dirty_modified = if dirty_worktree.has_changes { |
| 669 | + "Yes" |
| 670 | + } else { |
| 671 | + "No" |
| 672 | + }; |
| 673 | + |
| 674 | + assert_eq!(clean_modified, "No"); |
| 675 | + assert_eq!(dirty_modified, "Yes"); |
| 676 | + } |
411 | 677 | } |
0 commit comments