quinjet 0.0.49

A fast, live, keyboard-first Git source-control interface for the terminal
use std::time::Instant;

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
use ratatui::backend::TestBackend;

use super::*;
use crate::app::{AppEffect, RepositoryTabDrag, RepositoryTabMenu};
use crate::integration::Client;
use crate::ssh::SshMachine;
use crate::tabs::{RepositoryTabs, TabId};

fn app_with_tabs(count: usize, active: usize) -> (App, Vec<TabId>) {
    let mut tabs = RepositoryTabs::new("project-0", "/repo-0", ());
    let mut ids = vec![tabs.active_id().expect("initial tab")];
    for index in 1..count {
        ids.push(tabs.append(format!("project-{index}"), format!("/repo-{index}"), ()));
    }
    assert!(tabs.activate(ids[active]));
    let mut app = App::new("/repo-0", "project-0");
    app.set_repository_tabs(tabs.infos());
    (app, ids)
}

#[test]
fn repository_tab_hits_follow_repository_order() {
    let (mut app, ids) = app_with_tabs(3, 1);
    let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();

    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();

    let hits = &app.geometry.repository_tab_hits;
    assert_eq!(hits.iter().map(|hit| hit.id).collect::<Vec<_>>(), ids);
    assert!(
        hits.iter()
            .all(|hit| hit.area.y == 0 && hit.area.height == 1)
    );
    assert!(hits.iter().all(|hit| {
        hit.close.width == 3
            && hit.close.right() == hit.area.right().saturating_sub(1)
            && hit.area.contains((hit.close.x, hit.close.y).into())
    }));
    assert!(hits.windows(2).all(|pair| {
        let [left, right] = pair else {
            return false;
        };
        left.area.right() <= right.area.x
    }));
    assert_eq!(app.geometry.repository_tab_open.right(), 120);
    assert!(
        hits.last()
            .is_some_and(|hit| hit.area.right() <= app.geometry.repository_tab_open.x)
    );
}

#[test]
fn repository_tabs_render_close_icons_and_a_drag_destination() {
    let (mut app, ids) = app_with_tabs(3, 0);
    app.repository_tab_drag = Some(RepositoryTabDrag {
        id: ids[0],
        target: Some(ids[2]),
    });
    let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();

    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();

    let row = terminal.backend().buffer().content()[..120]
        .iter()
        .map(ratatui::buffer::Cell::symbol)
        .collect::<String>();
    assert_eq!(row.matches('×').count(), 3);
    assert!(row.contains(''));
}

#[test]
fn edith_client_hides_repository_tab_close_icons() {
    let (mut app, _) = app_with_tabs(1, 0);
    app.set_host_client(Some(Client::Edith));
    let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();

    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();

    let row = terminal.backend().buffer().content()[..120]
        .iter()
        .map(ratatui::buffer::Cell::symbol)
        .collect::<String>();
    assert!(!row.contains('×'));
    assert!(
        app.geometry
            .repository_tab_hits
            .iter()
            .all(|hit| hit.close.is_empty())
    );
}

#[test]
fn mixed_repository_tabs_show_their_owning_machine() {
    let mut app = App::new("/repo", "one");
    app.set_repository_tabs(vec![
        crate::tabs::TabInfo {
            id: TabId::new(0),
            title: "one".to_owned(),
            root: "/local/one".into(),
            machine: Some("macbook.local".to_owned()),
            active: true,
        },
        crate::tabs::TabInfo {
            id: TabId::new(1),
            title: "two".to_owned(),
            root: "/remote/two".into(),
            machine: Some("tof".to_owned()),
            active: false,
        },
    ]);
    let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();

    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();

    let row = terminal.backend().buffer().content()[..80]
        .iter()
        .map(ratatui::buffer::Cell::symbol)
        .collect::<String>();
    assert!(row.contains("one @macbook"));
    assert!(row.contains("two @tof"));
}

#[test]
fn repository_tab_strip_has_a_continuous_bottom_separator() {
    let (mut app, _) = app_with_tabs(2, 1);
    let theme = Theme::default();
    let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();

    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();

    for column in 0..80 {
        let cell = &terminal.backend().buffer()[(column, 1)];
        assert_eq!(cell.symbol(), "");
        assert_eq!(cell.fg, theme.border);
    }
    assert_eq!(app.geometry.changes_tab.y, 2);
}

#[test]
fn repository_tab_strip_appears_only_for_multiple_tabs() {
    let (mut single, _) = app_with_tabs(1, 0);
    let mut single_terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();

    single_terminal
        .draw(|frame| draw(frame, &mut single, &Theme::default()))
        .unwrap();

    assert_eq!(single.geometry.repository_tab_hits, Vec::new());
    assert_eq!(single.geometry.repository_tab_open, Rect::default());
    assert_eq!(single.geometry.repository_tab_previous, Rect::default());
    assert_eq!(single.geometry.repository_tab_next, Rect::default());
    assert_eq!(single.geometry.changes_tab.y, 0);

    let (mut multiple, ids) = app_with_tabs(2, 1);
    let mut multiple_terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();

    multiple_terminal
        .draw(|frame| draw(frame, &mut multiple, &Theme::default()))
        .unwrap();

    assert_eq!(
        multiple
            .geometry
            .repository_tab_hits
            .iter()
            .map(|hit| hit.id)
            .collect::<Vec<_>>(),
        ids
    );
    assert_eq!(
        multiple.geometry.repository_tab_open,
        Rect::new(75, 0, 5, 1)
    );
    assert_eq!(multiple.geometry.repository_tab_previous, Rect::default());
    assert_eq!(multiple.geometry.repository_tab_next, Rect::default());
    assert_eq!(multiple.geometry.changes_tab.y, 2);
}

#[test]
fn overflowing_repository_tabs_keep_the_active_tab_visible() {
    let (mut app, ids) = app_with_tabs(10, 8);
    let mut terminal = Terminal::new(TestBackend::new(72, 18)).unwrap();

    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();

    let visible = app
        .geometry
        .repository_tab_hits
        .iter()
        .map(|hit| hit.id)
        .collect::<Vec<_>>();
    assert_eq!(visible, ids[4..9]);
    assert!(visible.contains(&ids[8]));
    assert!(!visible.contains(&ids[9]));
    assert_eq!(app.geometry.repository_tab_previous, Rect::new(0, 0, 3, 1));
    assert_eq!(app.geometry.repository_tab_next, Rect::new(64, 0, 3, 1));
}

#[test]
fn repository_tab_menu_registers_four_clamped_actions() {
    let (mut app, ids) = app_with_tabs(2, 0);
    app.repository_tab_menu = Some(RepositoryTabMenu {
        id: ids[0],
        column: 79,
        row: 23,
        selected: 2,
    });
    let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();

    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();

    let hits = &app.geometry.repository_tab_menu_hits;
    assert_eq!(hits.len(), RepositoryTabAction::ALL.len());
    assert_eq!(
        hits.iter().map(|(_, action)| *action).collect::<Vec<_>>(),
        RepositoryTabAction::ALL.to_vec()
    );
    assert_eq!(
        hits.first().map(|(area, _)| *area),
        Some(Rect::new(59, 19, 20, 1))
    );
    assert_eq!(
        hits.last().map(|(area, _)| *area),
        Some(Rect::new(59, 22, 20, 1))
    );
    assert!(
        hits.iter()
            .all(|(area, _)| area.right() <= 80 && area.bottom() <= 24)
    );
}

#[test]
fn new_tab_picker_names_its_destination() {
    let mut app = App::new("/repo", "project");
    drop(app.handle_key(
        KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT),
        Instant::now(),
    ));
    let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();

    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();

    let rendered = terminal
        .backend()
        .buffer()
        .content()
        .iter()
        .map(ratatui::buffer::Cell::symbol)
        .collect::<String>();
    assert!(rendered.contains("Open in new tab"));
}

#[test]
fn project_picker_keeps_host_and_ssh_navigation_in_one_layout() {
    let mut app = App::new("/repo", "project");
    app.ssh_context = Some(SshContext {
        current: "current-host".to_owned(),
        machines: vec![
            SshMachine {
                target: "Pulkits-MacBook-Pro.local".to_owned(),
                folder: "/host".into(),
                accessible: true,
                uses: 0,
                local: true,
            },
            SshMachine {
                target: "busy-host".to_owned(),
                folder: "/busy".into(),
                accessible: true,
                uses: 14,
                local: false,
            },
            SshMachine {
                target: "current-host".to_owned(),
                folder: "/current".into(),
                accessible: true,
                uses: 3,
                local: false,
            },
        ],
        tabs: crate::ssh::SshTabs::default(),
    });
    let now = Instant::now();
    let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap();

    drop(app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), now));
    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();
    let rendered = terminal.backend().to_string();
    assert!(rendered.contains("Switch project"));
    assert!(rendered.contains("Machine"));
    assert!(rendered.contains("Pulkits-MacBook-Pro.local"));
    assert!(rendered.contains("busy-host"));
    assert!(rendered.contains("current-host"));
    assert!(rendered.contains("Tab next machine"));
    assert!(!rendered.contains("choose machine"));
    assert!(
        app.geometry
            .modal_action_hits
            .iter()
            .any(|(_, action)| *action == ModalAction::SwitchSshMachine(1))
    );

    drop(app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), now));
    drop(app.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT), now));
    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();
    let rendered = terminal.backend().to_string();
    assert!(rendered.contains("Open in new tab"));
    assert!(rendered.contains("Pulkits-MacBook-Pro.local"));
    assert!(rendered.contains("current-host"));

    let button = app
        .geometry
        .modal_action_hits
        .iter()
        .find(|(_, action)| *action == ModalAction::SwitchSshMachine(1))
        .map(|(area, _)| *area)
        .expect("SSH machine chip");
    let effects = app.handle_mouse(
        MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            column: button.x,
            row: button.y,
            modifiers: KeyModifiers::NONE,
        },
        now,
    );
    assert!(matches!(
        effects.as_slice(),
        [AppEffect::SwitchSshMachine(crate::ssh::SshSwitch {
            index: 1,
            mode: crate::ssh::SshProjectOpenMode::New,
        })]
    ));
    assert!(matches!(app.modal, Some(Modal::Projects { .. })));
}

#[test]
fn project_picker_shows_the_destination_while_opening() {
    let mut app = App::new("/repo", "project");
    app.modal = Some(Modal::Projects {
        groups: Vec::new(),
        selected: 0,
        query: crate::app::TextBuffer::default(),
        collapsed: HashSet::new(),
        loading: false,
        opening: Some("/Users/pulkit/scripts/quinjet-feature".into()),
        mode: ProjectOpenMode::CurrentTab,
    });
    let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();

    terminal
        .draw(|frame| draw(frame, &mut app, &Theme::default()))
        .unwrap();

    let rendered = terminal.backend().to_string();
    assert!(rendered.contains("Opening project…"));
    assert!(rendered.contains("/Users/pulkit/scripts/quinjet-feature"));
    assert!(!rendered.contains("Enter switch tab"));
}