use std::io::stderr;
use std::time::Duration;
use crossterm::{
event::{Event, KeyCode, KeyModifiers},
terminal::{disable_raw_mode, enable_raw_mode},
};
use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
use crate::cli::GoArgs;
use crate::error::Result;
use crate::git::{get_worktrees_with_details, STATUS_LEGEND};
use crate::shell::cwd_file::{try_write_cwd_file, CwdWriteResult};
use crate::ui::event::{is_cancel_key, poll_event};
use crate::ui::widgets::{SelectListWidget, SelectState};
use crate::ui::{SelectItem, SelectItemMetadata, TextInputState};
use crate::utils::open_in_editor;
const TUI_GO_INLINE_HEIGHT: u16 = 32;
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
if let Err(e) = disable_raw_mode() {
eprintln!("\x1b[33m Warning: Failed to restore terminal: {}\x1b[0m", e);
}
}
}
pub fn run_go(args: GoArgs) -> Result<()> {
let worktrees = get_worktrees_with_details()?;
if worktrees.is_empty() {
eprintln!("No worktrees found.");
return Ok(());
}
let items: Vec<SelectItem> = worktrees
.iter()
.map(|wt| SelectItem {
label: format!("{} {}", wt.status.bracketed_icon(), wt.display_branch()),
value: wt.path.display().to_string(),
description: Some(wt.path.display().to_string()),
metadata: Some(SelectItemMetadata {
last_commit_date: wt.commit_date.clone().unwrap_or_default(),
last_committer_name: wt.committer_name.clone().unwrap_or_default(),
last_commit_message: wt.commit_message.clone().unwrap_or_default(),
sync_status: wt.sync_status.clone(),
change_status: wt.change_status.clone(),
}),
})
.collect();
if let Some(ref query) = args.query {
let query_lower = query.to_lowercase();
let exact_match = items
.iter()
.find(|item| extract_branch_name(&item.label).to_lowercase() == query_lower);
if let Some(item) = exact_match {
return handle_selection(item, &args);
}
let matches: Vec<_> = items
.iter()
.filter(|item| item.label.to_lowercase().contains(&query_lower))
.collect();
if matches.len() == 1 {
return handle_selection(matches[0], &args);
}
if matches.is_empty() {
eprintln!("No worktree matching '{}' found.", query);
return Ok(());
}
}
let output_path_only = args.should_output_path_only();
let selected = run_go_tui(&items, args.query.as_deref(), output_path_only)?;
if let Some(item) = selected {
handle_selection(&item, &args)?;
}
Ok(())
}
fn handle_selection(item: &SelectItem, args: &GoArgs) -> Result<()> {
let path = std::path::Path::new(&item.value);
if let Some(editor) = args.editor() {
open_in_editor(editor, path)?;
println!(
"\x1b[32m✓\x1b[0m Opened {} in {}",
item.label,
editor.display_name()
);
std::thread::sleep(std::time::Duration::from_millis(500));
} else {
match try_write_cwd_file(path) {
Ok(CwdWriteResult::Written) => {}
Ok(CwdWriteResult::EnvNotSet) => println!("{}", item.value),
Err(e) => {
eprintln!("\x1b[33m Warning: Failed to write cwd file: {}\x1b[0m", e);
println!("{}", item.value);
}
}
}
Ok(())
}
fn run_go_tui(
items: &[SelectItem],
initial_query: Option<&str>,
output_path_only: bool,
) -> Result<Option<SelectItem>> {
enable_raw_mode()?;
let _guard = TerminalGuard;
let backend = CrosstermBackend::new(stderr());
let options = TerminalOptions {
viewport: Viewport::Inline(TUI_GO_INLINE_HEIGHT),
};
let mut terminal = Terminal::with_options(backend, options)?;
let mut input = match initial_query {
Some(query) => TextInputState::with_value(query.to_string()),
None => TextInputState::new(),
};
let mut state = SelectState::new(items.to_vec()).with_max_display(8);
if !input.value.is_empty() {
state.update_filter(&input.value);
}
let result = loop {
terminal.draw(|frame| {
let area = frame.area();
let widget = SelectListWidget::with_state(
"Go to worktree",
"Search worktrees...",
&input,
&state,
None,
)
.with_legend(STATUS_LEGEND);
frame.render_widget(widget, area);
})?;
if let Some(Event::Key(key)) = poll_event(Duration::from_millis(100))? {
if is_cancel_key(&key) {
break None;
}
match (key.modifiers, key.code) {
(_, KeyCode::Enter) => {
if let Some(item) = state.selected_item() {
break Some(item.clone());
}
}
(_, KeyCode::Up) | (KeyModifiers::CONTROL, KeyCode::Char('p')) => {
state.move_up();
}
(_, KeyCode::Down) | (KeyModifiers::CONTROL, KeyCode::Char('n')) => {
state.move_down();
}
(KeyModifiers::CONTROL, KeyCode::Char('u')) => {
input.clear();
state.update_filter(&input.value);
}
(KeyModifiers::CONTROL, KeyCode::Char('w'))
| (KeyModifiers::ALT, KeyCode::Backspace) => {
input.delete_word_backward();
state.update_filter(&input.value);
}
(_, KeyCode::Backspace) => {
input.delete_backward();
state.update_filter(&input.value);
}
(_, KeyCode::Delete) => {
input.delete_forward();
state.update_filter(&input.value);
}
(_, KeyCode::Left) | (KeyModifiers::CONTROL, KeyCode::Char('b')) => {
input.move_left();
}
(_, KeyCode::Right) | (KeyModifiers::CONTROL, KeyCode::Char('f')) => {
input.move_right();
}
(KeyModifiers::CONTROL, KeyCode::Char('a')) | (_, KeyCode::Home) => {
input.move_start();
}
(KeyModifiers::CONTROL, KeyCode::Char('e')) | (_, KeyCode::End) => {
input.move_end();
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => {
input.insert(c);
state.update_filter(&input.value);
}
_ => {}
}
}
};
drop(_guard);
if output_path_only {
eprintln!();
} else {
println!();
}
Ok(result)
}
fn extract_branch_name(label: &str) -> &str {
if label.len() > 4 && label.starts_with('[') && label.chars().nth(2) == Some(']') {
label.get(4..).unwrap_or(label)
} else {
label
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_branch_name() {
assert_eq!(extract_branch_name("[*] main"), "main");
assert_eq!(extract_branch_name("[M] develop"), "develop");
assert_eq!(extract_branch_name("[-] feature/test"), "feature/test");
assert_eq!(extract_branch_name("plain-label"), "plain-label");
assert_eq!(extract_branch_name(""), "");
assert_eq!(extract_branch_name("[ab"), "[ab");
}
#[test]
fn test_extract_branch_name_unicode() {
assert_eq!(extract_branch_name("[*] feat/日本語"), "feat/日本語");
assert_eq!(extract_branch_name("[M] 機能/テスト"), "機能/テスト");
assert_eq!(extract_branch_name("[-] fix/émoji-🚀"), "fix/émoji-🚀");
}
}