1mod app;
2#[doc(hidden)]
8pub mod commit_graph;
9pub mod keymap;
10pub mod modal_keymap;
11pub mod palette;
12pub mod state;
13pub mod theme;
14mod ui;
15pub mod wt_tree;
16
17use crate::error::Result;
18use crate::tui::keymap::Action;
19use crate::tui::modal_keymap::{KeyContext, ModalAction};
20use crossterm::{
21 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers},
22 execute,
23 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
24};
25use ratatui::{backend::CrosstermBackend, Terminal};
26use std::io;
27use std::path::{Path, PathBuf};
28use std::time::{Duration, Instant};
29
30pub use app::{
31 App, CreateKey, ExecPickerKey, LauncherPlan, LinkPromptKey, LinkPromptStage, LinkTarget, OpenTarget, View,
32};
33pub use state::async_task::{CreateWorktreeResult, TaskKind, TaskMsg, TaskRunner};
34pub use state::clean_overlay::CleanOverlay;
35pub use state::command_logs::CommandLogs;
36pub use state::config_panel::{
37 build_key_rows, ConfigPanel, FieldKind, KeyCapture, KeyRow, KeyTarget, SettingField, SettingsLayer, SettingsTab,
38};
39pub use state::confirm::{ConfirmButton, ConfirmKeyAction, ConfirmModal, CountdownTickOutcome};
40pub use state::create_form::{CreateForm, Field};
41pub use state::exec_picker::ExecPicker;
42pub use state::filter::FilterState;
43pub use state::github_fetch::{FetchKey, GitHubFetch, GitHubFetchState};
44pub use state::link_prompt::LinkPrompt;
45pub use state::pty_overlay::{key_to_bytes, PtyKind, PtyOverlay};
46pub use state::sidebar::SidebarState;
47
48pub fn clipboard_candidates() -> Vec<(&'static str, Vec<&'static str>)> {
55 if cfg!(target_os = "macos") {
56 vec![("pbcopy", vec![])]
57 } else if cfg!(target_os = "windows") {
58 vec![("clip", vec![])]
59 } else {
60 vec![
61 ("wl-copy", vec![]),
62 ("xclip", vec!["-selection", "clipboard"]),
63 ("xsel", vec!["--clipboard", "--input"]),
64 ]
65 }
66}
67pub use ui::{
68 author_initials, badge_group_width, bootstrap_report_lines, branch_name_color, branch_status_color,
69 build_sidebar_sections, centered_abs, chip_style, ci_indicator, clean_dir_icon, command_logs_footer_hints,
70 config_capture_footer_hints, config_edit_footer_hints, config_nav_footer_hints, confirm_buttons_line,
71 confirm_delete_branch_line, confirm_detail_line, create_buttons_line, delete_worktree_title, ellipsize_middle,
72 field_input_line, filled_cells_for_progress, footer_line, format_status, freshness_color, github_status_lines,
73 header_line, help_body_section_color, help_entry_line, help_label_style, help_lines, help_rows, help_section_style,
74 hint_key_style, hint_label_style, issue_badge_color, issue_pr_pane_title, issue_summary_line, link_open_modal_lines,
75 link_prompt_modal_width, link_target_keys, link_target_line, modal_hint_line, overlay_modal_width,
76 palette_name_style, pane_counter, panel_border_color, picker_window, pr_badge_color, pr_summary_line,
77 recent_commits_lines, recent_items_pane_title, reclaim_size_color, rename_buttons_line, status_line,
78 status_pane_title, table_marker, tilde_compress_with_home, type_selector_line, working_tree_counts_footer,
79 working_tree_pane_title, working_tree_status_counts, working_tree_status_line, worktree_name_style,
80 worktree_path_style, worktrees_pane_title, HelpRow, HintContext, SidebarSections, WorkingTreeCounts,
81 COMMIT_HASH_DISPLAY_LEN, ISSUE_ICON, PR_ICON, RECENT_COMMITS_LIMIT, WT_CREATED_ICON, WT_DELETED_ICON,
82 WT_MODIFIED_ICON,
83};
84
85#[doc(hidden)]
92pub use ui::draw;
93
94pub fn run(trust_mode: crate::trust::TrustMode) -> Result<()> {
95 let app = App::new()?.with_trust_mode(trust_mode);
106 let mut terminal = enter_terminal()?;
107 let result = run_app(&mut terminal, app);
108 leave_terminal(&mut terminal)?;
109 if let Some(path) = result? {
112 println!("{}", path.display());
113 }
114 Ok(())
115}
116
117pub fn run_workspace(root: &Path, trust_mode: crate::trust::TrustMode) -> Result<()> {
123 let app =
124 App::new_workspace_at_layered(root, crate::config::global_config_path().as_deref())?.with_trust_mode(trust_mode);
125 let mut terminal = enter_terminal()?;
126 let result = run_app(&mut terminal, app);
127 leave_terminal(&mut terminal)?;
128 if let Some(path) = result? {
129 println!("{}", path.display());
130 }
131 Ok(())
132}
133
134pub fn run_picker() -> Result<Option<PathBuf>> {
140 let app = App::new_picker_at(None)?;
144 let mut terminal = enter_terminal()?;
145 let result = run_app(&mut terminal, app);
146 leave_terminal(&mut terminal)?;
147 result
148}
149
150fn enter_terminal() -> Result<Terminal<CrosstermBackend<io::Stderr>>> {
154 enable_raw_mode()?;
155 let mut stderr = io::stderr();
160 execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
161 Ok(Terminal::new(CrosstermBackend::new(stderr))?)
162}
163
164fn leave_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>) -> Result<()> {
167 disable_raw_mode()?;
168 execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
169 terminal.show_cursor()?;
170 Ok(())
171}
172
173fn confirm_fire(app: &mut App) {
179 if app.is_delete_worktree_loading() {
180 app.status = TaskKind::DeleteWorktree.loading_label().into();
181 return;
182 }
183 match app.confirm_press_y(Instant::now()) {
184 ConfirmKeyAction::FireNow => {
185 if let Err(e) = app.confirm_delete() {
186 app.status = format!("delete failed: {}", e);
187 }
188 }
189 ConfirmKeyAction::Armed | ConfirmKeyAction::Disarmed => {}
192 }
193}
194
195fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>, mut app: App) -> Result<Option<PathBuf>> {
196 loop {
197 let now = Instant::now();
198 app.drain_task_results();
205 if app.is_github_loading() || app.is_task_loading() {
206 app.spinner.tick();
207 }
208
209 if app.should_quit {
210 if app.can_quit_now() {
211 break;
212 }
213 app.defer_quit_for_mutating_task();
214 }
215
216 if app.view == View::CommandLogs {
221 app.command_logs.sync();
222 }
223 if !app.destructive_overlay_open() {
227 app.maybe_auto_refresh(now);
228 }
229
230 if app.view == View::Pty {
235 let status = app.pty_overlay.as_mut().map(|p| {
236 p.poll_bytes();
237 (p.kind, p.is_alive())
238 });
239 match status {
240 Some((PtyKind::Exec, false)) => {
246 if let Some(p) = app.pty_overlay.as_mut() {
247 p.mark_finished();
248 }
249 }
250 Some((_, false)) | None => app.close_pty_overlay(),
252 Some((_, true)) => {}
253 }
254 }
255
256 if !app.destructive_overlay_open() {
263 app.sync_active_repo();
264 }
265
266 terminal.draw(|f| ui::draw(f, &mut app))?;
267
268 if app.view == View::Confirm {
275 if app.confirm.is_armed() {
280 app.spinner.tick();
281 }
282 match app.tick_confirm_countdown(now) {
283 CountdownTickOutcome::ReadyToFire => {
284 if let Err(e) = app.confirm_delete() {
285 app.status = format!("delete failed: {}", e);
286 }
287 }
288 CountdownTickOutcome::Pending | CountdownTickOutcome::NotArmed => {}
289 }
290 }
291
292 if app.view == View::CleanReport {
296 if app.clean_overlay.confirm.is_armed() {
297 app.spinner.tick();
298 }
299 match app.tick_clean_countdown(now) {
300 CountdownTickOutcome::ReadyToFire => app.clean_overlay_delete(),
301 CountdownTickOutcome::Pending | CountdownTickOutcome::NotArmed => {}
302 }
303 }
304
305 let poll_ms = if app.view == View::Pty { 50 } else { 200 };
309 if !event::poll(Duration::from_millis(poll_ms))? {
310 continue;
311 }
312 let ev = event::read()?;
313 if let Event::Resize(cols, rows) = ev {
316 if app.view == View::Pty {
317 if let Some(ref mut pty) = app.pty_overlay {
318 let inner_cols = ((cols as u32 * 90 / 100) as u16).saturating_sub(6).max(10);
320 let inner_rows = ((rows as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
321 pty.resize(inner_cols, inner_rows);
322 }
323 }
324 terminal.clear()?;
325 continue;
326 }
327 let Event::Key(key) = ev else { continue };
328 if key.kind != KeyEventKind::Press {
329 continue;
330 }
331
332 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
334 if app.view == View::Pty {
337 if let Some(ref mut pty) = app.pty_overlay {
338 let _ = pty.write_key(key);
339 }
340 continue;
341 }
342 app.should_quit = true;
343 if app.can_quit_now() {
344 break;
345 }
346 app.defer_quit_for_mutating_task();
347 continue;
348 }
349 if app.should_quit {
350 continue;
351 }
352
353 match app.view {
354 View::List if app.filter.active => match key.code {
358 KeyCode::Esc => {
359 if app.picker_mode {
364 app.picker_cancel();
365 } else {
366 app.exit_filter_cancel();
367 }
368 }
369 KeyCode::Enter => {
370 app.exit_filter_keep();
374 if app.picker_mode {
375 app.picker_confirm();
376 }
377 }
378 KeyCode::Backspace => app.filter_pop_char(),
379 KeyCode::Char(c) => app.filter_push_char(c),
380 _ => {}
381 },
382 View::List => {
383 if key.code == KeyCode::Esc {
391 app.cancel_pending_motion();
392 if !app.filter.query().is_empty() {
393 app.exit_filter_cancel();
394 } else {
395 app.should_quit = true;
396 }
397 } else if key.code == KeyCode::Enter {
398 app.cancel_pending_motion();
399 if app.picker_mode {
400 app.picker_confirm();
401 } else {
402 app.copy_path_to_status();
403 }
404 } else if let Some(action) = app.dispatch_key(key) {
405 if matches!(action, Action::Quit) {
410 app.should_quit = true;
411 } else {
412 run_action(terminal, &mut app, action)?;
413 }
414 }
415 }
416 View::Help => match app.resolve_modal(KeyContext::Help, key) {
419 Some(ModalAction::HelpClose) => app.view = View::List,
420 Some(ModalAction::HelpScrollDown) => app.help_scroll_down(),
421 Some(ModalAction::HelpScrollUp) => app.help_scroll_up(),
422 Some(ModalAction::HelpScrollRight) => app.help_scroll_right(),
423 Some(ModalAction::HelpScrollLeft) => app.help_scroll_left(),
424 Some(ModalAction::HelpScrollTop) => app.help_scroll = 0,
425 Some(ModalAction::HelpScrollBottom) => app.help_scroll = app.help_max_scroll,
426 _ => {}
427 },
428 View::CommandLogs => match app.resolve_modal(KeyContext::CommandLogs, key) {
434 Some(ModalAction::CommandLogsClose) => app.view = View::List,
435 Some(ModalAction::CommandLogsCopy) => copy_command_logs_to_clipboard(&mut app),
437 Some(ModalAction::CommandLogsScrollDown) => app.command_logs.scroll_down(),
438 Some(ModalAction::CommandLogsScrollUp) => app.command_logs.scroll_up(),
439 Some(ModalAction::CommandLogsScrollRight) => app.command_logs.scroll_right(),
440 Some(ModalAction::CommandLogsScrollLeft) => app.command_logs.scroll_left(),
441 Some(ModalAction::CommandLogsScrollTop) => app.command_logs.scroll_to_top(),
442 Some(ModalAction::CommandLogsScrollBottom) => app.command_logs.scroll_to_bottom(),
443 _ if app.key_matches_action(key, Action::CommandLogs) => app.view = View::List,
444 _ => {}
445 },
446 View::Config if app.config_panel.capture.is_some() => app.handle_capture_key(key),
465 View::Config if app.config_panel.editing.is_some() => match app.resolve_modal(KeyContext::ConfigEdit, key) {
466 Some(ModalAction::ConfigEditSubmit) => app.commit_settings_edit(),
467 Some(ModalAction::ConfigEditCancel) => app.config_panel.cancel_edit(),
468 _ => match key.code {
469 KeyCode::Backspace => app.config_panel.pop_edit_char(),
470 KeyCode::Char(c) => app.config_panel.push_edit_char(c),
471 _ => {}
472 },
473 },
474 View::Config => {
479 let on_all = app.config_panel.tab == SettingsTab::All;
480 match app.resolve_modal(KeyContext::Config, key) {
481 Some(ModalAction::ConfigClose) => app.view = View::List,
482 Some(ModalAction::ConfigNextTab) => app.config_panel.next_tab(),
483 Some(ModalAction::ConfigPrevTab) => app.config_panel.prev_tab(),
484 Some(ModalAction::ConfigToggleLayer) => app.config_panel.toggle_layer(),
485 Some(ModalAction::ConfigActivate) => {
489 if app.config_panel.tab == SettingsTab::Keys {
490 app.config_panel.begin_capture();
491 } else {
492 app.activate_selected_setting();
493 }
494 }
495 Some(ModalAction::ConfigSelectNext) => {
496 if on_all {
497 app.config_panel.scroll_down();
498 } else {
499 app.config_panel.select_next();
500 }
501 }
502 Some(ModalAction::ConfigSelectPrev) => {
503 if on_all {
504 app.config_panel.scroll_up();
505 } else {
506 app.config_panel.select_prev();
507 }
508 }
509 Some(ModalAction::ConfigScrollRight) if on_all => app.config_panel.scroll_right(),
510 Some(ModalAction::ConfigScrollLeft) if on_all => app.config_panel.scroll_left(),
511 Some(ModalAction::ConfigScrollTop) if on_all => app.config_panel.scroll_to_top(),
512 Some(ModalAction::ConfigScrollBottom) if on_all => app.config_panel.scroll_to_bottom(),
513 _ if app.key_matches_action(key, Action::ConfigPanel) => app.view = View::List,
514 _ => {}
515 }
516 }
517 View::Create if app.is_create_worktree_loading() => {}
522 View::Create => match app.handle_create_key(key) {
523 CreateKey::Submit => {
524 if let Err(e) = app.submit_create() {
525 app.status = format!("error: {}", e);
526 }
527 }
528 CreateKey::Cancel => app.view = View::List,
529 CreateKey::Handled => {}
530 },
531 View::Confirm if app.is_delete_worktree_loading() => {}
532 View::Confirm => match app.resolve_modal(KeyContext::Confirm, key) {
540 Some(ModalAction::ConfirmConfirm) => confirm_fire(&mut app),
541 Some(ModalAction::ConfirmActivate) => match app.confirm.focused_button() {
542 ConfirmButton::Confirm => confirm_fire(&mut app),
543 ConfirmButton::Cancel => app.confirm_dismiss(),
544 },
545 Some(ModalAction::ConfirmCancel) => app.confirm_dismiss(),
546 Some(ModalAction::ConfirmFocusConfirm) => app.confirm.focus_confirm(),
547 Some(ModalAction::ConfirmFocusCancel) => app.confirm.focus_cancel(),
548 Some(ModalAction::ConfirmToggleFocus) => app.confirm.toggle_focus(),
549 _ if app.key_matches_action(key, Action::ToggleDeleteBranch) => app.toggle_delete_branch(),
550 _ => {}
551 },
552 View::Report => {
555 if let Some(ModalAction::ReportClose) = app.resolve_modal(KeyContext::Report, key) {
556 app.view = View::List;
557 app.refresh()?;
558 }
559 }
560 View::OpenMenu => match app.resolve_modal(KeyContext::OpenMenu, key) {
563 Some(ModalAction::OpenMenuClose) => app.exit_open_menu(),
564 Some(ModalAction::OpenMenuToggle) => app.open_menu_toggle_selection(),
565 Some(ModalAction::OpenMenuAccept) => {
566 if let Some(url) = app.open_menu_pick(app.open_menu_selected) {
567 open_url(&url, &mut app);
568 }
569 }
570 Some(ModalAction::OpenMenuIssue) => {
571 if let Some(url) = app.open_menu_pick(LinkTarget::Issue) {
572 open_url(&url, &mut app);
573 }
574 }
575 Some(ModalAction::OpenMenuPr) => {
576 if let Some(url) = app.open_menu_pick(LinkTarget::Pr) {
577 open_url(&url, &mut app);
578 }
579 }
580 _ if app.key_matches_action(key, Action::FetchGithub) => app.refresh_github_status(),
581 _ => {}
582 },
583 View::LinkPrompt => match app.handle_link_prompt_key(key) {
586 LinkPromptKey::Submit => {
587 if let Err(e) = app.link_prompt_submit() {
588 app.status = format!("link failed: {}", e);
589 }
590 }
591 LinkPromptKey::Refresh => app.refresh_github_status(),
592 LinkPromptKey::Cancel => app.link_prompt_cancel(),
593 LinkPromptKey::Handled => {}
594 },
595 View::Pty => {
607 let exec_finished = app.pty_overlay.as_ref().is_some_and(|p| p.finished);
612 if key.code == KeyCode::Esc || exec_finished {
613 app.close_pty_overlay();
614 } else if let Some(ref mut pty) = app.pty_overlay {
615 let _ = pty.write_key(key);
616 }
617 }
618 View::ExecPicker => match app.handle_exec_picker_key(key) {
622 ExecPickerKey::Submit => {
623 if let Some((argv, cwd)) = app.exec_picker_resolve() {
624 let sz = terminal.size().unwrap_or_default();
625 let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
626 let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
627 let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
628 match PtyOverlay::spawn(PtyKind::Exec, &argv_refs, &cwd, inner_cols, inner_rows) {
629 Ok(pty) => app.open_pty_overlay(pty),
630 Err(e) => {
631 app.status = format!("exec overlay failed: {}", e);
632 app.close_exec_picker();
633 }
634 }
635 } else {
636 app.close_exec_picker();
638 }
639 }
640 ExecPickerKey::Cancel => app.close_exec_picker(),
641 ExecPickerKey::Handled => {}
642 },
643 View::CleanReport => match app.resolve_modal(KeyContext::Clean, key) {
648 Some(ModalAction::CleanCancel) => app.close_clean_overlay(),
649 Some(ModalAction::CleanConfirm) => {
650 if app.clean_confirm_press(now) == ConfirmKeyAction::FireNow {
651 app.clean_overlay_delete();
652 }
653 }
654 Some(ModalAction::CleanNext) => app.clean_overlay_next(),
655 Some(ModalAction::CleanPrev) => app.clean_overlay_prev(),
656 _ => {}
657 },
658 View::Edit if app.is_edit_worktree_loading() => {}
662 View::Edit => match app.handle_create_key(key) {
663 CreateKey::Submit => {
664 if let Err(e) = app.submit_edit_worktree() {
665 app.status = format!("rename failed: {}", e);
666 }
667 }
668 CreateKey::Cancel => app.cancel_edit_worktree(),
669 CreateKey::Handled => {}
670 },
671 View::CommandPalette => match app.resolve_modal(KeyContext::CommandPalette, key) {
685 Some(ModalAction::CommandPaletteClose) => app.close_command_palette(),
686 Some(ModalAction::CommandPaletteAccept) => {
687 if let Some(action) = app.accept_command_palette() {
688 run_palette_action(terminal, &mut app, action)?;
689 }
690 }
691 Some(ModalAction::CommandPalettePrev) => app.palette_cycle_up(),
692 Some(ModalAction::CommandPaletteNext) => app.palette_cycle_down(),
693 _ => match key.code {
694 KeyCode::Backspace => app.palette_pop_char(),
695 KeyCode::Char(c) if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' => {
696 app.palette_push_char(c);
697 }
698 KeyCode::Char(_) => {}
704 _ => {}
705 },
706 },
707 }
708
709 if app.picker_should_exit {
715 break;
716 }
717 if app.should_quit {
721 if app.can_quit_now() {
722 break;
723 }
724 app.defer_quit_for_mutating_task();
725 }
726 }
727 Ok(app.should_exit_to.or(app.picker_result))
729}
730
731fn run_action(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>, app: &mut App, action: Action) -> Result<()> {
760 if app.workspace_active_stale && action.is_repo_mutating() {
766 app.status = "workspace: selected repo is unavailable (moved/deleted?) — press r to refresh".into();
767 return Ok(());
768 }
769
770 match action {
771 Action::Quit => app.should_quit = true,
774 Action::Down => app.next(),
775 Action::Up => app.prev(),
776 Action::Top => app.first(),
777 Action::Bottom => app.last(),
778 Action::ToggleSidebar => app.toggle_sidebar(),
779 Action::ToggleSidebarMode => app.cycle_sidebar_mode(),
783 Action::CycleSidebarLayout => app.cycle_sidebar_layout(),
786 Action::ToggleSidebarPosition => app.toggle_sidebar_position(),
787 Action::FocusSwap => app.toggle_focus(),
788 Action::FocusWorktrees => app.focus_worktrees(),
789 Action::FocusStatus => app.focus_status(),
790 Action::Filter => app.enter_filter(),
791 Action::Refresh => app.request_refresh(),
795 Action::Help => app.enter_help(),
796 Action::YankPath => yank_selected_path_to_clipboard(app),
798 Action::YankBranchName => yank_selected_branch_to_clipboard(app),
800 Action::YankWorktreeName => yank_selected_worktree_name_to_clipboard(app),
802 Action::TerminalFullscreen => match app.resolve_open_target() {
805 None => app.status = "nothing selected".into(),
806 Some(OpenTarget::Finder { .. }) => app.open_selected_in_finder(),
807 Some(OpenTarget::Shell { path, command }) => run_subshell(terminal, &command, &[], Some(&path), app, "shell")?,
808 Some(OpenTarget::Editor { path, command }) => {
809 let path_str = path.display().to_string();
810 run_subshell(terminal, &command, &[&path_str], None, app, "editor")?
811 }
812 },
813 Action::TerminalPty => {
816 let cwd = app.selected().map(|wt| wt.path.clone());
817 match cwd {
818 None => app.status = "nothing selected".into(),
819 Some(path) => {
820 #[cfg(windows)]
821 let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into());
822 #[cfg(not(windows))]
823 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
824 let sz = terminal.size().unwrap_or_default();
825 let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
826 let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
827 match PtyOverlay::spawn(PtyKind::Terminal, &[shell.as_str()], &path, inner_cols, inner_rows) {
828 Ok(pty) => app.open_pty_overlay(pty),
829 Err(e) => app.status = format!("terminal overlay failed: {}", e),
830 }
831 }
832 }
833 }
834 Action::LazyGitFullscreen => {
836 if let Some(plan) = app.prepare_git_tui() {
837 run_launcher(terminal, plan, app)?;
838 }
839 }
840 Action::LazyGitPty => {
843 if let Some(plan) = app.prepare_git_tui() {
844 let sz = terminal.size().unwrap_or_default();
845 let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
846 let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
847 let argv: Vec<String> = plan.expanded.argv.clone();
848 let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
849 match PtyOverlay::spawn(PtyKind::LazyGit, &argv_refs, &plan.cwd, inner_cols, inner_rows) {
850 Ok(pty) => app.open_pty_overlay(pty),
851 Err(e) => app.status = format!("lazygit overlay failed: {}", e),
852 }
853 }
854 }
855 Action::ReviewPty if !app.picker_mode => {
859 if let Some(mut plan) = app.prepare_review() {
860 let sz = terminal.size().unwrap_or_default();
861 let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
862 let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
863 let argv: Vec<String> = plan.expanded.argv.clone();
864 let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
865 match PtyOverlay::spawn(PtyKind::Review, &argv_refs, &plan.cwd, inner_cols, inner_rows) {
866 Ok(mut pty) => {
867 pty.diff_file = plan.expanded.diff_file.take();
868 app.open_pty_overlay(pty);
869 }
870 Err(e) => app.status = format!("review overlay failed: {}", e),
871 }
872 }
873 }
874 Action::Create if !app.picker_mode => app.enter_create(),
875 Action::DeleteConfirm if !app.picker_mode => app.enter_confirm_delete(),
876 Action::Bootstrap if !app.picker_mode => app.bootstrap_selected(),
877 Action::Sync if !app.picker_mode => app.request_sync(),
879 Action::Pull if !app.picker_mode => app.request_pull(),
881 Action::Push if !app.picker_mode => app.request_push(),
882 Action::EditWorktree if !app.picker_mode => app.enter_edit_worktree(),
884 Action::ExitToWorktree => app.exit_to_worktree(),
886 Action::MuxPane if !app.picker_mode => app.open_in_mux_pane(),
888 Action::Macro1 if !app.picker_mode => run_macro(terminal, app, 1)?,
890 Action::Macro2 if !app.picker_mode => run_macro(terminal, app, 2)?,
891 Action::ToggleDeleteBranch if !app.picker_mode => app.toggle_delete_branch(),
892 Action::BrowseLinks if !app.picker_mode => app.enter_open_menu(),
894 Action::OpenDocs => open_url(DOCS_URL, app),
896 Action::LinkPrompt if !app.picker_mode => app.enter_link_prompt(),
897 Action::FetchGithub if !app.picker_mode => app.refresh_github_status(),
898 Action::ReviewFullscreen if !app.picker_mode => {
900 if let Some(plan) = app.prepare_review() {
901 run_launcher(terminal, plan, app)?;
902 }
903 }
904 Action::CommandPalette => app.open_command_palette(),
911 Action::CommandLogs => app.enter_command_logs(),
915 Action::ConfigPanel => app.enter_config_panel(),
919 Action::ExecOverlay if !app.picker_mode => app.enter_exec_picker(),
923 Action::CleanOverlay if !app.picker_mode => app.enter_clean_overlay(),
926 _ => {}
931 }
932 Ok(())
933}
934
935fn run_palette_action(
941 terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
942 app: &mut App,
943 action: Action,
944) -> Result<()> {
945 run_action(terminal, app, action)
946}
947
948pub fn wants_child_stdout_on_tty(stdout_is_terminal: bool) -> bool {
960 !stdout_is_terminal
961}
962
963fn route_fullscreen_child_stdout(command: &mut std::process::Command) {
969 use std::io::IsTerminal;
970 if !wants_child_stdout_on_tty(std::io::stdout().is_terminal()) {
971 return;
972 }
973 #[cfg(unix)]
974 if let Ok(tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") {
975 command.stdout(std::process::Stdio::from(tty));
976 }
977 #[cfg(not(unix))]
981 let _ = command;
982}
983
984fn run_launcher(
985 terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
986 plan: app::LauncherPlan,
987 app: &mut App,
988) -> Result<()> {
989 use std::process::{Command, Stdio};
990
991 let argv = plan.expanded.argv.clone();
992 let Some((bin, rest)) = argv.split_first() else {
993 app.status = "launcher template produced an empty argv".into();
994 return Ok(());
995 };
996
997 if which::which(bin).is_err() {
1000 app.status = format!(
1001 "`{}` not on $PATH — install it or change [review]/[git_tui] in .gwm.toml",
1002 bin
1003 );
1004 return Ok(());
1005 }
1006
1007 if plan.fullscreen {
1008 disable_raw_mode()?;
1009 execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
1010 terminal.show_cursor()?;
1011
1012 let mut cmd = Command::new(bin);
1013 cmd.args(rest).current_dir(&plan.cwd);
1014 route_fullscreen_child_stdout(&mut cmd);
1015 let spawn = cmd.status();
1016
1017 enable_raw_mode()?;
1018 execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture)?;
1019 terminal.clear()?;
1020
1021 match spawn {
1022 Ok(s) if s.success() => app.status = format!("{} exited ok", bin),
1023 Ok(s) => app.status = format!("{} exited with code {:?}", bin, s.code()),
1024 Err(e) => app.status = format!("failed to launch {}: {}", bin, e),
1025 }
1026 } else {
1027 let out = Command::new(bin)
1031 .args(rest)
1032 .current_dir(&plan.cwd)
1033 .stdout(Stdio::null())
1034 .stderr(Stdio::piped())
1035 .output();
1036 match out {
1037 Ok(o) if o.status.success() => app.status = format!("{} done", bin),
1038 Ok(o) => {
1039 let first = String::from_utf8_lossy(&o.stderr)
1040 .lines()
1041 .next()
1042 .unwrap_or_default()
1043 .trim()
1044 .to_string();
1045 app.status = if first.is_empty() {
1046 format!("{} exited with code {:?}", bin, o.status.code())
1047 } else {
1048 format!("{}: {}", bin, first)
1049 };
1050 }
1051 Err(e) => app.status = format!("failed to launch {}: {}", bin, e),
1052 }
1053 }
1054 drop(plan);
1056 Ok(())
1057}
1058
1059fn run_subshell(
1067 terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
1068 cmd: &str,
1069 args: &[&str],
1070 cwd: Option<&std::path::Path>,
1071 app: &mut App,
1072 label: &str,
1073) -> Result<()> {
1074 disable_raw_mode()?;
1075 execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
1076 terminal.show_cursor()?;
1077
1078 let mut command = std::process::Command::new(cmd);
1079 command.args(args);
1080 if let Some(dir) = cwd {
1081 command.current_dir(dir);
1082 }
1083 route_fullscreen_child_stdout(&mut command);
1084 let spawn = command.status();
1085
1086 enable_raw_mode()?;
1088 execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture)?;
1089 terminal.clear()?;
1090
1091 match spawn {
1092 Ok(s) if s.success() => app.status = format!("{} exited ok ({})", label, cmd),
1093 Ok(s) => app.status = format!("{} exited with code {:?}", label, s.code()),
1094 Err(e) => app.status = format!("failed to launch {} ({}): {}", label, cmd, e),
1095 }
1096 Ok(())
1097}
1098
1099fn yank_selected_path_to_clipboard(app: &mut App) {
1105 let Some(path) = app.yank_selected_path() else {
1106 app.status = "nothing selected".into();
1107 return;
1108 };
1109 let text = path.display().to_string();
1110 copy_text_to_clipboard(app, &text, "yanked path");
1111}
1112
1113fn yank_selected_branch_to_clipboard(app: &mut App) {
1114 let Some(branch) = app.yank_selected_branch() else {
1115 app.status = "nothing selected or no branch (detached HEAD)".into();
1116 return;
1117 };
1118 copy_text_to_clipboard(app, &branch, "yanked branch name");
1119}
1120
1121fn yank_selected_worktree_name_to_clipboard(app: &mut App) {
1122 let Some(name) = app.yank_selected_worktree_name() else {
1123 app.status = "nothing selected".into();
1124 return;
1125 };
1126 copy_text_to_clipboard(app, &name, "yanked worktree name");
1127}
1128
1129fn run_macro(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>, app: &mut App, n: u8) -> Result<()> {
1132 use crate::config::MacroOpenMode;
1133 let cfg = if n == 1 {
1134 app.config.tui.macro1.clone()
1135 } else {
1136 app.config.tui.macro2.clone()
1137 };
1138 let Some(macro_cfg) = cfg else {
1139 app.status = format!("macro{} not configured — add [tui.macro{}] to .gwm.toml", n, n);
1140 return Ok(());
1141 };
1142 use crate::multiplexer::{build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, SpawnMode};
1143 let Some(path) = app.selected().map(|w| w.path.clone()) else {
1147 app.status = format!("macro{}: nothing selected", n);
1148 return Ok(());
1149 };
1150
1151 #[cfg(windows)]
1152 let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into());
1153 #[cfg(not(windows))]
1154 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
1155 let shell_flag = if cfg!(windows) { "/C" } else { "-c" };
1156
1157 let mux_cmd = if matches!(macro_cfg.open_in, MacroOpenMode::MuxPane) {
1161 let label = format!("macro{}", n);
1162 if detect_tmux(std::env::var("TMUX").ok()) {
1163 Some(build_tmux_command(&label, &path, SpawnMode::Split))
1164 } else if detect_zellij(std::env::var("ZELLIJ").ok()) {
1165 Some(build_zellij_command(&label, &path, SpawnMode::Split))
1166 } else {
1167 app.status = format!("macro{}: no multiplexer — falling back to PTY overlay", n);
1168 None
1169 }
1170 } else {
1171 None
1172 };
1173
1174 if let Some(cmd) = mux_cmd {
1175 let bin = cmd[0].as_str();
1176 let mut full_cmd: Vec<&str> = cmd[1..].iter().map(String::as_str).collect();
1177 if bin == "zellij" {
1178 full_cmd.push("--");
1182 full_cmd.push(shell.as_str());
1183 full_cmd.push(shell_flag);
1184 full_cmd.push(macro_cfg.command.as_str());
1185 } else {
1186 full_cmd.push(macro_cfg.command.as_str());
1190 }
1191 match std::process::Command::new(bin).args(&full_cmd).spawn() {
1192 Ok(_) => app.status = format!("macro{} opened in mux pane", n),
1193 Err(e) => app.status = format!("macro{} mux failed: {}", n, e),
1194 }
1195 } else {
1196 let sz = terminal.size().unwrap_or_default();
1198 let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
1199 let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
1200 let argv = [shell.as_str(), shell_flag, macro_cfg.command.as_str()];
1201 match PtyOverlay::spawn(PtyKind::Terminal, &argv, &path, inner_cols, inner_rows) {
1202 Ok(pty) => app.open_pty_overlay(pty),
1203 Err(e) => app.status = format!("macro{} overlay failed: {}", n, e),
1204 }
1205 }
1206 Ok(())
1207}
1208
1209fn copy_command_logs_to_clipboard(app: &mut App) {
1213 let text = app.command_logs_transcript();
1214 if text.is_empty() {
1215 app.status = "no commands to copy".into();
1216 return;
1217 }
1218 copy_text_to_clipboard(app, &text, "copied command logs");
1219}
1220
1221fn copy_text_to_clipboard(app: &mut App, text: &str, success: &str) {
1228 use std::io::Write;
1229 for (cmd, args) in clipboard_candidates() {
1230 if which::which(cmd).is_err() {
1231 continue;
1232 }
1233 let child = std::process::Command::new(cmd)
1234 .args(&args)
1235 .stdin(std::process::Stdio::piped())
1236 .stdout(std::process::Stdio::null())
1237 .stderr(std::process::Stdio::null())
1238 .spawn();
1239 match child {
1240 Ok(mut c) => {
1241 if let Some(mut stdin) = c.stdin.take() {
1242 let _ = stdin.write_all(text.as_bytes());
1243 }
1244 match c.wait() {
1245 Ok(s) if s.success() => {
1246 app.status = format!("{} ({})", success, cmd);
1247 return;
1248 }
1249 Ok(s) => {
1250 app.status = format!("{} exited with code {:?}", cmd, s.code());
1251 return;
1252 }
1253 Err(e) => {
1254 app.status = format!("{} wait failed: {}", cmd, e);
1255 return;
1256 }
1257 }
1258 }
1259 Err(e) => {
1260 app.status = format!("failed to spawn {}: {}", cmd, e);
1263 return;
1264 }
1265 }
1266 }
1267 app.status = "y: no clipboard tool found (install pbcopy / wl-copy / xclip / xsel / clip)".into();
1268}
1269
1270pub const DOCS_URL: &str = concat!(env!("CARGO_PKG_REPOSITORY"), "/tree/main/docs");
1277
1278fn open_url(url: &str, app: &mut App) {
1282 let opener = if cfg!(target_os = "macos") {
1283 "open"
1284 } else if cfg!(target_os = "windows") {
1285 "explorer"
1286 } else {
1287 "xdg-open"
1288 };
1289 match std::process::Command::new(opener).arg(url).spawn() {
1290 Ok(_) => app.status = format!("opened {}", url),
1291 Err(e) => app.status = format!("failed to open {}: {}", url, e),
1292 }
1293}