use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use alacritty_terminal::event::Event as TermEvent;
use alacritty_terminal::grid::Scroll;
use alacritty_terminal::selection::SelectionType;
use alacritty_terminal::term::TermMode;
use anyhow::Result;
use crossterm::event::{
Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use futures::StreamExt;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::widgets::{Block, Borders};
use ratatui::{Frame, Terminal};
use tokio::sync::mpsc;
use super::actions::{self, ActionFlow, ActionKind, CheckReport, Dispatcher, Target};
use super::glyph::GlyphMode;
use super::hub::{HubCommand, ViewModelHandle};
use super::keys::{self, ChromeKey, KeyRoute};
use super::menu;
use super::mouse::{self, DragOrigin, Hit, RegionMap, TreeClick, WheelRoute};
use super::panes;
use super::session_layout;
use super::terminal::{GridSize, TabEffect, TabId, TabKind, TerminalTab};
use super::tree::TreeState;
use super::view_model::WorktreesViewModel;
use super::{clipboard, popup, render};
use crate::sessions::relocate::{self, RelocationMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum Focus {
#[default]
Tree,
Terminal,
}
const FRAME_INTERVAL: Duration = Duration::from_millis(16);
struct App {
tree: TreeState,
flow: ActionFlow,
menu: Option<popup::ActionMenu>,
relocate: Option<RelocateStep>,
focus: Focus,
panes: panes::PaneLayout,
quit_confirm: bool,
quit_requested: bool,
notice: Option<String>,
pty_tx: mpsc::UnboundedSender<(TabId, TermEvent)>,
commands: mpsc::UnboundedSender<HubCommand>,
glyphs: GlyphMode,
prompt: Option<Prompt>,
reported_visible: Vec<PathBuf>,
regions: RegionMap,
terminal_area: Option<Rect>,
menu_tab: Option<panes::TabAddr>,
menu_splitter: Option<usize>,
drag: DragOrigin,
clicks: mouse::ClickTracker,
}
struct Prompt {
kind: PromptKind,
input: String,
status: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PromptKind {
Find,
Palette,
}
impl PromptKind {
fn title(self) -> &'static str {
match self {
Self::Find => " find in scrollback ",
Self::Palette => " command ",
}
}
}
fn palette_entries(targets: &[Target]) -> Vec<popup::MenuItem> {
actions::applicable_actions(targets)
.into_iter()
.map(|(action, label)| popup::MenuItem::action(action, label))
.collect()
}
fn filter_palette(entries: Vec<popup::MenuItem>, query: &str) -> Vec<popup::MenuItem> {
if query.is_empty() {
return entries;
}
let needle = query.to_lowercase();
entries
.into_iter()
.filter(|item| item.label.to_lowercase().contains(&needle))
.collect()
}
enum RelocateStep {
PickSession {
mode: RelocationMode,
source_worktree: PathBuf,
source_dir: PathBuf,
sessions: Vec<actions::relocate_types::SessionInfo>,
selected: usize,
},
PickDestination {
mode: RelocationMode,
source_dir: PathBuf,
session: actions::relocate_types::SessionInfo,
candidates: Vec<PathBuf>,
selected: usize,
},
Confirm {
mode: RelocationMode,
source_dir: PathBuf,
session: actions::relocate_types::SessionInfo,
dest_worktree: PathBuf,
prompt: actions::ConfirmPrompt,
},
}
pub async fn run(
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
mut handle: ViewModelHandle,
dispatcher: Dispatcher,
commands: mpsc::UnboundedSender<HubCommand>,
glyphs: GlyphMode,
) -> Result<()> {
let mut events = EventStream::new();
let (pty_tx, mut pty_rx) = mpsc::unbounded_channel();
let mut app = App {
tree: TreeState::default(),
flow: ActionFlow::Idle,
menu: None,
relocate: None,
focus: Focus::Tree,
panes: panes::PaneLayout::default(),
quit_confirm: false,
quit_requested: false,
notice: None,
pty_tx,
commands,
glyphs,
prompt: None,
reported_visible: Vec::new(),
regions: RegionMap::default(),
terminal_area: None,
menu_tab: None,
menu_splitter: None,
drag: DragOrigin::None,
clicks: mouse::ClickTracker::default(),
};
let saved = session_layout::load(None);
if !saved.is_empty() {
let tx = app.pty_tx.clone();
let size = GridSize {
cols: 80,
lines: 24,
};
let failed = app.panes.restore(&saved, |id, kind, path| {
TerminalTab::spawn(id, kind, path.to_path_buf(), size, tx.clone())
});
for worktree in app.panes.open_worktrees() {
let _ = app.commands.send(HubCommand::SetOpenTab(worktree));
}
if !app.panes.is_empty() {
app.focus = Focus::Terminal;
}
if failed > 0 {
app.notice = Some(format!("{failed} saved tab(s) could not be reopened"));
}
}
let mut last_drawn_generation: Option<u64> = None;
let mut dirty = true;
let mut last_draw = tokio::time::Instant::now() - FRAME_INTERVAL;
loop {
let view = handle.view.borrow_and_update().clone();
let generation_moved = last_drawn_generation != Some(view.generation);
if (dirty || generation_moved) && last_draw.elapsed() >= FRAME_INTERVAL {
terminal.draw(|frame| draw(frame, &view, &mut app))?;
last_drawn_generation = Some(view.generation);
dirty = false;
last_draw = tokio::time::Instant::now();
}
let wait = if dirty || generation_moved {
FRAME_INTERVAL.saturating_sub(last_draw.elapsed())
} else {
Duration::from_millis(500)
};
tokio::select! {
biased;
ev = events.next() => {
match ev {
Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => {
app.notice = None;
if handle_key(&mut app, &view, &dispatcher, key).await {
break;
}
dirty = true;
}
Some(Ok(Event::Mouse(mouse))) => {
if handle_mouse(&mut app, &view, &dispatcher, mouse).await {
dirty = true;
}
if app.quit_requested {
break;
}
}
Some(Ok(Event::Paste(text))) => {
if app.focus == Focus::Terminal {
if let Some(tab) = app.panes.active_tab().filter(|t| t.is_alive()) {
tab.write_input(keys::paste_bytes(&text, tab.mode()));
}
}
}
Some(Ok(Event::FocusGained)) => app.forward_focus(true),
Some(Ok(Event::FocusLost)) => app.forward_focus(false),
Some(Ok(Event::Resize(..))) => dirty = true,
Some(Ok(_)) => {}
Some(Err(_)) | None => break,
}
}
pty = pty_rx.recv() => {
match pty {
Some((tab_id, event)) => {
if app.handle_pty_event(tab_id, event) {
dirty = true;
}
}
None => break,
}
}
changed = handle.view.changed() => {
if changed.is_err() {
break; }
}
() = tokio::time::sleep(wait) => {}
}
}
if let Err(e) = session_layout::save(&app.panes.to_saved(), None) {
tracing::debug!("worktrees ui: could not save the layout: {e:#}");
}
for group in &mut app.panes.groups {
for tab in &mut group.tabs {
tab.shutdown();
}
}
Ok(())
}
impl App {
fn handle_pty_event(&mut self, tab_id: TabId, event: TermEvent) -> bool {
let Some(effect) = self.panes.handle_event(tab_id, event) else {
return false; };
match effect {
TabEffect::None => false,
TabEffect::Redraw => true,
TabEffect::CopyToClipboard(text) => {
if clipboard::copy_text(&text).is_err() {
self.notice = Some("clipboard unavailable".to_string());
}
false
}
TabEffect::Exited => {
self.notice = Some("terminal exited — alt-w to close the tab".to_string());
true
}
}
}
fn forward_focus(&self, gained: bool) {
if let Some(tab) = self.panes.active_tab().filter(|t| t.is_alive()) {
if tab.mode().contains(TermMode::FOCUS_IN_OUT) {
tab.write_input(if gained {
b"\x1b[I".to_vec()
} else {
b"\x1b[O".to_vec()
});
}
}
}
fn open_tab(&mut self, kind: TabKind, worktree: PathBuf, size: GridSize, split: bool) {
if !split {
if let Some(addr) = self.panes.find_in_worktree(&worktree, kind) {
self.panes.focus(addr);
self.focus = Focus::Terminal;
return;
}
}
let tx = self.pty_tx.clone();
let target = worktree.clone();
let spawn = move |id| TerminalTab::spawn(id, kind, target, size, tx);
let opened = if split {
self.panes.split(spawn)
} else {
self.panes.open_tab(spawn)
};
match opened {
Ok(_) => {
self.focus = Focus::Terminal;
let _ = self.commands.send(HubCommand::SetOpenTab(worktree));
}
Err(e) => self.notice = Some(format!("{e:#}")),
}
}
fn close_tab(&mut self) {
let Some(worktree) = self.panes.close_active() else {
return;
};
if !self.panes.open_worktrees().contains(&worktree) {
let _ = self.commands.send(HubCommand::ClearOpenTab(worktree));
}
if self.panes.is_empty() {
self.focus = Focus::Tree;
}
}
fn cursor_worktree(&self, view: &WorktreesViewModel) -> Option<PathBuf> {
match self.tree.targets_for_cursor_only(view).into_iter().next()? {
Target::Worktree { path, .. } => Some(path),
Target::Repo { root, .. } => Some(root),
}
}
}
struct Areas {
tree: Rect,
terminal: Option<Rect>,
status: Rect,
}
fn layout(area: Rect, has_terminal: bool) -> Areas {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area);
if !has_terminal {
return Areas {
tree: rows[0],
terminal: None,
status: rows[1],
};
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(38), Constraint::Min(20)])
.split(rows[0]);
Areas {
tree: cols[0],
terminal: Some(cols[1]),
status: rows[1],
}
}
fn draw(frame: &mut Frame<'_>, view: &WorktreesViewModel, app: &mut App) {
dismiss_menu_if_origin_gone(app, view);
let areas = layout(frame.area(), !app.panes.is_empty());
render::draw_tree_pane(
frame,
areas.tree,
view,
&mut app.tree,
app.focus == Focus::Tree,
app.glyphs,
);
report_visible_rows(app, view, areas.tree);
app.terminal_area = areas.terminal;
let group_rects = match areas.terminal {
Some(area) => app.panes.arrange(area),
None => Vec::new(),
};
app.panes
.draw(frame, &group_rects, app.focus == Focus::Terminal);
let splitters = group_rects
.iter()
.take(group_rects.len().saturating_sub(1))
.map(|r| super::layout::boundary_row(r.body))
.collect();
app.regions = RegionMap {
popup: None,
tree: Block::default().borders(Borders::ALL).inner(areas.tree),
tree_offset: app.tree.offset,
groups: group_rects
.iter()
.map(|r| mouse::GroupRegion {
strip: r.strip,
grid: r.grid,
tab_spans: r.tab_spans.clone(),
})
.collect(),
splitters,
splitter_cols: areas
.terminal
.map_or((0, 0), |a| (a.x, a.x.saturating_add(a.width))),
};
render::draw_status_bar(frame, areas.status, view, &app.tree, &status_hint(app));
let area = frame.area();
app.regions.popup = draw_popups(frame, area, app);
}
fn report_visible_rows(app: &mut App, view: &WorktreesViewModel, tree_area: Rect) {
let inner = Block::default().borders(Borders::ALL).inner(tree_area);
let rows = TreeState::visible_rows(view);
let visible: Vec<PathBuf> = rows
.iter()
.skip(app.tree.offset)
.take(usize::from(inner.height))
.filter_map(|row| row.worktree_path(view))
.collect();
if visible != app.reported_visible {
app.reported_visible.clone_from(&visible);
let _ = app.commands.send(HubCommand::SetVisibleRows(visible));
}
}
fn status_hint(app: &App) -> String {
if let Some(notice) = &app.notice {
return notice.clone();
}
match (&app.flow, app.focus) {
(ActionFlow::Done { outcome }, _) => match outcome {
actions::ActionOutcome::Done { summary } => summary.clone(),
actions::ActionOutcome::BatchDone { results } => {
let failed = results.iter().filter(|(_, r)| r.is_err()).count();
if failed == 0 {
format!("done: {} target(s)", results.len())
} else {
format!("{failed} of {} target(s) failed", results.len())
}
}
actions::ActionOutcome::Failed { error } => format!("failed: {error}"),
},
(ActionFlow::Failed { error }, _) => format!("failed: {error}"),
(_, Focus::Terminal) => {
"alt-e tree alt-t tab alt-s split alt-[/] cycle alt-w close alt-c copy".to_string()
}
(_, Focus::Tree) => {
"↑↓ move space mark enter/alt-t shell tab alt-⇧t claude tab alt-⇧x codex tab a/alt-m actions c/C colour q quit".to_string()
}
}
}
fn draw_popups(frame: &mut Frame<'_>, area: Rect, app: &mut App) -> Option<mouse::PopupRegion> {
if app.quit_confirm {
let modal = popup::ConfirmModal {
prompt: actions::ConfirmPrompt {
title: "Quit?".to_string(),
body_lines: vec![
"A terminal tab is still running; quitting sends it SIGHUP.".to_string()
],
..Default::default()
},
};
popup::draw_confirm_modal(frame, area, &modal);
return None;
}
if let Some(prompt) = &app.prompt {
popup::draw_prompt(
frame,
area,
prompt.kind.title(),
&prompt.input,
&prompt.status,
);
return None;
}
if let Some(menu) = &mut app.menu {
return Some(popup::draw_menu(frame, area, menu, app.glyphs));
}
match &app.relocate {
Some(RelocateStep::PickSession {
sessions, selected, ..
}) => {
let labels: Vec<String> = sessions
.iter()
.map(|s| {
let when = relative_mtime(s.modified);
match relocate::transcript_preview(&s.jsonl_path, 48) {
Some(preview) => format!("{preview} ({when})"),
None => format!("{} ({when})", s.id),
}
})
.collect();
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
popup::draw_list_popup(frame, area, "Move which Claude session?", &refs, *selected);
return None;
}
Some(RelocateStep::PickDestination {
candidates,
selected,
..
}) => {
let labels: Vec<String> = candidates.iter().map(|p| p.display().to_string()).collect();
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
popup::draw_list_popup(frame, area, "Move to which worktree?", &refs, *selected);
return None;
}
Some(RelocateStep::Confirm { prompt, .. }) => {
popup::draw_confirm_modal(
frame,
area,
&popup::ConfirmModal {
prompt: prompt.clone(),
},
);
return None;
}
None => {}
}
if let ActionFlow::AwaitingConfirm { prompt, .. } = &app.flow {
popup::draw_confirm_modal(
frame,
area,
&popup::ConfirmModal {
prompt: prompt.clone(),
},
);
}
None
}
fn relative_mtime(modified: std::time::SystemTime) -> String {
match std::time::SystemTime::now().duration_since(modified) {
Ok(elapsed) => format!("{}s ago", elapsed.as_secs()),
Err(_) => "just now".to_string(),
}
}
fn popup_mouse_enabled(app: &App) -> bool {
app.menu.is_some() && !blocking_modal_open(app)
}
fn blocking_modal_open(app: &App) -> bool {
app.quit_confirm
|| app.prompt.is_some()
|| app.relocate.is_some()
|| matches!(app.flow, ActionFlow::AwaitingConfirm { .. })
}
async fn handle_mouse(
app: &mut App,
view: &WorktreesViewModel,
dispatcher: &Dispatcher,
mouse: MouseEvent,
) -> bool {
if popup_mouse_enabled(app) {
return handle_menu_mouse(app, view, dispatcher, mouse).await;
}
if app.quit_confirm
|| app.menu.is_some()
|| app.prompt.is_some()
|| app.relocate.is_some()
|| matches!(app.flow, ActionFlow::AwaitingConfirm { .. })
{
return false;
}
let (col, row, mods) = (mouse.column, mouse.row, mouse.modifiers);
match mouse.kind {
MouseEventKind::Down(button) => {
app.notice = None;
match app.regions.hit(col, row) {
Hit::Tree { row: tree_row } => {
tree_mouse_down(app, view, tree_row, button, mods, (col, row))
}
Hit::Terminal {
group,
col: gc,
line,
} => terminal_mouse_down(app, group, button, mods, (gc, line), (col, row)),
Hit::TabStrip { group, tab } => {
strip_mouse_down(app, group, tab, button, (col, row))
}
Hit::Splitter { index } => {
match button {
MouseButton::Left => {
app.drag = DragOrigin::Splitter { index };
false
}
MouseButton::Right => {
let mut m =
popup::ActionMenu::with_entries("Layout", menu::splitter_menu());
m.anchor = popup::Anchor::At { col, row };
app.menu = Some(m);
app.menu_splitter = Some(index);
true
}
MouseButton::Middle => false,
}
}
Hit::Chrome => {
if button == MouseButton::Right {
let mut m = popup::ActionMenu::with_entries("Menu", menu::global_menu());
m.anchor = popup::Anchor::At { col, row };
app.menu = Some(m);
app.menu_splitter = None;
return true;
}
false
}
Hit::PopupItem { .. }
| Hit::PopupSubmenuItem { .. }
| Hit::PopupInert
| Hit::PopupOutside => false,
}
}
MouseEventKind::Drag(button) => match app.drag {
DragOrigin::Tree { anchor } => {
let rows = TreeState::visible_rows(view).len();
app.tree.set_cursor(app.regions.tree_row_clamped(row), rows);
app.tree.mark_range(view, anchor, app.tree.cursor);
true
}
DragOrigin::Terminal { group } => {
let Some((gc, line)) = app.regions.clamp_to_grid(group, col, row) else {
return false;
};
if let Some(tab) = app.panes.group_tab(group) {
tab.selection_update(gc, line);
}
true
}
DragOrigin::Splitter { index } => drag_splitter(app, index, row),
DragOrigin::Child { group } => {
forward_to_child(app, group, MouseEventKind::Drag(button), mods, (col, row));
false
}
DragOrigin::None => false,
},
MouseEventKind::Up(button) => {
if let DragOrigin::Child { group } = std::mem::take(&mut app.drag) {
forward_to_child(app, group, MouseEventKind::Up(button), mods, (col, row));
}
false
}
MouseEventKind::Moved => {
if let Hit::Terminal { group, .. } = app.regions.hit(col, row) {
forward_to_child(app, group, MouseEventKind::Moved, mods, (col, row));
}
false
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
let up = mouse.kind == MouseEventKind::ScrollUp;
match app.regions.hit(col, row) {
Hit::Tree { .. } => {
let rows = TreeState::visible_rows(view).len();
app.tree.move_cursor(if up { -1 } else { 1 }, rows);
true
}
Hit::TabStrip { group, .. } => {
app.panes.focused = group;
app.panes.cycle_tab(if up { -1 } else { 1 });
true
}
Hit::Terminal {
group,
col: gc,
line,
} => {
let Some(tab) = app.panes.group_tab(group) else {
return false;
};
match mouse::route_wheel(up, mods, gc, line, tab.mode()) {
WheelRoute::Forward(bytes) | WheelRoute::ArrowKeys(bytes) => {
if tab.is_alive() {
tab.write_input(bytes);
}
false
}
WheelRoute::ScrollDisplay(lines) => {
tab.scroll(Scroll::Delta(lines));
true
}
}
}
Hit::PopupItem { .. }
| Hit::PopupSubmenuItem { .. }
| Hit::PopupInert
| Hit::PopupOutside
| Hit::Splitter { .. }
| Hit::Chrome => false,
}
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {
if let Hit::Terminal { group, .. } = app.regions.hit(col, row) {
forward_to_child(app, group, mouse.kind, mods, (col, row));
}
false
}
}
}
async fn handle_menu_mouse(
app: &mut App,
view: &WorktreesViewModel,
dispatcher: &Dispatcher,
mouse: MouseEvent,
) -> bool {
let hit = if app.regions.popup.is_some() {
app.regions.hit(mouse.column, mouse.row)
} else {
Hit::PopupOutside
};
match mouse.kind {
MouseEventKind::Moved | MouseEventKind::Drag(_) => {
let Some(menu) = app.menu.as_mut() else {
return false;
};
match hit {
Hit::PopupSubmenuItem { index } => {
if menu.submenu_selected == index {
return false;
}
menu.submenu_selected = index;
true
}
Hit::PopupItem { index } => {
if menu.selected == index {
return false;
}
menu.close_submenu();
menu.select_index(index)
}
_ => false,
}
}
MouseEventKind::Down(button) => {
if matches!(hit, Hit::PopupOutside) {
app.menu = None;
app.regions.popup = None;
if button == MouseButton::Right {
if let Hit::Tree { row: tree_row } = app.regions.hit(mouse.column, mouse.row) {
return tree_mouse_down(
app,
view,
tree_row,
button,
mouse.modifiers,
(mouse.column, mouse.row),
);
}
}
return true;
}
if let Some(menu) = app.menu.as_mut() {
menu.armed = true;
match hit {
Hit::PopupItem { index } => {
if menu.open_submenu != Some(index) {
menu.close_submenu();
}
menu.select_index(index);
}
Hit::PopupSubmenuItem { index } => menu.submenu_selected = index,
_ => {}
}
}
true
}
MouseEventKind::Up(_) => {
let Some(menu) = app.menu.as_mut() else {
return false;
};
if !menu.armed {
return false; }
match hit {
Hit::PopupSubmenuItem { index } => menu.submenu_selected = index,
Hit::PopupItem { index } if menu.open_submenu == Some(index) => {
menu.close_submenu();
return true;
}
Hit::PopupItem { index } => {
if !menu.select_index(index) {
return false;
}
}
_ => return false,
}
let Some(command) = menu.activate() else {
return true;
};
invoke_menu_command(app, view, dispatcher, command).await;
true
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
let up = mouse.kind == MouseEventKind::ScrollUp;
if let Some(menu) = app.menu.as_mut() {
menu.move_selection(if up { -1 } else { 1 });
}
true
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => false,
}
}
async fn invoke_menu_command(
app: &mut App,
view: &WorktreesViewModel,
dispatcher: &Dispatcher,
command: popup::MenuCommand,
) {
app.menu = None;
match command {
popup::MenuCommand::Action(
action @ (ActionKind::MoveClaudeSessionHere | ActionKind::CopyClaudeSessionHere),
) => {
let mode = if action == ActionKind::MoveClaudeSessionHere {
RelocationMode::Move
} else {
RelocationMode::Copy
};
start_relocate_flow(app, view, mode);
}
popup::MenuCommand::Action(action) => {
let targets = app.tree.targets(view);
run_action(app, dispatcher, action, targets).await;
}
popup::MenuCommand::Chrome(chrome) => handle_chrome_key(app, view, chrome),
popup::MenuCommand::Ui(ui) => run_ui_action(app, ui),
}
}
fn run_ui_action(app: &mut App, ui: popup::UiAction) {
match ui {
popup::UiAction::CloseOtherTabs | popup::UiAction::CloseTabsToRight => {
let Some(addr) = app.menu_tab else {
return;
};
let closed = if ui == popup::UiAction::CloseOtherTabs {
app.panes.close_other_tabs(addr)
} else {
app.panes.close_tabs_to_right(addr)
};
let still_open = app.panes.open_worktrees();
for worktree in closed {
if !still_open.contains(&worktree) {
let _ = app.commands.send(HubCommand::ClearOpenTab(worktree));
}
}
if app.panes.is_empty() {
app.focus = Focus::Tree;
}
}
popup::UiAction::SelectAll => {
if let Some(tab) = app.panes.active_tab() {
tab.select_all();
}
}
popup::UiAction::ClearSelection => {
if let Some(tab) = app.panes.active_tab() {
tab.clear_selection();
}
}
popup::UiAction::ScrollToBottom => {
if let Some(tab) = app.panes.active_tab() {
tab.scroll_to_bottom();
}
}
popup::UiAction::CloseGroupAbove | popup::UiAction::CloseGroupBelow => {
let Some(splitter) = app.menu_splitter else {
return;
};
let group = if ui == popup::UiAction::CloseGroupAbove {
splitter
} else {
splitter + 1
};
let closed = app.panes.close_group(group);
let still_open = app.panes.open_worktrees();
for worktree in closed {
if !still_open.contains(&worktree) {
let _ = app.commands.send(HubCommand::ClearOpenTab(worktree));
}
}
if app.panes.is_empty() {
app.focus = Focus::Tree;
}
}
popup::UiAction::Quit => {
if app.panes.any_alive() {
app.quit_confirm = true;
} else {
app.quit_requested = true;
}
}
}
}
fn strip_mouse_down(
app: &mut App,
group: usize,
tab: Option<usize>,
button: MouseButton,
(col, row): (u16, u16),
) -> bool {
app.focus = Focus::Terminal;
app.drag = DragOrigin::None;
app.panes.focused = group.min(app.panes.group_count().saturating_sub(1));
let Some(tab) = tab else {
return true;
};
match button {
MouseButton::Middle => {
let addr = panes::TabAddr { group, tab };
if let Some(worktree) = app.panes.close_tab(addr) {
if !app.panes.open_worktrees().contains(&worktree) {
let _ = app.commands.send(HubCommand::ClearOpenTab(worktree));
}
if app.panes.is_empty() {
app.focus = Focus::Tree;
}
}
}
MouseButton::Left => {
app.panes.focus(panes::TabAddr { group, tab });
}
MouseButton::Right => {
let addr = panes::TabAddr { group, tab };
app.panes.focus(addr);
let entries = menu::strip_menu(
app.panes.group_count(),
group,
app.panes.groups.get(group).map_or(0, |g| g.tabs.len()),
tab,
);
let mut m = popup::ActionMenu::with_entries("Tab", entries);
m.anchor = popup::Anchor::At { col, row };
app.menu = Some(m);
app.menu_tab = Some(addr);
}
}
true
}
fn drag_splitter(app: &mut App, index: usize, row: u16) -> bool {
let Some(area) = app.terminal_area else {
return false;
};
let next = super::layout::drag_splitter(area, &app.panes.weights, index, row);
if next == app.panes.weights {
return false;
}
app.panes.weights = next;
true
}
fn tree_mouse_down(
app: &mut App,
view: &WorktreesViewModel,
tree_row: usize,
button: MouseButton,
mods: KeyModifiers,
(col, row): (u16, u16),
) -> bool {
app.focus = Focus::Tree;
app.drag = DragOrigin::None;
let rows = TreeState::visible_rows(view).len();
if tree_row >= rows {
return true; }
let count = app.clicks.click(col, row, Instant::now());
match mouse::classify_tree_click(button, mods, count) {
TreeClick::Focus => app.tree.set_cursor(tree_row, rows),
TreeClick::ToggleMark => {
app.tree.set_cursor(tree_row, rows);
if let Some(path) = cursor_row_path(&app.tree, view) {
app.tree.toggle_mark(path);
}
}
TreeClick::ExtendRange => {
let from = app.tree.cursor;
app.tree.set_cursor(tree_row, rows);
app.tree.mark_range(view, from, tree_row);
}
TreeClick::Open => {
app.tree.set_cursor(tree_row, rows);
open_tab_for_cursor(app, view, TabKind::Shell, false);
}
TreeClick::ContextMenu => {
let inside_marks =
row_path(view, tree_row).is_some_and(|path| app.tree.marked.contains(&path));
if !inside_marks {
app.tree.set_cursor(tree_row, rows);
app.tree.clear_marks();
}
let targets = app.tree.targets(view);
let mut menu = popup::ActionMenu::with_entries(
if inside_marks { "Marked" } else { "Actions" },
menu::tree_menu(&targets),
);
menu.anchor = popup::Anchor::At { col, row };
menu.origin_row = row_path(view, tree_row);
app.menu = Some(menu);
}
}
if button == MouseButton::Left {
app.drag = DragOrigin::Tree { anchor: tree_row };
}
true
}
fn terminal_mouse_down(
app: &mut App,
group: usize,
button: MouseButton,
mods: KeyModifiers,
(gc, line): (u16, u16),
(col, row): (u16, u16),
) -> bool {
app.focus = Focus::Terminal;
app.drag = DragOrigin::None;
app.panes.focused = group.min(app.panes.group_count().saturating_sub(1));
let click = app.clicks.click(col, row, Instant::now());
let Some(tab) = app.panes.group_tab(group) else {
return true;
};
let mode = tab.mode();
if tab.is_alive() && mouse::forwards_to_child(mode, mods) {
if let Some(bytes) = mouse::encode_mouse(MouseEventKind::Down(button), mods, gc, line, mode)
{
tab.write_input(bytes);
}
app.drag = DragOrigin::Child { group };
return true;
}
if button == MouseButton::Left {
let ty = match click {
1 => SelectionType::Simple,
2 => SelectionType::Semantic,
_ => SelectionType::Lines,
};
tab.selection_start(gc, line, ty);
app.drag = DragOrigin::Terminal { group };
return true;
}
if button == MouseButton::Right {
let has_selection = tab.selection_to_string().is_some();
let mut m = popup::ActionMenu::with_entries("Terminal", menu::grid_menu(has_selection));
m.anchor = popup::Anchor::At { col, row };
app.menu = Some(m);
app.menu_tab = app.panes.groups.get(group).map(|g| panes::TabAddr {
group,
tab: g.active,
});
}
true
}
fn forward_to_child(
app: &App,
group: usize,
kind: MouseEventKind,
mods: KeyModifiers,
(col, row): (u16, u16),
) {
let Some(tab) = app.panes.group_tab(group).filter(|t| t.is_alive()) else {
return;
};
let Some((gc, line)) = app.regions.clamp_to_grid(group, col, row) else {
return;
};
let mode = tab.mode();
if !mouse::forwards_to_child(mode, mods) {
return;
}
if let Some(bytes) = mouse::encode_mouse(kind, mods, gc, line, mode) {
tab.write_input(bytes);
}
}
async fn handle_key(
app: &mut App,
view: &WorktreesViewModel,
dispatcher: &Dispatcher,
key: KeyEvent,
) -> bool {
if app.quit_confirm {
match key.code {
KeyCode::Char('y') | KeyCode::Enter => return true,
KeyCode::Char('n') | KeyCode::Esc => app.quit_confirm = false,
_ => {}
}
return false;
}
if let Some(chrome) = keys::chrome_key(&key) {
handle_chrome_key(app, view, chrome);
return false;
}
if app.prompt.is_some() {
handle_prompt_key(app, view, key).await;
return false;
}
if app.focus == Focus::Terminal {
match app.panes.active_tab().filter(|t| t.is_alive()) {
Some(tab) => {
if let KeyRoute::Passthrough(bytes) = keys::route(&key, tab.mode()) {
tab.write_input(bytes);
}
return false;
}
None => app.focus = Focus::Tree, }
}
let code = key.code;
if app.relocate.is_some() {
handle_relocate_key(app, view, dispatcher, code);
return false;
}
if let Some(menu) = &mut app.menu {
match code {
KeyCode::Up | KeyCode::Char('k') => menu.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => menu.move_selection(1),
KeyCode::Esc => {
if !menu.close_submenu() {
app.menu = None;
}
}
KeyCode::Right => {
menu.open_selected_submenu();
}
KeyCode::Left => {
menu.close_submenu();
}
KeyCode::Home => menu.select_end(false),
KeyCode::End => menu.select_end(true),
KeyCode::Enter => {
if let Some(command) = menu.activate() {
invoke_menu_command(app, view, dispatcher, command).await;
}
}
_ => {}
}
return false;
}
match &app.flow {
ActionFlow::AwaitingConfirm {
action, targets, ..
} => {
match code {
KeyCode::Char('y') | KeyCode::Enter => {
let action = *action;
let targets = targets.clone();
app.flow = ActionFlow::Executing {
action,
targets: targets.clone(),
};
let outcome = dispatcher.execute(action, &targets).await;
app.flow = ActionFlow::Done { outcome };
}
KeyCode::Char('n') | KeyCode::Esc => app.flow = ActionFlow::Idle,
_ => {}
}
return false;
}
ActionFlow::Done { .. } | ActionFlow::Failed { .. } => {
app.flow = ActionFlow::Idle; return false;
}
_ => {}
}
match code {
KeyCode::Char('q') => {
if app.panes.any_alive() {
app.quit_confirm = true;
} else {
return true;
}
}
KeyCode::Esc => {
if !app.tree.marked.is_empty() {
app.tree.clear_marks();
} else if app.panes.any_alive() {
app.quit_confirm = true;
} else {
return true;
}
}
KeyCode::Up | KeyCode::Char('k') => {
let rows = TreeState::visible_rows(view).len();
app.tree.move_cursor(-1, rows);
}
KeyCode::Down | KeyCode::Char('j') => {
let rows = TreeState::visible_rows(view).len();
app.tree.move_cursor(1, rows);
}
KeyCode::Char(' ') => {
if let Some(path) = cursor_row_path(&app.tree, view) {
app.tree.toggle_mark(path);
}
}
KeyCode::Enter => open_tab_for_cursor(app, view, TabKind::Shell, false),
KeyCode::F(10) => open_context_menu_for_focus(app, view),
KeyCode::Char('a') => {
let targets = app.tree.targets(view);
let mut menu = popup::ActionMenu::with_entries("Actions", menu::tree_menu(&targets));
menu.origin_row = cursor_row_path(&app.tree, view);
app.menu = Some(menu);
}
KeyCode::Char('c') => {
app.menu = Some(popup::ActionMenu::new(menu::row_color_items()));
}
KeyCode::Char('C') => {
let targets = app.tree.targets(view);
let outcome = dispatcher
.execute(ActionKind::ClearRowColor, &targets)
.await;
app.flow = ActionFlow::Done { outcome };
}
KeyCode::Char(':') => {
app.prompt = Some(Prompt {
kind: PromptKind::Palette,
input: String::new(),
status: String::new(),
});
}
_ => {}
}
false
}
async fn handle_prompt_key(app: &mut App, view: &WorktreesViewModel, key: KeyEvent) {
let Some(prompt) = app.prompt.as_mut() else {
return;
};
match key.code {
KeyCode::Esc => app.prompt = None,
KeyCode::Backspace => {
prompt.input.pop();
prompt.status.clear();
}
KeyCode::Char(c) => {
prompt.input.push(c);
prompt.status.clear();
}
KeyCode::Enter => match prompt.kind {
PromptKind::Find => {
let needle = prompt.input.clone();
let found = app
.panes
.active_tab()
.is_some_and(|tab| tab.find_in_scrollback(&needle));
if let Some(prompt) = app.prompt.as_mut() {
prompt.status = if found {
String::new()
} else {
"no match".to_string()
};
}
}
PromptKind::Palette => {
let query = prompt.input.clone();
app.prompt = None;
let targets = app.tree.targets(view);
let matches = filter_palette(palette_entries(&targets), &query);
match matches.first() {
Some(item) => {
let popup::MenuCommand::Action(action) = item.command else {
return;
};
if matches!(
action,
ActionKind::MoveClaudeSessionHere | ActionKind::CopyClaudeSessionHere
) {
let mode = if action == ActionKind::MoveClaudeSessionHere {
RelocationMode::Move
} else {
RelocationMode::Copy
};
start_relocate_flow(app, view, mode);
} else {
app.menu = Some(popup::ActionMenu::new(matches));
}
}
None => app.notice = Some(format!("no command matching {query:?}")),
}
}
},
_ => {}
}
}
fn handle_chrome_key(app: &mut App, view: &WorktreesViewModel, chrome: ChromeKey) {
match chrome {
ChromeKey::FocusTree => app.focus = Focus::Tree,
ChromeKey::FocusTerminal => {
if app.panes.is_empty() {
app.notice = Some("no terminal tab — enter or alt-t opens one".to_string());
} else {
app.focus = Focus::Terminal;
}
}
ChromeKey::NewShellTab => open_tab_for_cursor(app, view, TabKind::Shell, false),
ChromeKey::NewClaudeTab => open_tab_for_cursor(app, view, TabKind::Claude, false),
ChromeKey::NewCodexTab => open_tab_for_cursor(app, view, TabKind::Codex, false),
ChromeKey::SplitShellTab => open_tab_for_cursor(app, view, TabKind::Shell, true),
ChromeKey::CloseTab => app.close_tab(),
ChromeKey::NextTab => app.panes.cycle_tab(1),
ChromeKey::PrevTab => app.panes.cycle_tab(-1),
ChromeKey::SelectTab(index) => {
if !app.panes.select_tab(index) {
app.notice = Some(format!("no tab {}", index + 1));
}
}
ChromeKey::NextGroup => app.panes.cycle_group(1),
ChromeKey::PrevGroup => app.panes.cycle_group(-1),
ChromeKey::MoveTabDown => {
if !app.panes.move_tab_to_group(1) {
app.notice = Some("no group below to move this tab to".to_string());
}
}
ChromeKey::MoveTabUp => {
if !app.panes.move_tab_to_group(-1) {
app.notice = Some("no group above to move this tab to".to_string());
}
}
ChromeKey::ResetLayout => app.panes.reset_weights(),
ChromeKey::ClearAllRowColors => {
let _ = app.commands.send(HubCommand::ClearAllRowColors);
app.notice = Some("cleared every row colour".to_string());
}
ChromeKey::Find => {
if app.panes.active_tab().is_some() {
app.prompt = Some(Prompt {
kind: PromptKind::Find,
input: String::new(),
status: String::new(),
});
} else {
app.notice = Some("no terminal tab to search".to_string());
}
}
ChromeKey::Copy => {
let text = app
.panes
.active_tab()
.and_then(TerminalTab::selection_to_string);
app.notice = Some(match text {
Some(text) if clipboard::copy_text(&text).is_ok() => {
if let Some(tab) = app.panes.active_tab() {
tab.clear_selection();
}
"copied".to_string()
}
Some(_) => "clipboard unavailable".to_string(),
None => "nothing selected".to_string(),
});
}
ChromeKey::ContextMenu => open_context_menu_for_focus(app, view),
ChromeKey::ScrollPageUp => {
if let Some(tab) = app.panes.active_tab() {
tab.scroll(Scroll::PageUp);
}
}
ChromeKey::ScrollPageDown => {
if let Some(tab) = app.panes.active_tab() {
tab.scroll(Scroll::PageDown);
}
}
}
}
fn open_tab_for_cursor(app: &mut App, view: &WorktreesViewModel, kind: TabKind, split: bool) {
let Some(worktree) = app.cursor_worktree(view) else {
app.notice = Some("no row selected".to_string());
return;
};
if split {
let height = app.terminal_area.map_or(0, |a| a.height);
if height < panes::min_height_for(app.panes.group_count() + 1) {
app.notice = Some("not enough height to split — close a group first".to_string());
return;
}
}
app.open_tab(
kind,
worktree,
GridSize {
cols: 80,
lines: 24,
},
split,
);
}
async fn run_action(
app: &mut App,
dispatcher: &Dispatcher,
action: ActionKind,
targets: Vec<Target>,
) {
app.flow = ActionFlow::Checking {
action,
targets: targets.clone(),
};
match dispatcher.check(action, &targets).await {
CheckReport::ProceedWithoutConfirm => {
app.flow = ActionFlow::Executing {
action,
targets: targets.clone(),
};
let outcome = dispatcher.execute(action, &targets).await;
app.flow = ActionFlow::Done { outcome };
}
CheckReport::NeedsConfirm { prompt, .. } => {
app.flow = ActionFlow::AwaitingConfirm {
action,
targets,
prompt,
};
}
CheckReport::Refused { reason } => {
app.flow = ActionFlow::Failed { error: reason };
}
}
}
fn open_context_menu_for_focus(app: &mut App, view: &WorktreesViewModel) {
if blocking_modal_open(app) {
return;
}
if app.focus == Focus::Terminal {
let group = app.panes.focused;
let Some(tab) = app.panes.group_tab(group) else {
app.notice = Some("no terminal tab".to_string());
return;
};
let has_selection = tab.selection_to_string().is_some();
let cursor = tab.cursor_position();
let anchor = app
.regions
.groups
.get(group)
.map_or(popup::Anchor::Centered, |g| {
let (col, line) = cursor.unwrap_or((0, 0));
popup::Anchor::At {
col: g.grid.x.saturating_add(col),
row: g.grid.y.saturating_add(line),
}
});
let mut m = popup::ActionMenu::with_entries("Terminal", menu::grid_menu(has_selection));
m.anchor = anchor;
app.menu = Some(m);
app.menu_tab = app.panes.groups.get(group).map(|g| panes::TabAddr {
group,
tab: g.active,
});
return;
}
let targets = app.tree.targets(view);
let entries = menu::tree_menu(&targets);
if entries.is_empty() {
app.notice = Some("no actions for this row".to_string());
return;
}
let row = app
.tree
.cursor
.saturating_sub(app.tree.offset)
.try_into()
.unwrap_or(u16::MAX);
let mut m = popup::ActionMenu::with_entries("Actions", entries);
m.anchor = popup::Anchor::At {
col: app.regions.tree.x,
row: app.regions.tree.y.saturating_add(row),
};
m.origin_row = cursor_row_path(&app.tree, view);
app.menu = Some(m);
app.menu_tab = None;
}
fn dismiss_menu_if_origin_gone(app: &mut App, view: &WorktreesViewModel) {
let Some(origin) = app.menu.as_ref().and_then(|m| m.origin_row.clone()) else {
return;
};
let still_there = TreeState::visible_rows(view)
.iter()
.any(|row| row.path(view) == Some(origin.as_path()));
if !still_there {
app.menu = None;
app.notice = Some("that row is gone — menu closed".to_string());
}
}
fn row_path(view: &WorktreesViewModel, index: usize) -> Option<PathBuf> {
TreeState::visible_rows(view)
.get(index)?
.path(view)
.map(std::path::Path::to_path_buf)
}
fn cursor_row_path(tree: &TreeState, view: &WorktreesViewModel) -> Option<PathBuf> {
match tree.targets_for_cursor_only(view).first()? {
Target::Repo { root, .. } => Some(root.clone()),
Target::Worktree { path, .. } => Some(path.clone()),
}
}
fn start_relocate_flow(app: &mut App, view: &WorktreesViewModel, mode: RelocationMode) {
let Some(Target::Worktree { path, .. }) = tree_only_target(&app.tree, view) else {
app.flow = ActionFlow::Failed {
error: "no worktree row selected".to_string(),
};
return;
};
let Some(source_dir) = relocate::project_dir_for(&path) else {
app.flow = ActionFlow::Failed {
error: "could not resolve the Claude projects directory".to_string(),
};
return;
};
let mut sessions = match relocate::enumerate_sessions(&source_dir) {
Ok(sessions) => sessions,
Err(e) => {
app.flow = ActionFlow::Failed {
error: format!("{e:#}"),
};
return;
}
};
if sessions.is_empty() {
app.flow = ActionFlow::Failed {
error: "no Claude sessions found here".to_string(),
};
return;
}
if sessions.len() == 1 {
let session = sessions.remove(0);
let candidates = other_worktree_paths(view, &path);
app.relocate = Some(RelocateStep::PickDestination {
mode,
source_dir,
session,
candidates,
selected: 0,
});
} else {
app.relocate = Some(RelocateStep::PickSession {
mode,
source_worktree: path,
source_dir,
sessions,
selected: 0,
});
}
}
fn tree_only_target(tree: &TreeState, view: &WorktreesViewModel) -> Option<Target> {
tree.targets_for_cursor_only(view).into_iter().next()
}
fn other_worktree_paths(view: &WorktreesViewModel, exclude: &Path) -> Vec<PathBuf> {
view.repos
.iter()
.flat_map(|repo| repo.worktrees.iter())
.map(|wt| wt.path.clone())
.filter(|p| p != exclude)
.collect()
}
fn handle_relocate_key(
app: &mut App,
view: &WorktreesViewModel,
dispatcher: &Dispatcher,
code: KeyCode,
) {
match code {
KeyCode::Esc => app.relocate = None,
KeyCode::Up | KeyCode::Char('k') => move_relocate_selection(app, -1),
KeyCode::Down | KeyCode::Char('j') => move_relocate_selection(app, 1),
KeyCode::Enter | KeyCode::Char('y') => advance_relocate(app, view, dispatcher),
KeyCode::Char('n') => {
if matches!(app.relocate, Some(RelocateStep::Confirm { .. })) {
app.relocate = None;
}
}
_ => {}
}
}
fn move_relocate_selection(app: &mut App, delta: isize) {
match &mut app.relocate {
Some(RelocateStep::PickSession {
sessions, selected, ..
}) => *selected = clamp_selection(*selected, delta, sessions.len()),
Some(RelocateStep::PickDestination {
candidates,
selected,
..
}) => *selected = clamp_selection(*selected, delta, candidates.len()),
Some(RelocateStep::Confirm { .. }) | None => {}
}
}
fn clamp_selection(current: usize, delta: isize, len: usize) -> usize {
if len == 0 {
return 0;
}
let max = len - 1;
let current = current.min(max) as isize;
(current + delta).clamp(0, max as isize) as usize
}
fn advance_relocate(app: &mut App, view: &WorktreesViewModel, dispatcher: &Dispatcher) {
match app.relocate.take() {
Some(RelocateStep::PickSession {
mode,
source_worktree,
source_dir,
mut sessions,
selected,
}) => {
if selected >= sessions.len() {
return;
}
let session = sessions.swap_remove(selected);
let candidates = other_worktree_paths(view, &source_worktree);
app.relocate = Some(RelocateStep::PickDestination {
mode,
source_dir,
session,
candidates,
selected: 0,
});
}
Some(RelocateStep::PickDestination {
mode,
source_dir,
session,
candidates,
selected,
..
}) => {
let Some(dest_worktree) = candidates.into_iter().nth(selected) else {
return;
};
match dispatcher.check_relocate_session(&source_dir, &session, &dest_worktree) {
CheckReport::NeedsConfirm { prompt, .. } => {
app.relocate = Some(RelocateStep::Confirm {
mode,
source_dir,
session,
dest_worktree,
prompt,
});
}
CheckReport::Refused { reason } => {
app.flow = ActionFlow::Failed { error: reason };
}
CheckReport::ProceedWithoutConfirm => {
let outcome = dispatcher.execute_relocate_session(
&source_dir,
&session,
&dest_worktree,
mode,
);
app.flow = ActionFlow::Done { outcome };
}
}
}
Some(RelocateStep::Confirm {
mode,
source_dir,
session,
dest_worktree,
..
}) => {
let outcome =
dispatcher.execute_relocate_session(&source_dir, &session, &dest_worktree, mode);
app.flow = ActionFlow::Done { outcome };
}
None => {}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
use ratatui::backend::TestBackend;
use super::super::client::WorktreesClient;
use super::super::view_model::{RepoRow, WorktreeRow};
fn view_with(paths: &[&str]) -> WorktreesViewModel {
let worktrees = paths
.iter()
.map(|p| WorktreeRow {
path: PathBuf::from(p),
branch: Some("main".to_string()),
head_sha: None,
upstream_sha: None,
is_main: false,
open: false,
window_key: None,
pr: None,
pr_none: false,
operation: None,
rebasing: false,
pushing: false,
ahead_behind: super::super::view_model::AheadBehindState::Unknown,
sessions: Vec::new(),
row_color: None,
here: false,
})
.collect();
WorktreesViewModel {
repos: vec![RepoRow {
main_repo: "repo".to_string(),
github: None,
root: PathBuf::from("/repo"),
polling_enabled: false,
row_color: None,
worktrees,
}],
..Default::default()
}
}
fn test_app() -> (App, Dispatcher, mpsc::UnboundedReceiver<HubCommand>) {
let (commands, commands_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let dispatcher = Dispatcher::new(
WorktreesClient::new("/tmp/nonexistent-omni-dev-app-test.sock"),
commands.clone(),
);
let app = App {
tree: TreeState::default(),
flow: ActionFlow::Idle,
menu: None,
relocate: None,
focus: Focus::Tree,
panes: panes::PaneLayout::default(),
glyphs: GlyphMode::Unicode,
prompt: None,
reported_visible: Vec::new(),
quit_confirm: false,
quit_requested: false,
notice: None,
pty_tx,
commands,
regions: RegionMap::default(),
terminal_area: None,
menu_tab: None,
menu_splitter: None,
drag: DragOrigin::None,
clicks: mouse::ClickTracker::default(),
};
(app, dispatcher, commands_rx)
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn alt(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)
}
fn alt_key(code: KeyCode, extra: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::ALT | extra)
}
#[cfg(unix)]
fn install_tab(app: &mut App, tab: TerminalTab) {
app.panes
.open_tab(move |_| Ok(tab))
.expect("installing a prebuilt tab cannot fail");
}
fn grid_rect(app: &App) -> Rect {
app.regions
.groups
.first()
.map(|g| g.grid)
.expect("a terminal region")
}
fn only_tab(app: &App) -> &TerminalTab {
app.panes.active_tab().expect("a tab")
}
fn only_tab_mut(app: &mut App) -> Option<&mut TerminalTab> {
let focused = app.panes.focused;
app.panes.groups.get_mut(focused)?.active_tab_mut()
}
fn shutdown_all(app: &mut App) {
for group in &mut app.panes.groups {
for tab in &mut group.tabs {
tab.shutdown();
}
}
}
fn mouse_at(kind: MouseEventKind, col: u16, row: u16, modifiers: KeyModifiers) -> MouseEvent {
MouseEvent {
kind,
column: col,
row,
modifiers,
}
}
fn draw_into(terminal: &mut Terminal<TestBackend>, view: &WorktreesViewModel, app: &mut App) {
terminal.draw(|frame| draw(frame, view, app)).unwrap();
}
async fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
for _ in 0..100 {
if cond() {
return true;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
false
}
#[cfg(unix)]
fn scripted_tab(app: &App, script: &str, cwd: PathBuf) -> TerminalTab {
let request = super::super::terminal::pty::SpawnRequest {
tab: 1,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), script.to_string()],
)),
cwd,
size: GridSize { cols: 40, lines: 6 },
extra_env: Vec::new(),
};
TerminalTab::from_request(TabKind::Shell, request, app.pty_tx.clone()).unwrap()
}
fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
terminal
.backend()
.buffer()
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
#[tokio::test]
async fn tree_keys_move_mark_open_menus_and_quit() {
let (mut app, dispatcher, mut commands) = test_app();
let view = view_with(&["/repo/a", "/repo/b"]);
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('j'))).await);
assert_eq!(app.tree.cursor, 1);
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('k'))).await);
assert_eq!(app.tree.cursor, 0);
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::Down)).await);
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char(' '))).await);
assert_eq!(app.tree.marked.len(), 1);
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::Esc)).await);
assert!(app.tree.marked.is_empty());
assert!(handle_key(&mut app, &view, &dispatcher, press(KeyCode::Esc)).await);
assert!(handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('q'))).await);
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('a'))).await);
assert!(app.menu.is_some());
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Down)).await;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('k'))).await;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::End)).await;
let end_selected = app.menu.as_ref().unwrap().selected;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Home)).await;
assert_eq!(
app.menu.as_ref().unwrap().selected,
0,
"Home lands on the first item"
);
assert_ne!(end_selected, 0, "End had landed elsewhere first");
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Esc)).await;
assert!(app.menu.is_none());
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('c'))).await;
assert!(app.menu.is_some());
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Enter)).await;
assert!(matches!(app.flow, ActionFlow::Done { .. }));
assert!(matches!(
commands.try_recv(),
Ok(HubCommand::SetRowColor(..))
));
assert!(status_hint(&app).starts_with("Set colour"));
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('x'))).await;
assert_eq!(app.flow, ActionFlow::Idle);
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('C'))).await;
assert!(matches!(
commands.try_recv(),
Ok(HubCommand::ClearRowColor(_))
));
assert!(matches!(app.flow, ActionFlow::Done { .. }));
app.flow = ActionFlow::Idle;
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::F(9))).await);
}
#[tokio::test]
async fn an_action_that_needs_the_daemon_fails_visibly_when_it_is_down() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
app.tree.cursor = 1; let targets = app.tree.targets(&view);
run_action(&mut app, &dispatcher, ActionKind::CloseWindow, targets).await;
match &app.flow {
ActionFlow::Done {
outcome: actions::ActionOutcome::BatchDone { results },
} => assert!(results.iter().all(|(_, r)| r.is_err())),
other => panic!("unexpected flow: {other:?}"),
}
assert!(status_hint(&app).contains("failed"));
let targets = app.tree.targets(&view);
run_action(&mut app, &dispatcher, ActionKind::CloseWorktree, targets).await;
assert!(matches!(app.flow, ActionFlow::Failed { .. }));
assert!(status_hint(&app).starts_with("failed:"));
app.flow = ActionFlow::AwaitingConfirm {
action: ActionKind::CloseWorktree,
targets: Vec::new(),
prompt: actions::ConfirmPrompt::default(),
};
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('n'))).await;
assert_eq!(app.flow, ActionFlow::Idle);
app.flow = ActionFlow::AwaitingConfirm {
action: ActionKind::CloseWorktree,
targets: Vec::new(),
prompt: actions::ConfirmPrompt::default(),
};
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('y'))).await;
assert!(matches!(app.flow, ActionFlow::Done { .. }));
}
#[tokio::test]
async fn alt_m_and_f10_both_open_the_context_menu_from_the_tree() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
app.tree.cursor = 1;
handle_key(&mut app, &view, &dispatcher, alt('m')).await;
assert!(app.menu.is_some(), "alt-m opens the menu");
assert!(
app.menu
.as_ref()
.and_then(|m| m.origin_row.as_ref())
.is_some(),
"and records the row it acts on, like the right-click route"
);
assert!(
matches!(
app.menu.as_ref().map(|m| m.anchor),
Some(popup::Anchor::At { .. })
),
"anchored at the cursor, not centred like the `a` menu"
);
app.menu = None;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::F(10))).await;
assert!(app.menu.is_some(), "bare F10 opens it");
app.menu = None;
handle_key(
&mut app,
&view,
&dispatcher,
KeyEvent::new(KeyCode::F(10), KeyModifiers::SHIFT),
)
.await;
assert!(app.menu.is_some(), "shift-F10 opens it too");
}
#[tokio::test]
async fn the_a_menu_and_the_context_menu_offer_the_same_entries() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
app.tree.cursor = 1;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('a'))).await;
let from_a = app.menu.take().expect("`a` opens a menu").entries;
handle_key(&mut app, &view, &dispatcher, alt('m')).await;
let from_alt_m = app.menu.take().expect("alt-m opens a menu").entries;
assert_eq!(from_a, from_alt_m);
}
#[tokio::test]
async fn the_context_menu_key_is_inert_over_another_modal() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
app.tree.cursor = 1;
app.flow = ActionFlow::AwaitingConfirm {
action: ActionKind::CloseWorktree,
targets: app.tree.targets(&view),
prompt: actions::ConfirmPrompt {
title: "Close?".to_string(),
..Default::default()
},
};
handle_key(&mut app, &view, &dispatcher, alt('m')).await;
assert!(app.menu.is_none(), "no menu over a confirm modal");
assert!(
matches!(app.flow, ActionFlow::AwaitingConfirm { .. }),
"and the pending confirm survives"
);
app.flow = ActionFlow::Idle;
app.prompt = Some(Prompt {
kind: PromptKind::Palette,
input: String::new(),
status: String::new(),
});
handle_key(&mut app, &view, &dispatcher, alt('m')).await;
assert!(app.menu.is_none(), "no menu over a prompt");
app.prompt = None;
handle_key(&mut app, &view, &dispatcher, alt('m')).await;
assert!(app.menu.is_some());
}
#[tokio::test]
async fn chrome_chords_without_a_terminal_leave_notices() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
handle_key(&mut app, &view, &dispatcher, alt('l')).await;
assert!(app
.notice
.as_deref()
.unwrap_or("")
.contains("no terminal tab"));
handle_key(&mut app, &view, &dispatcher, alt('c')).await;
assert_eq!(app.notice.as_deref(), Some("nothing selected"));
handle_key(&mut app, &view, &dispatcher, alt('w')).await; assert_eq!(app.focus, Focus::Tree);
handle_key(&mut app, &view, &dispatcher, alt('e')).await;
assert_eq!(app.focus, Focus::Tree);
handle_key(
&mut app,
&view,
&dispatcher,
KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT),
)
.await;
handle_key(
&mut app,
&view,
&dispatcher,
KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT),
)
.await;
assert!(!app.handle_pty_event(99, TermEvent::Wakeup));
app.forward_focus(true); }
#[tokio::test]
async fn relocate_wizard_fails_cleanly_without_sessions_and_cancels() {
let (mut app, dispatcher, _commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_string_lossy().into_owned();
let view = view_with(&[&path, "/repo/other"]);
start_relocate_flow(&mut app, &view, RelocationMode::Move);
assert!(matches!(app.flow, ActionFlow::Failed { .. }));
app.flow = ActionFlow::Idle;
let session = actions::relocate_types::SessionInfo {
id: "s1".to_string(),
jsonl_path: dir.path().join("s1.jsonl"),
modified: std::time::SystemTime::UNIX_EPOCH,
has_sidecar: false,
};
app.relocate = Some(RelocateStep::PickSession {
mode: RelocationMode::Copy,
source_worktree: dir.path().to_path_buf(),
source_dir: dir.path().to_path_buf(),
sessions: vec![session.clone(), session.clone()],
selected: 0,
});
let mut picker_terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
picker_terminal
.draw(|frame| draw(frame, &view, &mut app))
.unwrap();
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Down)).await;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Up)).await;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Enter)).await;
assert!(matches!(
app.relocate,
Some(RelocateStep::PickDestination { .. })
));
picker_terminal
.draw(|frame| draw(frame, &view, &mut app))
.unwrap();
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('j'))).await;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('n'))).await; handle_key(&mut app, &view, &dispatcher, press(KeyCode::Esc)).await;
assert!(app.relocate.is_none());
app.relocate = Some(RelocateStep::Confirm {
mode: RelocationMode::Copy,
source_dir: dir.path().to_path_buf(),
session,
dest_worktree: PathBuf::from("/repo/other"),
prompt: actions::ConfirmPrompt::default(),
});
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal.draw(|frame| draw(frame, &view, &mut app)).unwrap();
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('n'))).await;
assert!(app.relocate.is_none());
assert_eq!(clamp_selection(5, 1, 0), 0);
assert!(relative_mtime(std::time::SystemTime::now()).ends_with("ago"));
}
#[cfg(unix)]
#[tokio::test]
async fn a_terminal_tab_takes_focus_keys_and_closes_cleanly() {
let (mut app, dispatcher, mut commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let here = dir.path().to_path_buf();
let view = view_with(&[&here.to_string_lossy(), "/repo/other"]);
let request = super::super::terminal::pty::SpawnRequest {
tab: 1,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), "sleep 5".to_string()],
)),
cwd: here.clone(),
size: GridSize { cols: 40, lines: 6 },
extra_env: Vec::new(),
};
let tab = TerminalTab::from_request(TabKind::Shell, request, app.pty_tx.clone()).unwrap();
install_tab(&mut app, tab);
app.focus = Focus::Terminal;
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('x'))).await);
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::Esc)).await);
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::CapsLock)).await);
handle_key(&mut app, &view, &dispatcher, alt('e')).await;
assert_eq!(app.focus, Focus::Tree);
handle_key(&mut app, &view, &dispatcher, alt('l')).await;
assert_eq!(app.focus, Focus::Terminal);
assert!(status_hint(&app).contains("alt-e tree"));
app.forward_focus(false);
app.forward_focus(true);
app.focus = Focus::Tree;
app.open_tab(
TabKind::Shell,
here.clone(),
GridSize {
cols: 80,
lines: 24,
},
false,
);
assert_eq!(app.focus, Focus::Terminal);
assert_eq!(app.panes.groups[0].tabs.len(), 1, "no duplicate tab");
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
terminal.draw(|frame| draw(frame, &view, &mut app)).unwrap();
assert!(buffer_text(&terminal).contains("WORKTREES"));
app.focus = Focus::Tree;
assert!(!handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('q'))).await);
assert!(app.quit_confirm);
terminal.draw(|frame| draw(frame, &view, &mut app)).unwrap();
assert!(buffer_text(&terminal).contains("Quit?"));
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('n'))).await;
assert!(!app.quit_confirm);
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Esc)).await;
assert!(app.quit_confirm);
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('x'))).await; assert!(handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char('y'))).await);
app.quit_confirm = false;
assert!(app.handle_pty_event(1, TermEvent::Wakeup));
assert!(!app.handle_pty_event(1, TermEvent::Bell));
assert!(app.handle_pty_event(1, TermEvent::ChildExit(std::process::ExitStatus::default())));
assert!(app
.notice
.as_deref()
.unwrap_or("")
.contains("terminal exited"));
only_tab_mut(&mut app).unwrap().exit_status = None; handle_key(&mut app, &view, &dispatcher, alt('w')).await;
assert!(app.panes.is_empty());
assert_eq!(app.focus, Focus::Tree);
assert!(
drained_clear_open_tab(&mut commands, &here),
"closing the last tab clears the here cue"
);
}
fn drained_clear_open_tab(
commands: &mut mpsc::UnboundedReceiver<HubCommand>,
path: &Path,
) -> bool {
std::iter::from_fn(|| commands.try_recv().ok())
.any(|cmd| matches!(cmd, HubCommand::ClearOpenTab(p) if p == path))
}
#[cfg(unix)]
#[tokio::test]
async fn open_tab_for_cursor_spawns_and_reports_here_beside_an_exited_tab() {
let (mut app, _dispatcher, mut commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let here = dir.path().to_path_buf();
let view = view_with(&[&here.to_string_lossy()]);
app.tree.cursor = 1;
let request = super::super::terminal::pty::SpawnRequest {
tab: 1,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), "true".to_string()],
)),
cwd: here.clone(),
size: GridSize { cols: 40, lines: 6 },
extra_env: Vec::new(),
};
let mut tab =
TerminalTab::from_request(TabKind::Shell, request, app.pty_tx.clone()).unwrap();
tab.exit_status = Some(std::process::ExitStatus::default());
install_tab(&mut app, tab);
let empty = WorktreesViewModel::default();
open_tab_for_cursor(&mut app, &empty, TabKind::Shell, false);
assert_eq!(app.notice.as_deref(), Some("no row selected"));
open_tab_for_cursor(&mut app, &view, TabKind::Shell, false);
match commands.try_recv() {
Ok(HubCommand::SetOpenTab(p)) => assert_eq!(p, here),
other => {
assert!(
app.notice.is_some(),
"neither a tab nor a notice: {other:?}"
);
}
}
shutdown_all(&mut app);
}
#[tokio::test]
async fn mouse_on_the_tree_focuses_marks_ranges_scrolls_and_ignores_chrome() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a", "/repo/b", "/repo/c"]); let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
draw_into(&mut terminal, &view, &mut app);
let tree = app.regions.tree;
assert_eq!((tree.x, tree.y), (1, 1), "inside the border");
let none = KeyModifiers::NONE;
let down = |row: u16, mods| mouse_at(MouseEventKind::Down(MouseButton::Left), 5, row, mods);
assert_eq!(app.drag, DragOrigin::None);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Drag(MouseButton::Left), 5, 3, none)
)
.await
);
assert!(!handle_mouse(&mut app, &view, &dispatcher, down(0, none)).await);
assert!(handle_mouse(&mut app, &view, &dispatcher, down(tree.y + 2, none)).await);
assert_eq!(app.tree.cursor, 2);
assert_eq!(app.drag, DragOrigin::Tree { anchor: 2 });
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Up(MouseButton::Left), 5, 3, none)
)
.await
);
assert_eq!(app.drag, DragOrigin::None);
assert!(app.tree.marked.is_empty(), "a click alone marks nothing");
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
down(tree.y + 3, KeyModifiers::CONTROL)
)
.await
);
assert!(app.tree.marked.contains(&PathBuf::from("/repo/c")));
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
down(tree.y + 1, KeyModifiers::SHIFT)
)
.await
);
assert_eq!(app.tree.cursor, 1);
assert_eq!(app.tree.marked.len(), 3, "rows 1..=3 marked");
app.tree.clear_marks();
assert!(handle_mouse(&mut app, &view, &dispatcher, down(tree.y + 1, none)).await);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Drag(MouseButton::Left), 200, 200, none)
)
.await
);
assert_eq!(app.tree.cursor, 3);
assert_eq!(app.tree.marked.len(), 3);
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Up(MouseButton::Left), 200, 200, none),
)
.await;
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::ScrollUp, 5, tree.y, none)
)
.await
);
assert_eq!(app.tree.cursor, 2);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::ScrollDown, 5, tree.y, none)
)
.await
);
assert_eq!(app.tree.cursor, 3);
assert!(handle_mouse(&mut app, &view, &dispatcher, down(tree.y + 8, none)).await);
assert_eq!(app.tree.cursor, 3);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Down(MouseButton::Right), 5, tree.y, none)
)
.await
);
assert_eq!(app.tree.cursor, 0);
assert_eq!(app.drag, DragOrigin::None);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Drag(MouseButton::Right), 5, 3, none)
)
.await
);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Moved, 5, 3, none)
)
.await
);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::ScrollLeft, 5, 3, none)
)
.await
);
app.menu = Some(popup::ActionMenu::new(Vec::new()));
let cursor_before = app.tree.cursor;
assert!(handle_mouse(&mut app, &view, &dispatcher, down(tree.y, none)).await);
assert!(app.menu.is_none(), "the outside click dismissed the menu");
assert_eq!(
app.tree.cursor, cursor_before,
"and was consumed, not applied to the row underneath"
);
app.quit_confirm = true;
assert!(!handle_mouse(&mut app, &view, &dispatcher, down(tree.y, none)).await);
app.quit_confirm = false;
}
#[tokio::test]
async fn menu_mouse_hovers_arms_scrolls_and_invokes_under_the_pointer() {
let (mut app, dispatcher, mut commands) = test_app();
let view = view_with(&["/repo/a"]);
let none = KeyModifiers::NONE;
let colors = crate::cli::worktrees::ui::row_colors::KNOWN_ROW_COLORS;
app.menu = Some(popup::ActionMenu::new(vec![
popup::MenuItem::action(ActionKind::SetRowColor(colors[0]), "a"),
popup::MenuItem::action(ActionKind::SetRowColor(colors[1]), "b"),
]));
app.regions.popup = Some(mouse::PopupRegion {
submenu: None,
rect: Rect::new(9, 4, 12, 4),
items: vec![(0, Rect::new(10, 5, 10, 1)), (1, Rect::new(10, 6, 10, 1))],
});
let at = |kind, row| mouse_at(kind, 10, row, none);
assert!(handle_mouse(&mut app, &view, &dispatcher, at(MouseEventKind::Moved, 6)).await);
assert_eq!(app.menu.as_ref().unwrap().selected, 1);
assert!(!handle_mouse(&mut app, &view, &dispatcher, at(MouseEventKind::Moved, 6)).await);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Up(MouseButton::Right), 6)
)
.await
);
assert!(app.menu.is_some(), "an unarmed release does not dismiss");
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::ScrollUp, 6)
)
.await
);
assert_eq!(app.menu.as_ref().unwrap().selected, 0);
assert!(!app.menu.as_ref().unwrap().armed);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::ScrollDown, 6)
)
.await
);
assert_eq!(app.menu.as_ref().unwrap().selected, 1);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Down(MouseButton::Right), 5)
)
.await
);
assert!(app.menu.as_ref().unwrap().armed);
assert_eq!(app.menu.as_ref().unwrap().selected, 0);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Up(MouseButton::Right), 5)
)
.await
);
assert!(app.menu.is_none(), "invoking a command closes the menu");
assert!(matches!(
commands.try_recv(),
Ok(HubCommand::SetRowColor(..))
));
assert!(matches!(app.flow, ActionFlow::Done { .. }));
}
#[tokio::test]
async fn menu_command_move_and_copy_claude_session_start_the_relocate_wizard() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
app.menu = Some(popup::ActionMenu::new(Vec::new()));
invoke_menu_command(
&mut app,
&view,
&dispatcher,
popup::MenuCommand::Action(ActionKind::MoveClaudeSessionHere),
)
.await;
assert!(app.menu.is_none());
assert!(matches!(app.flow, ActionFlow::Failed { .. }));
app.flow = ActionFlow::Idle;
app.menu = Some(popup::ActionMenu::new(Vec::new()));
invoke_menu_command(
&mut app,
&view,
&dispatcher,
popup::MenuCommand::Action(ActionKind::CopyClaudeSessionHere),
)
.await;
assert!(app.menu.is_none());
assert!(matches!(app.flow, ActionFlow::Failed { .. }));
}
#[cfg(unix)]
#[tokio::test]
async fn ui_action_close_other_and_close_to_right_clear_the_here_cue_for_every_closed_tab() {
let (mut app, dispatcher, mut commands) = test_app();
let view = view_with(&["/repo/a"]);
let dirs: Vec<tempfile::TempDir> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect();
for d in &dirs {
let tab = scripted_tab(&app, "sleep 5", d.path().to_path_buf());
install_tab(&mut app, tab);
}
assert_eq!(app.panes.groups[0].tabs.len(), 3);
app.menu_tab = Some(panes::TabAddr { group: 0, tab: 1 });
app.menu = Some(popup::ActionMenu::new(Vec::new()));
invoke_menu_command(
&mut app,
&view,
&dispatcher,
popup::MenuCommand::Ui(popup::UiAction::CloseOtherTabs),
)
.await;
assert!(app.menu.is_none());
assert_eq!(app.panes.groups[0].tabs.len(), 1, "only the anchor remains");
let cleared: Vec<PathBuf> = std::iter::from_fn(|| commands.try_recv().ok())
.filter_map(|cmd| match cmd {
HubCommand::ClearOpenTab(p) => Some(p),
_ => None,
})
.collect();
assert!(cleared.contains(&dirs[0].path().to_path_buf()));
assert!(cleared.contains(&dirs[2].path().to_path_buf()));
assert!(!cleared.contains(&dirs[1].path().to_path_buf()));
let dirs2: Vec<tempfile::TempDir> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect();
for d in &dirs2 {
let tab = scripted_tab(&app, "sleep 5", d.path().to_path_buf());
install_tab(&mut app, tab);
}
assert_eq!(app.panes.groups[0].tabs.len(), 4, "the anchor plus 3 more");
app.menu_tab = Some(panes::TabAddr { group: 0, tab: 1 });
app.menu = Some(popup::ActionMenu::new(Vec::new()));
invoke_menu_command(
&mut app,
&view,
&dispatcher,
popup::MenuCommand::Ui(popup::UiAction::CloseTabsToRight),
)
.await;
assert_eq!(
app.panes.groups[0].tabs.len(),
2,
"the two tabs left of it survive"
);
let cleared: Vec<PathBuf> = std::iter::from_fn(|| commands.try_recv().ok())
.filter_map(|cmd| match cmd {
HubCommand::ClearOpenTab(p) => Some(p),
_ => None,
})
.collect();
assert!(cleared.contains(&dirs2[1].path().to_path_buf()));
assert!(cleared.contains(&dirs2[2].path().to_path_buf()));
assert!(!cleared.contains(&dirs2[0].path().to_path_buf()));
shutdown_all(&mut app);
}
#[tokio::test]
async fn ui_action_close_resets_focus_to_tree_when_the_layout_is_already_empty() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
app.focus = Focus::Terminal;
app.menu_tab = Some(panes::TabAddr { group: 0, tab: 0 });
app.menu = Some(popup::ActionMenu::new(Vec::new()));
invoke_menu_command(
&mut app,
&view,
&dispatcher,
popup::MenuCommand::Ui(popup::UiAction::CloseOtherTabs),
)
.await;
assert_eq!(app.focus, Focus::Tree);
}
#[tokio::test]
async fn ui_action_close_variants_without_a_menu_tab_are_a_no_op() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
app.menu_tab = None;
app.menu = Some(popup::ActionMenu::new(Vec::new()));
invoke_menu_command(
&mut app,
&view,
&dispatcher,
popup::MenuCommand::Ui(popup::UiAction::CloseTabsToRight),
)
.await;
assert!(app.menu.is_none(), "the command still closes the menu");
assert!(app.panes.is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn ui_action_select_all_clear_selection_and_scroll_to_bottom_touch_the_active_tab() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
let dir = tempfile::tempdir().unwrap();
let tab = scripted_tab(&app, "sleep 5", dir.path().to_path_buf());
install_tab(&mut app, tab);
for ui in [
popup::UiAction::SelectAll,
popup::UiAction::ClearSelection,
popup::UiAction::ScrollToBottom,
] {
app.menu = Some(popup::ActionMenu::new(Vec::new()));
invoke_menu_command(&mut app, &view, &dispatcher, popup::MenuCommand::Ui(ui)).await;
assert!(app.menu.is_none());
}
shutdown_all(&mut app);
}
#[tokio::test]
async fn clicking_a_submenu_row_toggles_it_open_and_shut() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
app.menu = Some(popup::ActionMenu::with_entries(
"Actions",
vec![
popup::MenuEntry::Item(popup::MenuItem::action(ActionKind::Focus, "Open Worktree")),
popup::MenuEntry::Submenu {
label: "Row Colour",
items: vec![popup::MenuItem::action(
ActionKind::SetRowColor("red"),
"red",
)],
},
],
));
app.regions.popup = Some(mouse::PopupRegion {
submenu: None,
rect: Rect::new(9, 4, 12, 4),
items: vec![(0, Rect::new(10, 5, 10, 1)), (1, Rect::new(10, 6, 10, 1))],
});
let none = KeyModifiers::NONE;
let click = |row: u16| {
[
mouse_at(MouseEventKind::Down(MouseButton::Left), 12, row, none),
mouse_at(MouseEventKind::Up(MouseButton::Left), 12, row, none),
]
};
for event in click(6) {
handle_mouse(&mut app, &view, &dispatcher, event).await;
}
assert_eq!(
app.menu.as_ref().and_then(|m| m.open_submenu),
Some(1),
"the first click on the submenu row opens it"
);
for event in click(6) {
handle_mouse(&mut app, &view, &dispatcher, event).await;
}
assert_eq!(
app.menu.as_ref().and_then(|m| m.open_submenu),
None,
"and the second closes it rather than reopening it"
);
assert!(app.menu.is_some(), "the menu itself stays open either way");
}
#[tokio::test]
async fn a_context_menu_closes_when_its_subject_row_disappears() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/wt-1", "/repo/wt-2"]);
let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
draw_into(&mut terminal, &view, &mut app);
let row = app.regions.tree.y + 2;
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Down(MouseButton::Right),
5,
row,
KeyModifiers::NONE,
),
)
.await;
assert!(app.menu.is_some(), "the right-click opened a menu");
assert!(
app.menu
.as_ref()
.and_then(|m| m.origin_row.as_ref())
.is_some(),
"and recorded which row it acts on"
);
let shrunk = view_with(&["/repo/wt-1"]);
draw_into(&mut terminal, &shrunk, &mut app);
assert!(app.menu.is_none(), "the menu closed with its subject");
assert!(app.notice.is_some(), "and said why");
app.menu = Some(popup::ActionMenu::new(menu::row_color_items()));
draw_into(&mut terminal, &shrunk, &mut app);
assert!(app.menu.is_some(), "no subject, nothing to outlive");
}
#[cfg(unix)]
#[tokio::test]
async fn a_double_click_on_a_row_opens_a_shell_tab_there() {
let (mut app, dispatcher, _commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let view = view_with(&[&dir.path().to_string_lossy()]);
let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
draw_into(&mut terminal, &view, &mut app);
let y = app.regions.tree.y + 1; let click = mouse_at(
MouseEventKind::Down(MouseButton::Left),
5,
y,
KeyModifiers::NONE,
);
handle_mouse(&mut app, &view, &dispatcher, click).await;
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Up(MouseButton::Left),
5,
y,
KeyModifiers::NONE,
),
)
.await;
handle_mouse(&mut app, &view, &dispatcher, click).await;
assert_eq!(app.tree.cursor, 1);
assert!(
!app.panes.is_empty() || app.notice.is_some(),
"double-click neither opened a tab nor reported a failure"
);
if !app.panes.is_empty() {
assert_eq!(app.focus, Focus::Terminal);
}
app.close_tab();
}
#[cfg(unix)]
#[tokio::test]
async fn mouse_on_the_terminal_selects_by_drag_word_and_line_and_scrolls() {
let (mut app, dispatcher, _commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let view = view_with(&[&dir.path().to_string_lossy()]);
let tab = scripted_tab(
&app,
"printf 'hello world\\nsecond line\\n'; sleep 3",
dir.path().to_path_buf(),
);
install_tab(&mut app, tab);
let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap();
draw_into(&mut terminal, &view, &mut app);
let grid = grid_rect(&app);
assert!(
wait_until(|| only_tab(&app).selection_to_string().is_none() && {
draw_into(&mut terminal, &view, &mut app);
buffer_text(&terminal).contains("second line")
})
.await,
"the child's output never rendered"
);
let none = KeyModifiers::NONE;
let at = |kind, col: u16, line: u16| mouse_at(kind, grid.x + col, grid.y + line, none);
app.focus = Focus::Tree;
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Down(MouseButton::Left), 0, 0)
)
.await
);
assert_eq!(app.focus, Focus::Terminal);
assert_eq!(app.drag, DragOrigin::Terminal { group: 0 });
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Drag(MouseButton::Left), 4, 0)
)
.await
);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Up(MouseButton::Left), 4, 0)
)
.await
);
assert_eq!(app.drag, DragOrigin::None);
assert_eq!(
only_tab(&app).selection_to_string().as_deref(),
Some("hello")
);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Down(MouseButton::Left), 0, 1)
)
.await
);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Drag(MouseButton::Left), 0, 0, none)
)
.await
);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Drag(MouseButton::Left), 500, 500, none)
)
.await
);
let clamped = only_tab(&app).selection_to_string().unwrap();
assert!(clamped.starts_with("second line"), "got {clamped:?}");
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Up(MouseButton::Left), 0, 1),
)
.await;
let dbl = at(MouseEventKind::Down(MouseButton::Left), 7, 0);
handle_mouse(&mut app, &view, &dispatcher, dbl).await;
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Up(MouseButton::Left), 7, 0),
)
.await;
handle_mouse(&mut app, &view, &dispatcher, dbl).await;
assert_eq!(
only_tab(&app).selection_to_string().as_deref(),
Some("world")
);
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Up(MouseButton::Left), 7, 0),
)
.await;
handle_mouse(&mut app, &view, &dispatcher, dbl).await;
let line = only_tab(&app).selection_to_string();
assert_eq!(line.as_deref().map(str::trim_end), Some("hello world"));
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Up(MouseButton::Left), 7, 0),
)
.await;
draw_into(&mut terminal, &view, &mut app); handle_chrome_key(&mut app, &view, ChromeKey::Copy);
assert_eq!(app.notice.as_deref(), Some("copied"));
assert!(only_tab(&app).selection_to_string().is_none());
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::ScrollUp, 1, 1)
)
.await
);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::ScrollDown, 1, 1)
)
.await
);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Down(MouseButton::Middle), 1, 1)
)
.await
);
assert_eq!(app.drag, DragOrigin::None);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::Moved, 1, 1)
)
.await
);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
at(MouseEventKind::ScrollRight, 1, 1)
)
.await
);
app.close_tab();
}
#[cfg(unix)]
#[tokio::test]
async fn a_child_that_asked_for_the_mouse_receives_it_unless_alt_is_held() {
let (mut app, dispatcher, _commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let view = view_with(&[&dir.path().to_string_lossy()]);
let script = "stty -echo -icanon min 1 time 0; printf '\\033[?1000h\\033[?1006h'; \
dd bs=1 count=9 2>/dev/null | od -An -c; sleep 2";
let tab = scripted_tab(&app, script, dir.path().to_path_buf());
install_tab(&mut app, tab);
let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap();
draw_into(&mut terminal, &view, &mut app);
let grid = grid_rect(&app);
assert!(
wait_until(|| only_tab(&app).mode().contains(TermMode::SGR_MOUSE)).await,
"the child never enabled mouse reporting"
);
let none = KeyModifiers::NONE;
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Down(MouseButton::Left),
grid.x,
grid.y,
KeyModifiers::ALT
)
)
.await
);
assert_eq!(app.drag, DragOrigin::Terminal { group: 0 });
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Up(MouseButton::Left),
grid.x,
grid.y,
KeyModifiers::ALT,
),
)
.await;
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Down(MouseButton::Left),
grid.x,
grid.y,
none
)
)
.await
);
assert_eq!(app.drag, DragOrigin::Child { group: 0 });
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Drag(MouseButton::Left),
grid.x + 1,
grid.y,
none
)
)
.await
);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Up(MouseButton::Left),
grid.x + 1,
grid.y,
none
)
)
.await
);
assert_eq!(app.drag, DragOrigin::None);
assert!(
wait_until(|| {
draw_into(&mut terminal, &view, &mut app);
let text: String = buffer_text(&terminal).split_whitespace().collect();
text.contains("033[<0;1;1M")
})
.await,
"the child never echoed the SGR press: {}",
buffer_text(&terminal)
);
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::ScrollUp, grid.x, grid.y, none)
)
.await
);
app.close_tab();
}
#[cfg(unix)]
#[tokio::test]
async fn right_click_on_a_reporting_child_forwards_unless_alt_is_held() {
let (mut app, dispatcher, _commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let view = view_with(&[&dir.path().to_string_lossy()]);
let script = "stty -echo -icanon min 1 time 0; printf '\\033[?1000h\\033[?1006h'; \
dd bs=1 count=9 2>/dev/null | od -An -c; sleep 2";
let tab = scripted_tab(&app, script, dir.path().to_path_buf());
install_tab(&mut app, tab);
let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap();
draw_into(&mut terminal, &view, &mut app);
let grid = grid_rect(&app);
assert!(
wait_until(|| only_tab(&app).mode().contains(TermMode::SGR_MOUSE)).await,
"the child never enabled mouse reporting"
);
let none = KeyModifiers::NONE;
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Down(MouseButton::Right),
grid.x,
grid.y,
KeyModifiers::ALT
)
)
.await
);
assert!(app.menu.is_some(), "alt-right-click should open the menu");
assert_eq!(
app.drag,
DragOrigin::None,
"opening the menu starts no drag"
);
app.menu = None;
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Down(MouseButton::Right),
grid.x,
grid.y,
none
)
)
.await
);
assert!(
app.menu.is_none(),
"the child took the mouse; no menu should open"
);
assert_eq!(app.drag, DragOrigin::Child { group: 0 });
assert!(
wait_until(|| {
draw_into(&mut terminal, &view, &mut app);
let text: String = buffer_text(&terminal).split_whitespace().collect();
text.contains("033[<2;1;1M")
})
.await,
"the child never echoed the right-click SGR press \
(or the alt-held click leaked bytes to it): {}",
buffer_text(&terminal)
);
app.close_tab();
}
#[cfg(unix)]
#[tokio::test]
async fn tab_chords_split_cycle_select_move_and_reset_the_stack() {
let (mut app, dispatcher, mut commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let here = dir.path().to_path_buf();
let view = view_with(&[&here.to_string_lossy(), "/repo/other"]);
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let tab = scripted_tab(&app, "sleep 5", here.clone());
install_tab(&mut app, tab);
draw_into(&mut terminal, &view, &mut app);
assert_eq!(app.panes.group_count(), 1);
app.tree.cursor = 1;
handle_key(&mut app, &view, &dispatcher, alt('s')).await;
assert_eq!(app.panes.group_count(), 2, "alt-s split the stack");
assert_eq!(app.panes.focused, 1);
draw_into(&mut terminal, &view, &mut app);
handle_key(
&mut app,
&view,
&dispatcher,
alt_key(KeyCode::Up, KeyModifiers::NONE),
)
.await;
assert_eq!(app.panes.focused, 0);
handle_key(
&mut app,
&view,
&dispatcher,
alt_key(KeyCode::Down, KeyModifiers::NONE),
)
.await;
assert_eq!(app.panes.focused, 1);
handle_key(
&mut app,
&view,
&dispatcher,
alt_key(KeyCode::Up, KeyModifiers::SHIFT),
)
.await;
assert_eq!(app.panes.group_count(), 1);
assert_eq!(app.panes.groups[0].tabs.len(), 2);
handle_key(
&mut app,
&view,
&dispatcher,
alt_key(KeyCode::Down, KeyModifiers::SHIFT),
)
.await;
assert!(app.notice.as_deref().unwrap_or("").contains("no group"));
assert_eq!(app.panes.groups[0].active, 1);
handle_key(&mut app, &view, &dispatcher, alt(']')).await;
assert_eq!(app.panes.groups[0].active, 0, "wraps");
handle_key(&mut app, &view, &dispatcher, alt('[')).await;
assert_eq!(app.panes.groups[0].active, 1);
handle_key(&mut app, &view, &dispatcher, alt('1')).await;
assert_eq!(app.panes.groups[0].active, 0);
handle_key(&mut app, &view, &dispatcher, alt('9')).await;
assert!(app.notice.as_deref().unwrap_or("").contains("no tab 9"));
app.panes.weights = vec![7];
handle_key(&mut app, &view, &dispatcher, alt('0')).await;
assert_eq!(app.panes.weights, vec![1]);
while let Ok(cmd) = commands.try_recv() {
drop(cmd); }
handle_key(&mut app, &view, &dispatcher, alt('w')).await;
assert!(app.panes.any_alive(), "one tab left");
assert!(
!drained_clear_open_tab(&mut commands, &here),
"the cue stays while a tab remains"
);
handle_key(&mut app, &view, &dispatcher, alt('w')).await;
assert!(app.panes.is_empty());
assert!(
drained_clear_open_tab(&mut commands, &here),
"the last close clears the cue"
);
assert_eq!(app.focus, Focus::Tree);
}
#[cfg(unix)]
#[tokio::test]
async fn a_split_is_refused_when_the_pane_is_too_short_to_draw_it() {
let (mut app, dispatcher, _commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let here = dir.path().to_path_buf();
let view = view_with(&[&here.to_string_lossy()]);
let tab = scripted_tab(&app, "sleep 5", here);
install_tab(&mut app, tab);
let mut terminal = Terminal::new(TestBackend::new(100, 7)).unwrap();
draw_into(&mut terminal, &view, &mut app);
app.tree.cursor = 1;
handle_key(&mut app, &view, &dispatcher, alt('s')).await;
assert_eq!(app.panes.group_count(), 1, "the split was refused");
assert!(app
.notice
.as_deref()
.unwrap_or("")
.contains("not enough height"));
app.close_tab();
}
#[cfg(unix)]
#[tokio::test]
async fn the_tab_strip_activates_closes_and_the_splitter_resizes() {
let (mut app, dispatcher, _commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let here = dir.path().to_path_buf();
let view = view_with(&[&here.to_string_lossy()]);
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
for _ in 0..2 {
let tab = scripted_tab(&app, "sleep 5", here.clone());
install_tab(&mut app, tab);
}
let tab = scripted_tab(&app, "sleep 5", here);
app.panes.split(move |_| Ok(tab)).unwrap();
draw_into(&mut terminal, &view, &mut app);
assert_eq!(app.regions.groups.len(), 2);
assert_eq!(app.regions.splitters.len(), 1);
let strip = app.regions.groups[0].strip;
let spans = app.regions.groups[0].tab_spans.clone();
assert_eq!(spans.len(), 2, "two tabs on the strip");
let none = KeyModifiers::NONE;
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Down(MouseButton::Left),
spans[0].0,
strip.y,
none
)
)
.await
);
assert_eq!(app.panes.focused, 0);
assert_eq!(app.panes.groups[0].active, 0);
assert_eq!(app.focus, Focus::Terminal);
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::ScrollDown, spans[0].0, strip.y, none)
)
.await
);
assert_eq!(app.panes.groups[0].active, 1);
let splitter = app.regions.splitters[0];
let before = app.panes.weights.clone();
assert!(
!handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(MouseEventKind::Down(MouseButton::Left), 60, splitter, none)
)
.await
);
assert_eq!(app.drag, DragOrigin::Splitter { index: 0 });
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Drag(MouseButton::Left),
60,
splitter - 4,
none
)
)
.await
);
assert_ne!(app.panes.weights, before, "the drag moved the boundary");
draw_into(&mut terminal, &view, &mut app);
let rects = app.panes.arrange(app.terminal_area.unwrap());
assert!(rects.iter().all(|r| r.body.height >= 3));
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Up(MouseButton::Left),
60,
splitter - 4,
none,
),
)
.await;
assert_eq!(app.drag, DragOrigin::None);
draw_into(&mut terminal, &view, &mut app);
let spans = app.regions.groups[0].tab_spans.clone();
let strip_y = app.regions.groups[0].strip.y;
let before = app.panes.groups[0].tabs.len();
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Down(MouseButton::Middle),
spans[0].0,
strip_y,
none
)
)
.await
);
assert_eq!(app.panes.groups[0].tabs.len(), before - 1);
shutdown_all(&mut app);
}
#[cfg(unix)]
#[tokio::test]
async fn right_clicking_a_tab_focuses_it_before_opening_the_strip_menu() {
let (mut app, dispatcher, _commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let here = dir.path().to_path_buf();
let view = view_with(&[&here.to_string_lossy()]);
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
for _ in 0..2 {
let tab = scripted_tab(&app, "sleep 5", here.clone());
install_tab(&mut app, tab);
}
draw_into(&mut terminal, &view, &mut app);
let strip = app.regions.groups[0].strip;
let spans = app.regions.groups[0].tab_spans.clone();
assert_eq!(spans.len(), 2, "two tabs on the strip");
assert_eq!(app.panes.groups[0].active, 1, "the second tab opened last");
let none = KeyModifiers::NONE;
assert!(
handle_mouse(
&mut app,
&view,
&dispatcher,
mouse_at(
MouseEventKind::Down(MouseButton::Right),
spans[0].0,
strip.y,
none
)
)
.await
);
assert_eq!(
app.panes.groups[0].active, 0,
"right-click focuses the tab under the pointer, like left-click"
);
assert_eq!(app.focus, Focus::Terminal);
let menu = app.menu.as_ref().expect("the strip menu opened");
assert_eq!(menu.title, "Tab");
assert_eq!(
menu.anchor,
popup::Anchor::At {
col: spans[0].0,
row: strip.y
}
);
assert_eq!(app.menu_tab, Some(panes::TabAddr { group: 0, tab: 0 }));
shutdown_all(&mut app);
}
#[tokio::test]
async fn the_command_palette_filters_by_label_and_opens_the_menu_at_the_match() {
let (mut app, dispatcher, _commands) = test_app();
let view = view_with(&["/repo/a"]);
app.tree.cursor = 1;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char(':'))).await;
assert!(app.prompt.is_some());
for c in "copy dir".chars() {
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char(c))).await;
}
assert!(app.menu.is_none(), "typing did not trigger a tree command");
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
draw_into(&mut terminal, &view, &mut app);
assert!(buffer_text(&terminal).contains("copy dir"));
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Backspace)).await;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Enter)).await;
assert!(app.prompt.is_none());
assert!(app.menu.is_some(), "the palette opened the action menu");
draw_into(&mut terminal, &view, &mut app);
app.menu = None;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char(':'))).await;
for c in "zzzz".chars() {
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char(c))).await;
}
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Enter)).await;
assert!(app.menu.is_none());
assert!(app.notice.as_deref().unwrap_or("").contains("no command"));
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char(':'))).await;
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Esc)).await;
assert!(app.prompt.is_none());
}
#[test]
fn filter_palette_matches_case_insensitively_and_keeps_everything_when_empty() {
let items = vec![
popup::MenuItem::action(ActionKind::CopyDirectory, "Copy Directory"),
popup::MenuItem::action(ActionKind::Focus, "Focus Worktree"),
];
assert_eq!(filter_palette(items.clone(), "").len(), 2);
assert_eq!(filter_palette(items.clone(), "copy").len(), 1);
assert_eq!(filter_palette(items.clone(), "COPY").len(), 1);
assert_eq!(filter_palette(items.clone(), "work").len(), 1);
assert!(filter_palette(items, "nothing").is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn find_searches_the_scrollback_and_reports_a_miss() {
let (mut app, dispatcher, _commands) = test_app();
let dir = tempfile::tempdir().unwrap();
let here = dir.path().to_path_buf();
let view = view_with(&[&here.to_string_lossy()]);
handle_key(&mut app, &view, &dispatcher, alt('f')).await;
assert!(app.prompt.is_none());
assert!(app.notice.as_deref().unwrap_or("").contains("no terminal"));
let tab = scripted_tab(
&app,
"i=0; while [ $i -lt 60 ]; do echo line-$i; i=$((i+1)); done; \
echo NEEDLE-HERE; i=0; while [ $i -lt 60 ]; do echo tail-$i; i=$((i+1)); done; \
sleep 3",
here,
);
install_tab(&mut app, tab);
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
assert!(
wait_until(|| {
draw_into(&mut terminal, &view, &mut app);
buffer_text(&terminal).contains("tail-59")
})
.await,
"the child never finished printing"
);
handle_key(&mut app, &view, &dispatcher, alt('f')).await;
assert!(app.prompt.is_some());
for c in "needle".chars() {
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char(c))).await;
}
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Enter)).await;
let prompt = app.prompt.as_ref().expect("the find prompt stays open");
assert_eq!(prompt.status, "", "case-insensitive match should be found");
draw_into(&mut terminal, &view, &mut app);
assert!(
buffer_text(&terminal).contains("NEEDLE-HERE"),
"the match was scrolled into view"
);
for _ in 0..6 {
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Backspace)).await;
}
for c in "nowhere".chars() {
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Char(c))).await;
}
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Enter)).await;
assert_eq!(app.prompt.as_ref().unwrap().status, "no match");
handle_key(&mut app, &view, &dispatcher, press(KeyCode::Esc)).await;
assert!(app.prompt.is_none());
shutdown_all(&mut app);
}
#[tokio::test]
async fn visible_rows_are_reported_once_per_change_not_once_per_frame() {
let (mut app, _dispatcher, mut commands) = test_app();
let view = view_with(&["/repo/a", "/repo/b"]);
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
draw_into(&mut terminal, &view, &mut app);
let first = commands.try_recv();
match first {
Ok(HubCommand::SetVisibleRows(rows)) => {
assert_eq!(rows.len(), 2, "both worktree rows are visible");
assert!(rows.iter().all(|p| p.starts_with("/repo/")));
assert!(
!rows.contains(&PathBuf::from("/repo")),
"the repo header is not a worktree row"
);
}
other => panic!("expected SetVisibleRows, got {other:?}"),
}
for _ in 0..5 {
draw_into(&mut terminal, &view, &mut app);
}
assert!(
commands.try_recv().is_err(),
"an unchanged visible set was re-reported"
);
let mut small = Terminal::new(TestBackend::new(80, 4)).unwrap();
draw_into(&mut small, &view, &mut app);
match commands.try_recv() {
Ok(HubCommand::SetVisibleRows(rows)) => assert!(
rows.len() < 2,
"a 4-row pane cannot show both worktrees: {rows:?}"
),
other => panic!("expected a new SetVisibleRows, got {other:?}"),
}
}
#[tokio::test]
async fn alt_shift_c_clears_every_row_colour() {
let (mut app, dispatcher, mut commands) = test_app();
let view = view_with(&["/repo/a"]);
app.tree.cursor = 1;
handle_key(
&mut app,
&view,
&dispatcher,
KeyEvent::new(KeyCode::Char('C'), KeyModifiers::ALT),
)
.await;
assert!(matches!(
commands.try_recv(),
Ok(HubCommand::ClearAllRowColors)
));
assert_eq!(app.notice.as_deref(), Some("cleared every row colour"));
handle_key(
&mut app,
&view,
&dispatcher,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT | KeyModifiers::SHIFT),
)
.await;
assert!(matches!(
commands.try_recv(),
Ok(HubCommand::ClearAllRowColors)
));
handle_key(&mut app, &view, &dispatcher, alt('c')).await;
assert_eq!(app.notice.as_deref(), Some("nothing selected"));
assert!(commands.try_recv().is_err());
}
#[test]
fn layout_gives_the_tree_the_full_width_until_a_tab_exists() {
let area = Rect::new(0, 0, 100, 30);
let without = layout(area, false);
assert_eq!(without.tree.width, 100);
assert!(without.terminal.is_none());
assert_eq!(without.status.height, 1);
let with = layout(area, true);
let terminal = with.terminal.expect("a terminal pane");
assert_eq!(with.tree.width + terminal.width, 100);
assert!(terminal.width >= 20);
assert_eq!(with.tree.height, 29);
}
}