use std::io;
use std::path::{Path, PathBuf};
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, 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);
}
}
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,
) -> 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);
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('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),
_ => {}
},
Screen::Error(_) => match key {
Key::Enter => app.quit(),
_ => {}
},
Screen::Alert(_) => match key {
Key::Enter | Key::Esc => app.dismiss_alert(),
_ => {}
},
Screen::Loading => {
}
}
}
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::path::Path;
use std::process::Command;
use ratatui::backend::TestBackend;
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 {
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());
std::env::set_current_dir(prev_dir).unwrap();
terminal.backend().clone()
}
#[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());
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();
}
}