use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::Parser;
use crossterm::ExecutableCommand;
use crossterm::event::{
self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, poll,
};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::{Backend, CrosstermBackend};
use crate::app::{App, HunkOutcome, Screen};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Key {
Up,
Down,
Enter,
Esc,
PageUp,
PageDown,
Char(char),
Ctrl(char),
MouseClick(u16),
}
mod app;
mod config;
mod git;
mod ui;
#[derive(Parser)]
#[command(name = "tuit", version)]
struct Cli {
#[arg(short, long, value_name = "PATH")]
repo: Option<PathBuf>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let explicit_repo = cli.repo.is_some();
let repo_path = cli
.repo
.map(|p| p.canonicalize().unwrap_or(p))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
if explicit_repo {
validate_repo_path(&repo_path)?;
}
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
io::stdout().execute(event::EnableMouseCapture)?;
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
let cfg = config::load().unwrap_or_else(|_| config::Config {
theme: "default".into(),
colors: config::default_colors(),
poll_interval_ms: 2000,
notification_timeout_ms: 3000,
});
let mut app = App::new(cfg, repo_path);
app.load_commits();
terminal.draw(|frame| {
ui::render(frame, &app);
})?;
let poll_interval = Duration::from_millis(app.poll_interval_ms);
loop {
if poll(poll_interval)? {
if let Some(key) = read_key()? {
handle_input(&mut app, key);
if drain_hunk_launch(&mut app, &mut launch_hunk) {
let _ = terminal.clear();
}
}
}
app.poll();
terminal.draw(|frame| {
ui::render(frame, &app);
})?;
if app.should_quit {
break;
}
}
disable_raw_mode()?;
io::stdout().execute(event::DisableMouseCapture)?;
io::stdout().execute(LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn read_key() -> Result<Option<Key>> {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
let k = match key.code {
KeyCode::Esc => Key::Esc,
KeyCode::Enter => Key::Enter,
KeyCode::Up => Key::Up,
KeyCode::Down => Key::Down,
KeyCode::PageUp => Key::PageUp,
KeyCode::PageDown => Key::PageDown,
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => Key::Ctrl(c),
KeyCode::Char(c) => Key::Char(c),
_ => return Ok(None),
};
Ok(Some(k))
}
Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
Ok(Some(Key::MouseClick(mouse.row)))
}
_ => Ok(None),
}
}
fn validate_repo_path(path: &Path) -> Result<()> {
if !path.exists() {
anyhow::bail!("リポジトリパスが存在しません: {}", path.display());
}
if !path.is_dir() {
anyhow::bail!(
"リポジトリパスはディレクトリである必要があります: {}",
path.display()
);
}
git::open_repo(path)
.with_context(|| format!("無効な Git リポジトリです: {}", path.display()))?;
Ok(())
}
pub fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
events: impl Iterator<Item = Key>,
repo_path: PathBuf,
launcher: &mut dyn FnMut(&Path, &str) -> HunkOutcome,
) -> Result<()>
where
<B as Backend>::Error: Send + Sync + 'static,
{
let cfg = config::load().unwrap_or_else(|_| config::Config {
theme: "default".into(),
colors: config::default_colors(),
poll_interval_ms: 2000,
notification_timeout_ms: 3000,
});
let config = cfg;
let mut app = App::new(config, repo_path);
app.load_commits();
terminal.draw(|frame| {
ui::render(frame, &app);
})?;
for key in events {
handle_input(&mut app, key);
drain_hunk_launch(&mut app, launcher);
terminal.draw(|frame| {
ui::render(frame, &app);
})?;
if app.should_quit {
break;
}
}
let _ = terminal.draw(|frame| {
ui::render(frame, &app);
});
Ok(())
}
pub fn handle_input(app: &mut App, key: Key) {
if app.show_help {
match key {
Key::Char('?') | Key::Esc => app.show_help = false,
_ => {}
}
return;
}
if key == Key::Char('?') {
app.show_help = true;
return;
}
if key == Key::Char('r') {
app.reload();
return;
}
match &app.screen {
Screen::List => match key {
Key::Up | Key::Char('k') => app.navigate_up(),
Key::Down | Key::Char('j') => app.navigate_down(),
Key::Ctrl('f') | Key::PageDown => app.navigate_page_down(),
Key::Ctrl('b') | Key::PageUp => app.navigate_page_up(),
Key::Enter => app.select_commit(),
Key::Char('c') => copy_commit_hash(app),
Key::Char('h') => app.open_in_hunk(),
Key::Char('q') => app.quit(),
Key::MouseClick(row) => select_commit_at_row(app, row),
_ => {}
},
Screen::Detail => match key {
Key::Esc => app.close_detail(),
Key::Up | Key::Char('k') => app.scroll_detail_up(),
Key::Down | Key::Char('j') => app.scroll_detail_down(),
Key::Ctrl('f') | Key::PageDown => app.scroll_detail_page_down(),
Key::Ctrl('b') | Key::PageUp => app.scroll_detail_page_up(),
Key::Char('c') => copy_commit_hash(app),
Key::Char('h') => app.open_in_hunk(),
_ => {}
},
Screen::Error(_) => match key {
Key::Enter => app.quit(),
_ => {}
},
Screen::Alert(_) => match key {
Key::Enter | Key::Esc => app.dismiss_alert(),
_ => {}
},
Screen::Loading => {
}
}
}
fn drain_hunk_launch(
app: &mut App,
launcher: &mut dyn FnMut(&Path, &str) -> HunkOutcome,
) -> bool {
match app.pending_hunk_launch.take() {
Some(oid) => {
let outcome = launcher(&app.repo_path, &oid);
app.on_hunk_finished(outcome);
true
}
None => false,
}
}
fn launch_hunk(repo_path: &Path, oid: &str) -> HunkOutcome {
let _ = disable_raw_mode();
let _ = io::stdout().execute(event::DisableMouseCapture);
let _ = io::stdout().execute(LeaveAlternateScreen);
let status = Command::new("hunk")
.arg("show")
.arg(oid)
.current_dir(repo_path)
.status();
let _ = enable_raw_mode();
let _ = io::stdout().execute(EnterAlternateScreen);
let _ = io::stdout().execute(event::EnableMouseCapture);
match status {
Ok(s) if s.success() => HunkOutcome::Success,
Ok(s) => HunkOutcome::Failed(format!("hunk exited with {s}")),
Err(e) => HunkOutcome::Failed(format!("Failed to launch hunk: {e}")),
}
}
fn select_commit_at_row(app: &mut App, row: u16) {
if app.commits.is_empty() || row < 1 {
return;
}
let visible_row = (row as usize).saturating_sub(1);
let new_index = app.list_scroll.get() + visible_row;
let max_index = app.commits.len().saturating_sub(1);
if new_index <= max_index {
app.selected_index = new_index;
}
}
fn copy_commit_hash(app: &mut App) {
let oid = match app.current_commit_oid() {
Some(o) => o,
None => return,
};
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if clipboard.set_text(oid.clone()).is_ok() {
let short = oid.chars().take(7).collect::<String>();
app.set_notification(format!("Copied {} to clipboard", short));
}
}
}
#[cfg(test)]
mod e2e_tests {
use std::cell::RefCell;
use std::path::Path;
use std::process::Command;
use std::rc::Rc;
use ratatui::backend::TestBackend;
use super::app::HunkOutcome;
use super::*;
fn init_repo(path: &Path, n: usize) {
let _ = std::fs::remove_dir_all(path);
std::fs::create_dir_all(path).unwrap();
Command::new("git")
.args(["init", "--initial-branch=main"])
.arg(path)
.status()
.unwrap();
for i in 0..n {
let file = path.join(format!("file-{i}.txt"));
std::fs::write(&file, format!("content {i}")).unwrap();
Command::new("git")
.args([
"-C",
&path.to_string_lossy(),
"add",
&file.to_string_lossy(),
])
.status()
.unwrap();
Command::new("git")
.args([
"-C",
&path.to_string_lossy(),
"commit",
"-m",
&format!("Commit subject {i}"),
"--allow-empty",
])
.env("GIT_AUTHOR_NAME", "Test User")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test User")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()
.unwrap();
}
}
fn init_empty_repo(path: &Path) {
let _ = std::fs::remove_dir_all(path);
std::fs::create_dir_all(path).unwrap();
Command::new("git")
.args(["init", "--initial-branch=main"])
.arg(path)
.status()
.unwrap();
}
fn run_with_events(repo_path: &Path, events: Vec<Key>) -> TestBackend {
run_with_events_and_launcher(repo_path, events, &mut |_, _| HunkOutcome::Success)
}
fn run_with_events_and_launcher(
repo_path: &Path,
events: Vec<Key>,
launcher: &mut dyn FnMut(&Path, &str) -> HunkOutcome,
) -> TestBackend {
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(repo_path).unwrap();
let backend = TestBackend::new(210, 24);
let mut terminal = Terminal::new(backend).unwrap();
let config_home = repo_path.join(".tuit-config");
std::fs::create_dir_all(&config_home).unwrap();
let _ = super::run_app(
&mut terminal,
events.into_iter(),
repo_path.to_path_buf(),
launcher,
);
std::env::set_current_dir(prev_dir).unwrap();
terminal.backend().clone()
}
fn rev_parse(repo_path: &Path, rev: &str) -> String {
let out = Command::new("git")
.args(["-C", &repo_path.to_string_lossy(), "rev-parse", rev])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[test]
fn h_in_list_launches_hunk_with_selected_commit_oid() {
let tmp = std::env::temp_dir().join("tuit-e2e-hunk-list");
init_repo(&tmp, 2);
let expected_oid = rev_parse(&tmp, "HEAD");
let calls = Rc::new(RefCell::new(Vec::new()));
let calls2 = calls.clone();
let mut launcher = move |_path: &Path, oid: &str| {
calls2.borrow_mut().push(oid.to_string());
HunkOutcome::Success
};
let _backend = run_with_events_and_launcher(
&tmp,
vec![Key::Char('h'), Key::Char('q')],
&mut launcher,
);
assert_eq!(
calls.borrow().as_slice(),
&[expected_oid],
"h in Commit List should launch hunk with the selected commit's full OID",
);
}
#[test]
fn h_in_detail_launches_hunk_with_viewed_commit_oid() {
let tmp = std::env::temp_dir().join("tuit-e2e-hunk-detail");
init_repo(&tmp, 2);
let expected_oid = rev_parse(&tmp, "HEAD~1");
let calls = Rc::new(RefCell::new(Vec::new()));
let calls2 = calls.clone();
let mut launcher = move |_path: &Path, oid: &str| {
calls2.borrow_mut().push(oid.to_string());
HunkOutcome::Success
};
let _backend = run_with_events_and_launcher(
&tmp,
vec![
Key::Char('j'),
Key::Enter,
Key::Char('h'),
Key::Esc,
Key::Char('q'),
],
&mut launcher,
);
assert_eq!(
calls.borrow().as_slice(),
&[expected_oid],
"h in Commit Detail should launch hunk with the viewed commit's full OID",
);
}
#[test]
fn hunk_launch_failure_shows_footer_notification() {
let tmp = std::env::temp_dir().join("tuit-e2e-hunk-failure");
init_repo(&tmp, 1);
let mut launcher = |_path: &Path, _oid: &str| {
HunkOutcome::Failed("Failed to launch hunk: command not found".to_string())
};
let backend = run_with_events_and_launcher(
&tmp,
vec![Key::Char('h'), Key::Char('q')],
&mut launcher,
);
let content = buf_to_string(backend.buffer());
assert!(
content.contains("Failed to launch hunk: command not found"),
"Failure notification should be rendered in the footer, got:\n{content}",
);
}
#[test]
fn hunk_launch_success_resumes_silently() {
let tmp = std::env::temp_dir().join("tuit-e2e-hunk-success");
init_repo(&tmp, 1);
let mut launcher = |_path: &Path, _oid: &str| HunkOutcome::Success;
let backend = run_with_events_and_launcher(
&tmp,
vec![Key::Char('h'), Key::Char('q')],
&mut launcher,
);
let content = buf_to_string(backend.buffer());
assert!(
!content.contains("Failed") && !content.contains("exited with"),
"Successful launch should not raise any notification, got:\n{content}",
);
}
#[test]
fn h_on_error_screen_does_not_launch_hunk() {
let tmp = std::env::temp_dir().join("tuit-e2e-hunk-error");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let calls = Rc::new(RefCell::new(Vec::new()));
let calls2 = calls.clone();
let mut launcher = move |_path: &Path, oid: &str| {
calls2.borrow_mut().push(oid.to_string());
HunkOutcome::Success
};
let _backend = run_with_events_and_launcher(
&tmp,
vec![Key::Char('h'), Key::Enter],
&mut launcher,
);
assert!(
calls.borrow().is_empty(),
"h on a screen without a focused commit must not launch hunk",
);
}
#[test]
fn happy_path_commit_list_shows_all_commits() {
let tmp = std::env::temp_dir().join("tuit-e2e-happy");
init_repo(&tmp, 3);
let backend = run_with_events(&tmp, vec![Key::Char('q')]);
let buf = backend.buffer();
let content = buf_to_string(buf);
assert!(
content.contains("Commit subject 0"),
"Expected 'Commit subject 0' in buffer, got:\n{content}",
);
assert!(
content.contains("Commit subject 1"),
"Expected 'Commit subject 1' in buffer, got:\n{content}",
);
assert!(
content.contains("Commit subject 2"),
"Expected 'Commit subject 2' in buffer, got:\n{content}",
);
assert!(
!content.contains("Test User"),
"Author 'Test User' should NOT appear in commit list, got:\n{content}",
);
}
#[test]
fn non_git_directory_shows_error() {
let tmp = std::env::temp_dir().join("tuit-e2e-non-git");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let backend = run_with_events(&tmp, vec![Key::Enter]);
let buf = backend.buffer();
let content = buf_to_string(buf);
let stripped: String = content.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
stripped.contains("tuitはgitリポジトリの中で実行してください"),
"Expected error message, got:\n{content}",
);
}
#[test]
fn empty_repository_shows_empty_message() {
let tmp = std::env::temp_dir().join("tuit-e2e-empty");
init_empty_repo(&tmp);
let backend = run_with_events(&tmp, vec![Key::Enter]);
let buf = backend.buffer();
let content = buf_to_string(buf);
let stripped: String = content.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
stripped.contains("このリポジトリにはまだコミットがありません"),
"Expected empty repo message, got:\n{content}",
);
}
fn buf_to_string(buf: &ratatui::buffer::Buffer) -> String {
let mut s = String::new();
let area = buf.area;
for y in 0..area.height {
let mut prev_was_space = false;
for x in 0..area.width {
let cell = buf.cell((x, y)).unwrap();
let sym = cell.symbol();
if sym.is_empty() {
continue;
}
if sym == " " {
if prev_was_space {
continue;
}
prev_was_space = true;
} else {
prev_was_space = false;
}
s.push_str(sym);
}
if y + 1 < area.height {
s.push('\n');
}
}
s
}
#[test]
fn poll_detects_new_commit() {
let tmp = std::env::temp_dir().join("tuit-e2e-poll-new");
init_repo(&tmp, 2);
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp).unwrap();
let cfg = config::Config {
theme: "default".into(),
colors: config::default_colors(),
poll_interval_ms: 2000,
notification_timeout_ms: 3000,
};
let mut app = App::new(cfg, tmp.clone());
app.load_commits();
assert_eq!(app.commits.len(), 2);
assert_eq!(app.screen, Screen::List);
let first_head = app.current_head_oid.clone();
let file = tmp.join("file-2.txt");
std::fs::write(&file, "content 2").unwrap();
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "add", &file.to_string_lossy()])
.status()
.unwrap();
Command::new("git")
.args([
"-C",
&tmp.to_string_lossy(),
"commit",
"--allow-empty",
"-m",
"Commit subject 2",
])
.env("GIT_AUTHOR_NAME", "Test User")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test User")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()
.unwrap();
app.poll();
assert!(app.current_head_oid.is_some());
assert_ne!(app.current_head_oid, first_head);
assert_eq!(app.commits.len(), 3);
assert!(
app.commits[0].message.contains("Commit subject 2"),
"Expected newest commit at top, got: {}",
app.commits[0].message
);
std::env::set_current_dir(prev_dir).unwrap();
}
#[test]
fn poll_head_change_shows_notification() {
let tmp = std::env::temp_dir().join("tuit-e2e-poll-head-notification");
init_repo(&tmp, 2);
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp).unwrap();
let cfg = config::Config {
theme: "default".into(),
colors: config::default_colors(),
poll_interval_ms: 2000,
notification_timeout_ms: 3000,
};
let mut app = App::new(cfg, tmp.clone());
app.load_commits();
app.poll();
assert!(app.current_head_oid.is_some());
assert!(
app.notification.is_none(),
"No notification on initial HEAD recording"
);
let file = tmp.join("head-notification.txt");
std::fs::write(&file, "head notification content").unwrap();
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "add", &file.to_string_lossy()])
.status()
.unwrap();
Command::new("git")
.args([
"-C",
&tmp.to_string_lossy(),
"commit",
"--allow-empty",
"-m",
"Head change notification commit",
])
.env("GIT_AUTHOR_NAME", "Test User")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test User")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()
.unwrap();
app.last_poll_time = std::time::Instant::now() - std::time::Duration::from_millis(5000);
app.poll();
assert!(
app.notification.is_some(),
"Expected notification after HEAD change"
);
assert!(
app.notification
.as_ref()
.unwrap()
.message
.contains("HEAD moved"),
"Expected HEAD moved notification, got: {:?}",
app.notification
);
std::env::set_current_dir(prev_dir).unwrap();
}
#[test]
fn poll_timestamps_update_in_detail() {
let tmp = std::env::temp_dir().join("tuit-e2e-poll-detail-time");
init_repo(&tmp, 1);
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp).unwrap();
let cfg = config::Config {
theme: "default".into(),
colors: config::default_colors(),
poll_interval_ms: 2000,
notification_timeout_ms: 3000,
};
let mut app = App::new(cfg, tmp.clone());
app.load_commits();
app.select_commit();
assert_eq!(app.screen, Screen::Detail);
let _original_date = app.selected_commit.as_ref().unwrap().date.clone();
app.poll();
app.last_poll_time = std::time::Instant::now() - std::time::Duration::from_millis(5000); app.poll();
assert_eq!(app.screen, Screen::Detail);
assert!(app.selected_commit.is_some());
std::env::set_current_dir(prev_dir).unwrap();
}
#[test]
fn detail_overlay_hides_commit_list_text() {
let tmp = std::env::temp_dir().join("tuit-e2e-detail-overlay");
init_repo(&tmp, 5);
let backend = run_with_events(&tmp, vec![Key::Enter]);
let content = buf_to_string(backend.buffer());
assert!(
content.contains("Commit subject 4"),
"Expected selected commit subject in buffer, got:\n{content}",
);
for i in 0..4 {
assert!(
!content.contains(&format!("Commit subject {i}")),
"Commit subject {i} leaked through popup:\n{content}",
);
}
}
#[test]
fn reload_detects_branch_switch() {
let tmp = std::env::temp_dir().join("tuit-e2e-reload-branch");
init_repo(&tmp, 2);
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "checkout", "-b", "feature"])
.status()
.unwrap();
let file = tmp.join("feature.txt");
std::fs::write(&file, "feature content").unwrap();
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "add", &file.to_string_lossy()])
.status()
.unwrap();
Command::new("git")
.args([
"-C",
&tmp.to_string_lossy(),
"commit",
"-m",
"Commit subject 2 (feature)",
])
.env("GIT_AUTHOR_NAME", "Test User")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test User")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()
.unwrap();
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "checkout", "main"])
.status()
.unwrap();
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp).unwrap();
let cfg = config::Config {
theme: "default".into(),
colors: config::default_colors(),
poll_interval_ms: 2000,
notification_timeout_ms: 3000,
};
let mut app = App::new(cfg, tmp.clone());
app.load_commits();
assert_eq!(app.current_branch, "main");
assert_eq!(app.commits.len(), 2);
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "checkout", "feature"])
.status()
.unwrap();
app.reload();
assert_eq!(app.current_branch, "feature");
assert_eq!(app.commits.len(), 3);
assert!(
app.commits[0]
.message
.contains("Commit subject 2 (feature)"),
"Expected feature branch commit at top, got: {}",
app.commits[0].message
);
assert_eq!(app.selected_index, 0);
assert_eq!(app.screen, Screen::List);
assert!(app.selected_commit.is_none());
std::env::set_current_dir(prev_dir).unwrap();
}
#[test]
fn reload_via_key_event_updates_branch_in_handle_input() {
let tmp = std::env::temp_dir().join("tuit-e2e-reload-key-event");
init_repo(&tmp, 2);
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "checkout", "-b", "feature"])
.status()
.unwrap();
let file = tmp.join("f.txt");
std::fs::write(&file, "f").unwrap();
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "add", &file.to_string_lossy()])
.status()
.unwrap();
Command::new("git")
.args([
"-C",
&tmp.to_string_lossy(),
"commit",
"-m",
"Feature commit",
])
.env("GIT_AUTHOR_NAME", "Test User")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test User")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()
.unwrap();
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "checkout", "main"])
.status()
.unwrap();
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp).unwrap();
let cfg = config::Config {
theme: "default".into(),
colors: config::default_colors(),
poll_interval_ms: 2000,
notification_timeout_ms: 3000,
};
let mut app = App::new(cfg, tmp.clone());
app.load_commits();
assert_eq!(app.current_branch, "main");
assert_eq!(app.commits.len(), 2);
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "checkout", "feature"])
.status()
.unwrap();
handle_input(&mut app, Key::Char('r'));
assert_eq!(
app.current_branch, "feature",
"branch name should update after reload"
);
assert_eq!(
app.commits.len(),
3,
"commit count should reflect feature branch"
);
assert!(
app.commits[0].message.contains("Feature commit"),
"top commit should be the feature branch commit"
);
assert_eq!(app.screen, Screen::List);
assert_eq!(app.selected_index, 0);
std::env::set_current_dir(prev_dir).unwrap();
}
#[test]
fn reload_via_run_app_r_key() {
let tmp = std::env::temp_dir().join("tuit-e2e-run-app-r");
init_repo(&tmp, 3);
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "checkout", "-b", "feature"])
.status()
.unwrap();
let file = tmp.join("ft.txt");
std::fs::write(&file, "ft").unwrap();
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "add", &file.to_string_lossy()])
.status()
.unwrap();
Command::new("git")
.args([
"-C",
&tmp.to_string_lossy(),
"commit",
"-m",
"Only on feature",
])
.env("GIT_AUTHOR_NAME", "Test User")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test User")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()
.unwrap();
Command::new("git")
.args(["-C", &tmp.to_string_lossy(), "checkout", "main"])
.status()
.unwrap();
let backend = run_with_events(&tmp, vec![Key::Char('r'), Key::Char('q')]);
let content = buf_to_string(backend.buffer());
assert!(
content.contains("main"),
"Expected branch 'main' in header, got:\n{content}"
);
assert!(
content.contains("tuit"),
"Expected 'tuit' in header, got:\n{content}"
);
assert!(
content.contains("Commit subject 0"),
"Expected commit 0 in list after reload, got:\n{content}"
);
}
#[test]
fn reload_detaches_detail_view() {
let tmp = std::env::temp_dir().join("tuit-e2e-reload-detail");
init_repo(&tmp, 3);
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp).unwrap();
let cfg = config::Config {
theme: "default".into(),
colors: config::default_colors(),
poll_interval_ms: 2000,
notification_timeout_ms: 3000,
};
let mut app = App::new(cfg, tmp.clone());
app.load_commits();
app.select_commit();
assert_eq!(app.screen, Screen::Detail);
assert!(app.selected_commit.is_some());
assert!(app.selected_index == 0);
app.reload();
assert_eq!(app.screen, Screen::List);
assert!(app.selected_commit.is_none());
assert_eq!(app.selected_index, 0);
std::env::set_current_dir(prev_dir).unwrap();
}
#[test]
fn narrow_width_hides_author_and_hash() {
let tmp = std::env::temp_dir().join("tuit-e2e-narrow");
init_repo(&tmp, 3);
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp).unwrap();
let backend = TestBackend::new(30, 24);
let mut terminal = Terminal::new(backend).unwrap();
let _ = super::run_app(
&mut terminal,
vec![Key::Char('q')].into_iter(),
tmp.clone(),
&mut |_, _| HunkOutcome::Success,
);
let content = buf_to_string(terminal.backend().buffer());
std::env::set_current_dir(prev_dir).unwrap();
assert!(
!content.contains("Test User"),
"Author 'Test User' unexpectedly found in narrow (30-col) rendering:\n{content}",
);
for i in 0..3 {
assert!(
content.contains(&format!("Commit subject {i}")),
"Commit subject {i} missing in 30-col rendering:\n{content}",
);
}
}
#[test]
fn mouse_click_selects_commit_in_list() {
let tmp = std::env::temp_dir().join("tuit-e2e-mouse-click");
init_repo(&tmp, 5);
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp).unwrap();
let cfg = config::Config {
theme: "default".into(),
colors: config::default_colors(),
poll_interval_ms: 2000,
notification_timeout_ms: 3000,
};
let mut app = App::new(cfg, tmp.clone());
app.load_commits();
assert_eq!(app.screen, Screen::List);
assert_eq!(app.commits.len(), 5);
assert_eq!(app.selected_index, 0);
super::handle_input(&mut app, Key::MouseClick(3));
assert_eq!(
app.selected_index, 2,
"Click on row 3 should select index 2"
);
super::handle_input(&mut app, Key::MouseClick(1));
assert_eq!(
app.selected_index, 0,
"Click on row 1 should select index 0"
);
super::handle_input(&mut app, Key::MouseClick(0));
assert_eq!(
app.selected_index, 0,
"Click on header row should not change selection"
);
super::handle_input(&mut app, Key::MouseClick(100));
assert_eq!(
app.selected_index, 0,
"Click beyond list should not change selection"
);
std::env::set_current_dir(prev_dir).unwrap();
}
}