mod actions;
pub mod agent;
mod ansi;
mod app;
mod diff;
mod diff_ops;
mod keymap;
mod scope;
mod settings;
mod sort;
pub mod spinner;
pub mod ui;
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::backend::CrosstermBackend;
use std::io;
use std::sync::mpsc;
use std::time::Duration;
use crate::git;
use crate::github;
use crate::multiplexer::{create_backend, detect_backend};
use self::actions::apply_action;
use self::app::{App, AppEvent, DashboardTab, ViewMode};
use self::diff_ops::DiffOps;
use self::keymap::{Context, action_for_key};
use self::spinner::SPINNER_FRAME_COUNT;
use self::ui::ui;
fn get_context(app: &App) -> Context {
match &app.view_mode {
ViewMode::Dashboard => match app.active_tab {
DashboardTab::Agents => {
if app.filter_active {
Context::DashboardFilter
} else if app.input_mode {
Context::DashboardInput
} else {
Context::DashboardNormal
}
}
DashboardTab::Worktrees => {
if app.worktree_filter_active {
Context::WorktreeFilter
} else {
Context::WorktreeNormal
}
}
},
ViewMode::Diff(diff) => {
if diff.patch_mode {
if diff.comment_input.is_some() {
Context::Comment
} else {
Context::Patch
}
} else {
Context::DiffNormal
}
}
}
}
fn handle_mouse_event(app: &mut App, kind: MouseEventKind) {
if let ViewMode::Diff(ref mut diff_view) = app.view_mode {
let total_lines = if diff_view.patch_mode {
diff_view
.hunks
.get(diff_view.current_hunk)
.map(|h| h.parsed_lines.len())
.unwrap_or(0)
} else {
diff_view.line_count
};
match kind {
MouseEventKind::ScrollUp => {
diff_view.scroll = diff_view.scroll.saturating_sub(3);
}
MouseEventKind::ScrollDown => {
let max_scroll = total_lines.saturating_sub(diff_view.viewport_height as usize);
diff_view.scroll = (diff_view.scroll + 3).min(max_scroll);
}
_ => {}
}
}
}
pub fn run(cli_preview_size: Option<u8>, open_diff: bool, session_filter: bool) -> Result<()> {
let mux = create_backend(detect_backend());
if !mux.is_running().unwrap_or(false) {
println!("No {} server running.", mux.name());
return Ok(());
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = ratatui::Terminal::new(backend)?;
let (event_tx, event_rx) = mpsc::channel::<AppEvent>();
let mut app = App::new(mux, session_filter, event_tx.clone())?;
if let Some(size) = cli_preview_size {
app.preview_size = size;
}
if open_diff && let Some(ref current_path) = app.current_worktree {
if let Some(idx) = app.agents.iter().position(|a| &a.path == current_path) {
app.table_state.select(Some(idx));
app.load_diff(false); }
}
while crossterm::event::poll(Duration::ZERO).unwrap_or(false) {
if crossterm::event::read().is_err() {
break;
}
}
let input_tx = event_tx;
std::thread::spawn(move || {
while let Ok(ev) = event::read() {
if input_tx.send(AppEvent::Terminal(ev)).is_err() {
break; }
}
});
let tick_rate = Duration::from_millis(250);
let mut last_tick = std::time::Instant::now();
let refresh_interval = Duration::from_secs(2);
let mut last_refresh = std::time::Instant::now();
let preview_refresh_interval_normal = Duration::from_millis(500);
let preview_refresh_interval_input = Duration::from_millis(100);
let mut last_preview_refresh = std::time::Instant::now();
loop {
terminal.draw(|f| ui(f, &mut app))?;
let current_preview_interval = if app.input_mode {
preview_refresh_interval_input
} else {
preview_refresh_interval_normal
};
let time_until_preview =
current_preview_interval.saturating_sub(last_preview_refresh.elapsed());
let time_until_tick = tick_rate.saturating_sub(last_tick.elapsed());
let timeout = time_until_tick.min(time_until_preview);
match event_rx.recv_timeout(timeout) {
Ok(event) => {
handle_event(&mut app, event, &mut last_preview_refresh);
while let Ok(event) = event_rx.try_recv() {
handle_event(&mut app, event, &mut last_preview_refresh);
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
if last_tick.elapsed() >= tick_rate {
last_tick = std::time::Instant::now();
app.spinner_frame = (app.spinner_frame + 1) % SPINNER_FRAME_COUNT;
}
if last_refresh.elapsed() >= refresh_interval {
app.refresh();
last_refresh = std::time::Instant::now();
}
if app.mux.supports_preview() && last_preview_refresh.elapsed() >= current_preview_interval
{
app.refresh_preview();
last_preview_refresh = std::time::Instant::now();
}
if app.should_quit || app.should_jump {
break;
}
}
git::save_status_cache(&app.git_statuses);
github::save_pr_cache(app.pr_statuses());
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
fn handle_event(app: &mut App, event: AppEvent, last_preview_refresh: &mut std::time::Instant) {
match event {
AppEvent::Terminal(terminal_event) => {
handle_terminal_event(app, terminal_event, last_preview_refresh);
}
bg_event => app.apply_event(bg_event),
}
}
fn handle_terminal_event(
app: &mut App,
event: Event,
last_preview_refresh: &mut std::time::Instant,
) {
if let Event::Mouse(mouse) = &event {
handle_mouse_event(app, mouse.kind);
return;
}
let Event::Key(key) = event else { return };
if key.kind != KeyEventKind::Press {
return;
}
if app.show_help {
app.show_help = false;
return;
}
if app.pending_kill_pane_id.is_some() {
if key.code == crossterm::event::KeyCode::Char('y') {
app.confirm_kill();
} else {
app.pending_kill_pane_id = None;
}
return;
}
if app.pending_remove.is_some() {
match key.code {
crossterm::event::KeyCode::Char('y') => app.confirm_remove(),
crossterm::event::KeyCode::Char('k') => app.toggle_remove_keep_branch(),
crossterm::event::KeyCode::Char('f') => app.arm_remove_force(),
_ => app.pending_remove = None, }
return;
}
if app.pending_base_picker.is_some() {
match key.code {
crossterm::event::KeyCode::Char('j') | crossterm::event::KeyCode::Down => {
app.base_picker_down()
}
crossterm::event::KeyCode::Char('k') | crossterm::event::KeyCode::Up => {
app.base_picker_up()
}
crossterm::event::KeyCode::Enter => app.confirm_base_picker(),
crossterm::event::KeyCode::Backspace => app.base_picker_filter_delete(),
crossterm::event::KeyCode::Esc => app.pending_base_picker = None,
crossterm::event::KeyCode::Char(c) => app.base_picker_filter_append(c),
_ => {}
}
return;
}
if app.pending_project_picker.is_some() {
match key.code {
crossterm::event::KeyCode::Char('j') | crossterm::event::KeyCode::Down => {
app.project_picker_down()
}
crossterm::event::KeyCode::Char('k') | crossterm::event::KeyCode::Up => {
app.project_picker_up()
}
crossterm::event::KeyCode::Enter => app.confirm_project_picker(),
crossterm::event::KeyCode::Backspace => app.project_picker_filter_delete(),
crossterm::event::KeyCode::Esc => app.pending_project_picker = None,
crossterm::event::KeyCode::Char(c) => app.project_picker_filter_append(c),
_ => {}
}
return;
}
if let Some(ref state) = app.pending_add_worktree {
if state.editing_base {
match key.code {
crossterm::event::KeyCode::Tab => app.add_worktree_base_tab_complete(),
crossterm::event::KeyCode::Enter => app.add_worktree_toggle_base(),
crossterm::event::KeyCode::Backspace => app.add_worktree_base_delete(),
crossterm::event::KeyCode::Esc => app.add_worktree_toggle_base(),
crossterm::event::KeyCode::Char('w')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.add_worktree_base_delete_word()
}
crossterm::event::KeyCode::Char('u')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.add_worktree_base_clear()
}
crossterm::event::KeyCode::Char('b')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.add_worktree_toggle_base()
}
crossterm::event::KeyCode::Char(c) => app.add_worktree_base_append(c),
_ => {}
}
} else {
match key.code {
crossterm::event::KeyCode::Down => app.add_worktree_down(),
crossterm::event::KeyCode::Up => app.add_worktree_up(),
crossterm::event::KeyCode::Tab => app.add_worktree_tab_complete(),
crossterm::event::KeyCode::Enter => app.add_worktree_confirm_selection(),
crossterm::event::KeyCode::Backspace => app.add_worktree_delete(),
crossterm::event::KeyCode::Esc => app.pending_add_worktree = None,
crossterm::event::KeyCode::Char('w')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.add_worktree_delete_word()
}
crossterm::event::KeyCode::Char('u')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.add_worktree_clear()
}
crossterm::event::KeyCode::Char('b')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.add_worktree_toggle_base()
}
crossterm::event::KeyCode::Char('p')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.add_worktree_toggle_pr_mode()
}
crossterm::event::KeyCode::Char(c) => app.add_worktree_append(c),
_ => {}
}
}
return;
}
if app.pending_sweep.is_some() {
match key.code {
crossterm::event::KeyCode::Char(' ') => app.sweep_toggle(),
crossterm::event::KeyCode::Char('j') | crossterm::event::KeyCode::Down => {
app.sweep_down()
}
crossterm::event::KeyCode::Char('k') | crossterm::event::KeyCode::Up => app.sweep_up(),
crossterm::event::KeyCode::Enter => app.confirm_sweep(),
_ => app.pending_sweep = None, }
return;
}
let ctx = get_context(app);
if ctx == Context::DiffNormal
&& let ViewMode::Diff(ref diff) = app.view_mode
&& diff.is_branch_diff
&& let Some(actions::Action::EnterPatchMode) = action_for_key(ctx, key)
{
return;
}
if let Some(action) = action_for_key(ctx, key) {
let refreshed_preview = apply_action(app, action);
if refreshed_preview {
*last_preview_refresh = std::time::Instant::now();
}
}
}