use std::collections::HashSet;
use std::io;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use crossterm::event;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Widget;
use super::SelectionResult;
use super::bookmark_gen;
use super::bookmark_gen::BookmarkGenError;
use super::bookmark_gen::BookmarkNameCache;
use super::bookmark_gen::CacheEntry;
use super::bookmark_widget::BookmarkAssignmentState;
use super::bookmark_widget::BookmarkRow;
use super::bookmark_widget::BookmarkWidget;
use super::bookmark_widget::CustomNameState;
use super::bookmark_widget::InputMode;
use super::bookmark_widget::RowState;
use super::bookmark_widget::SelectionError;
use super::bookmark_widget::VaryResult;
use super::bookmark_widget::bookmark_help_line;
use super::event::Action;
use super::event::EditAction;
use super::event::map_event;
use super::event::map_event_editing;
use super::graph_widget::GraphViewState;
use super::graph_widget::GraphWidget;
use super::graph_widget::graph_help_line;
use super::graph_widget::max_collapsed_height;
use crate::error::StakkError::Interrupted;
use crate::error::StakkError::{self};
use crate::graph::layout::GraphLayout;
use crate::graph::layout::build_layout;
use crate::graph::types::ChangeGraph;
use crate::graph::types::SegmentCommit;
type Tui = Terminal<CrosstermBackend<io::Stderr>>;
enum Screen {
GraphView,
BookmarkAssignment,
}
struct PendingCommand {
row_idx: usize,
rx: tokio::sync::oneshot::Receiver<Result<String, BookmarkGenError>>,
}
pub fn run_tui(
graph: &ChangeGraph,
bookmark_command: Option<&str>,
auto_prefix: Option<&str>,
reserved: &HashSet<String>,
) -> Result<Option<SelectionResult>, StakkError> {
let layout = build_layout(graph);
let has_bookmark_command = bookmark_command.is_some();
let bookmark_cache = Arc::new(Mutex::new(BookmarkNameCache::new()));
if layout.leaf_nodes().is_empty() {
eprintln!("No selectable branches found.");
return Ok(None);
}
let mut graph_state = GraphViewState::new();
let mut bookmark_state: Option<BookmarkAssignmentState> = None;
let mut screen = Screen::GraphView;
crossterm::terminal::enable_raw_mode()?;
let mut terminal = make_terminal(graph_viewport_height(&layout)?)?;
let result = run_event_loop(
&mut terminal,
graph,
&layout,
&mut graph_state,
&mut bookmark_state,
&mut screen,
has_bookmark_command,
bookmark_command,
auto_prefix,
&bookmark_cache,
reserved,
);
let _ = collapse_viewport(&mut terminal);
let _ = crossterm::execute!(io::stderr(), crossterm::cursor::Show);
crossterm::terminal::disable_raw_mode()?;
match &result {
Ok(Some(selection)) => {
let stack = selection
.assignments
.iter()
.map(|a| a.bookmark_name.as_str())
.collect::<Vec<_>>()
.join(" → ");
eprintln!("Selected stack: {stack}");
eprintln!();
}
Ok(None) => eprintln!("Selection cancelled."),
Err(_) => {}
}
result
}
fn selected_stack_path(
graph: &ChangeGraph,
layout: &GraphLayout,
graph_state: &GraphViewState,
) -> Vec<SegmentCommit> {
let leaf = layout.leaves[graph_state.selected_leaf];
let leaf_commit_id = &layout.nodes[leaf].commit_id;
let stack = graph
.stacks
.iter()
.find(|s| {
s.tip_commit()
.is_some_and(|c| &c.commit_id == leaf_commit_id)
})
.expect("every layout leaf is the tip of a stack");
stack.commits_trunk_to_tip().cloned().collect()
}
fn viewport_height_for(content_rows: usize) -> io::Result<u16> {
let (_, term_height) = crossterm::terminal::size()?;
let height = (content_rows + 3)
.min(30)
.min(usize::from(term_height).saturating_sub(2));
Ok(u16::try_from(height).expect("viewport height fits in u16"))
}
fn graph_viewport_height(layout: &GraphLayout) -> io::Result<u16> {
viewport_height_for(max_collapsed_height(layout))
}
fn make_terminal(viewport_height: u16) -> io::Result<Tui> {
let backend = CrosstermBackend::new(io::stderr());
let options = ratatui::TerminalOptions {
viewport: ratatui::Viewport::Inline(viewport_height),
};
Terminal::with_options(backend, options)
}
fn collapse_viewport(terminal: &mut Tui) -> io::Result<()> {
let area = terminal.get_frame().area();
crossterm::execute!(
io::stderr(),
crossterm::cursor::MoveTo(area.x, area.y),
crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown),
)
}
fn replace_viewport(terminal: &mut Tui, viewport_height: u16) -> io::Result<()> {
collapse_viewport(terminal)?;
*terminal = make_terminal(viewport_height)?;
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "TUI event loop needs all context threaded through"
)]
fn run_event_loop(
terminal: &mut Tui,
graph: &ChangeGraph,
layout: &GraphLayout,
graph_state: &mut GraphViewState,
bookmark_state: &mut Option<BookmarkAssignmentState>,
screen: &mut Screen,
has_bookmark_command: bool,
bookmark_command: Option<&str>,
auto_prefix: Option<&str>,
bookmark_cache: &Arc<Mutex<BookmarkNameCache>>,
reserved: &HashSet<String>,
) -> Result<Option<SelectionResult>, StakkError> {
let mut pending: Vec<PendingCommand> = Vec::new();
let mut spinner_tick: usize = 0;
let mut error_message: Option<String> = None;
loop {
if let Some(msg) = drain_completed(&mut pending, bookmark_state) {
error_message = Some(msg);
}
resolve_cached_names(bookmark_state, bookmark_cache);
let has_loading = bookmark_state.as_ref().is_some_and(|s| {
s.rows
.iter()
.any(|r| matches!(r.state, RowState::UseCustom(CustomNameState::Loading)))
});
let has_pending = !pending.is_empty() || has_loading;
if has_pending {
spinner_tick = spinner_tick.wrapping_add(1);
}
terminal.draw(|frame| {
let area = frame.area();
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(area);
match screen {
Screen::GraphView => {
render_graph_screen(
frame,
chunks[0],
chunks[1],
chunks[2],
chunks[3],
layout,
graph_state,
);
}
Screen::BookmarkAssignment => {
if let Some(bm_state) = bookmark_state.as_ref() {
render_bookmark_screen(
frame,
chunks[0],
chunks[1],
chunks[2],
chunks[3],
bm_state,
spinner_tick,
bookmark_command,
error_message.as_deref(),
);
}
}
}
})?;
let poll_timeout = if has_pending {
Duration::from_millis(80)
} else {
Duration::from_mins(1)
};
if !event::poll(poll_timeout)? {
continue; }
let ev = event::read()?;
match screen {
Screen::GraphView => {
let action = map_event(&ev);
match action {
Action::Left | Action::Up => {
if graph_state.selected_leaf > 0 {
graph_state.selected_leaf -= 1;
} else {
graph_state.selected_leaf = layout.leaves.len().saturating_sub(1);
}
}
Action::Right | Action::Down => {
if graph_state.selected_leaf < layout.leaves.len().saturating_sub(1) {
graph_state.selected_leaf += 1;
} else {
graph_state.selected_leaf = 0;
}
}
Action::Select => {
if let Some(&leaf) = layout.leaves.get(graph_state.selected_leaf) {
let path = layout.path_to_leaf(leaf);
let state = BookmarkAssignmentState::from_path(
&path,
has_bookmark_command,
auto_prefix,
reserved,
);
replace_viewport(terminal, viewport_height_for(state.rows.len())?)?;
*bookmark_state = Some(state);
*screen = Screen::BookmarkAssignment;
}
}
Action::Cancel => {
return Ok(None);
}
Action::Quit => {
return Err(Interrupted);
}
Action::Toggle
| Action::ReverseToggle
| Action::EnterEdit
| Action::Vary
| Action::ReverseVary
| Action::None => {}
}
}
Screen::BookmarkAssignment => {
let is_editing = bookmark_state
.as_ref()
.is_some_and(|s| s.input_mode == InputMode::Editing);
if is_editing {
if let Some(edit_action) = map_event_editing(&ev) {
match edit_action {
EditAction::InsertChar(c) => {
if let Some(state) = bookmark_state.as_mut() {
state.insert_char(c);
}
}
EditAction::Backspace => {
if let Some(state) = bookmark_state.as_mut() {
state.delete_char();
}
}
EditAction::ExitEdit => {
if let Some(state) = bookmark_state.as_mut() {
state.exit_edit_mode();
}
}
EditAction::Quit => {
return Err(Interrupted);
}
}
}
continue;
}
let action = map_event(&ev);
match action {
Action::Up => {
if let Some(state) = bookmark_state {
state.cursor_up();
}
}
Action::Down => {
if let Some(state) = bookmark_state {
state.cursor_down();
}
}
Action::Toggle | Action::ReverseToggle => {
error_message = None;
if let Some(state) = bookmark_state {
if action == Action::ReverseToggle {
state.toggle_current_reverse();
} else {
state.toggle_current();
}
if let Some(cmd) = bookmark_command {
fire_pending_commands(state, cmd, bookmark_cache, &mut pending);
}
}
}
Action::Select => {
if let Some(state) = bookmark_state.as_ref() {
match state.build_result() {
Ok(assignments) if assignments.is_empty() => {}
Ok(assignments) => {
let path = selected_stack_path(graph, layout, graph_state);
return Ok(Some(SelectionResult { assignments, path }));
}
Err(SelectionError::DuplicateName(name)) => {
error_message =
Some(format!("Duplicate bookmark name: {name}"));
}
Err(SelectionError::NameExists(name)) => {
error_message =
Some(format!("Bookmark already exists: {name}"));
}
Err(SelectionError::StillLoading) => {
error_message =
Some("A bookmark name is still loading…".to_string());
}
Err(SelectionError::InvalidName(msg)) => {
error_message = Some(format!("Invalid bookmark name: {msg}"));
}
}
}
}
Action::EnterEdit => {
if let Some(state) = bookmark_state.as_mut() {
state.enter_edit_mode();
}
}
Action::Vary | Action::ReverseVary => {
error_message = None;
if let Some(state) = bookmark_state {
let result = if action == Action::ReverseVary {
state.vary_current_reverse()
} else {
state.vary_current()
};
match result {
VaryResult::NeedsRefire => {
let segment = bookmark_gen::dynamic_segment_commits(
&state.rows,
state.cursor,
);
let key = bookmark_gen::cache_key(&segment);
bookmark_cache
.lock()
.expect("cache mutex poisoned")
.remove(&key);
if let Some(cmd) = bookmark_command {
fire_pending_commands(
state,
cmd,
bookmark_cache,
&mut pending,
);
}
}
VaryResult::TfidfNoVariation => {
error_message =
Some("No other auto-name variations available".to_string());
}
VaryResult::ExistingCycled
| VaryResult::TfidfCycled
| VaryResult::Noop => {}
}
}
}
Action::Cancel => {
pending.clear();
replace_viewport(terminal, graph_viewport_height(layout)?)?;
*screen = Screen::GraphView;
*bookmark_state = None;
}
Action::Quit => {
return Err(Interrupted);
}
Action::Left | Action::Right | Action::None => {}
}
}
}
}
}
fn fire_pending_commands(
state: &mut BookmarkAssignmentState,
command: &str,
cache: &Arc<Mutex<BookmarkNameCache>>,
pending: &mut Vec<PendingCommand>,
) {
let mut cache_guard = cache.lock().expect("cache mutex poisoned");
let needs_fire: Vec<usize> = state
.rows
.iter()
.enumerate()
.filter_map(|(i, row)| match &row.state {
RowState::UseCustom(CustomNameState::Loading) => {
let segment = bookmark_gen::dynamic_segment_commits(&state.rows, i);
let key = bookmark_gen::cache_key(&segment);
match cache_guard.get(&key) {
Some(CacheEntry::Computing { .. })
if !cache_guard.get(&key).unwrap().is_expired() =>
{
None
}
Some(CacheEntry::Computing { .. }) => {
cache_guard.remove(&key);
Some(i)
}
Some(CacheEntry::Computed(_)) | None => Some(i),
}
}
RowState::UseCustom(CustomNameState::Ready(_)) => {
let segment = bookmark_gen::dynamic_segment_commits(&state.rows, i);
let key = bookmark_gen::cache_key(&segment);
if let Some(CacheEntry::Computed(cached_name)) = cache_guard.get(&key)
&& row.custom_name.as_ref() == Some(cached_name)
{
return None;
}
Some(i)
}
_ => None,
})
.collect();
if needs_fire.is_empty() {
return;
}
let handle = tokio::runtime::Handle::current();
for idx in &needs_fire {
let idx = *idx;
pending.retain(|p| p.row_idx != idx);
let segment = bookmark_gen::dynamic_segment_commits(&state.rows, idx);
let key = bookmark_gen::cache_key(&segment);
match cache_guard.get(&key) {
Some(CacheEntry::Computed(cached_name)) => {
let name = cached_name.clone();
state.rows[idx].custom_name = Some(name.clone());
state.rows[idx].state = RowState::UseCustom(CustomNameState::Ready(name));
continue;
}
Some(CacheEntry::Computing { .. }) if !cache_guard.get(&key).unwrap().is_expired() => {
state.rows[idx].state = RowState::UseCustom(CustomNameState::Loading);
continue;
}
_ => {
}
}
cache_guard.insert(
key.clone(),
CacheEntry::Computing {
since: std::time::Instant::now(),
},
);
let input = bookmark_gen::build_segment_input(&segment);
let json = serde_json::to_string(&input).expect("SegmentInput is always serializable");
let cmd = command.to_string();
let task_cache = Arc::clone(cache);
let (tx, rx) = tokio::sync::oneshot::channel();
handle.spawn(async move {
let result =
bookmark_gen::run_command(&cmd, &json, bookmark_gen::COMPUTING_TIMEOUT).await;
if let Ok(mut guard) = task_cache.lock() {
match &result {
Ok(name) if bookmark_gen::validate_bookmark_name(name).is_ok() => {
guard.insert(key, CacheEntry::Computed(name.clone()));
}
_ => {
guard.remove(&key);
}
}
}
let _ = tx.send(result);
});
state.rows[idx].state = RowState::UseCustom(CustomNameState::Loading);
pending.push(PendingCommand { row_idx: idx, rx });
}
}
fn drain_completed(
pending: &mut Vec<PendingCommand>,
bookmark_state: &mut Option<BookmarkAssignmentState>,
) -> Option<String> {
let Some(state) = bookmark_state.as_mut() else {
pending.clear();
return None;
};
let mut first_error: Option<String> = None;
let mut completed = Vec::new();
for (i, cmd) in pending.iter_mut().enumerate() {
match cmd.rx.try_recv() {
Ok(result) => completed.push((i, cmd.row_idx, result)),
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {} Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
completed.push((
i,
cmd.row_idx,
Err(BookmarkGenError::EmptyOutput {
command: "(dropped)".to_string(),
}),
));
}
}
}
completed.sort_by_key(|b| std::cmp::Reverse(b.0));
for (pending_idx, row_idx, result) in completed {
pending.remove(pending_idx);
if row_idx >= state.rows.len() {
continue;
}
match result {
Ok(name) => {
if bookmark_gen::validate_bookmark_name(&name).is_ok() {
state.rows[row_idx].custom_name = Some(name.clone());
if matches!(
state.rows[row_idx].state,
RowState::UseCustom(CustomNameState::Loading)
) {
state.rows[row_idx].state =
RowState::UseCustom(CustomNameState::Ready(name));
}
} else {
let err = bookmark_gen::validate_bookmark_name(&name).unwrap_err();
if first_error.is_none() {
first_error = Some(format!("Bookmark command: {err}"));
}
if matches!(
state.rows[row_idx].state,
RowState::UseCustom(CustomNameState::Loading)
) {
state.rows[row_idx].custom_name = None;
state.rows[row_idx].state = RowState::Unchecked;
}
}
}
Err(e) => {
if first_error.is_none() {
first_error = Some(format!("Bookmark command: {e}"));
}
if matches!(
state.rows[row_idx].state,
RowState::UseCustom(CustomNameState::Loading)
) {
state.rows[row_idx].custom_name = None;
state.rows[row_idx].state = RowState::Unchecked;
}
}
}
}
first_error
}
fn resolve_cached_names(
bookmark_state: &mut Option<BookmarkAssignmentState>,
cache: &Arc<Mutex<BookmarkNameCache>>,
) {
let Some(state) = bookmark_state.as_mut() else {
return;
};
let cache_guard = cache.lock().expect("cache mutex poisoned");
let resolved: Vec<(usize, String)> = state
.rows
.iter()
.enumerate()
.filter(|(_, row)| matches!(row.state, RowState::UseCustom(CustomNameState::Loading)))
.filter_map(|(i, _)| {
let segment = bookmark_gen::dynamic_segment_commits(&state.rows, i);
let key = bookmark_gen::cache_key(&segment);
if let Some(CacheEntry::Computed(name)) = cache_guard.get(&key) {
Some((i, name.clone()))
} else {
None
}
})
.collect();
for (i, name) in resolved {
state.rows[i].custom_name = Some(name.clone());
state.rows[i].state = RowState::UseCustom(CustomNameState::Ready(name));
}
}
fn render_graph_screen(
frame: &mut ratatui::Frame,
title_area: Rect,
subtitle_area: Rect,
content_area: Rect,
help_area: Rect,
layout: &GraphLayout,
state: &GraphViewState,
) {
let title = Line::from(vec![Span::styled(
" Select branch stack",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)]);
frame.render_widget(title, title_area);
let leaf_count = layout.leaves.len();
if leaf_count > 0 {
let indicator = Line::from(vec![Span::styled(
format!("leaf {}/{leaf_count} ", state.selected_leaf + 1),
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
)])
.right_aligned();
frame.render_widget(indicator, title_area);
}
let subtitle = Line::from(vec![Span::styled(
" The highlighted branch will be submitted as a stack of pull requests.",
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
)]);
frame.render_widget(subtitle, subtitle_area);
let widget = GraphWidget::new(layout, state);
widget.render(content_area, frame.buffer_mut());
frame.render_widget(graph_help_line(), help_area);
}
#[expect(
clippy::too_many_arguments,
reason = "render function needs all layout areas plus state"
)]
fn render_bookmark_screen(
frame: &mut ratatui::Frame,
title_area: Rect,
subtitle_area: Rect,
content_area: Rect,
help_area: Rect,
state: &BookmarkAssignmentState,
spinner_tick: usize,
bookmark_command: Option<&str>,
error_message: Option<&str>,
) {
let title = Line::from(vec![Span::styled(
" Assign bookmarks to commits",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)]);
frame.render_widget(title, title_area);
let subtitle = if let Some(err) = error_message {
Line::from(vec![Span::styled(
format!(" {err}"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)])
} else {
Line::from(vec![Span::styled(
" Checked commits ([x]/[+]/[~]/[*]) will get own PRs. Unchecked will be part of the \
PR above them.",
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
)])
};
frame.render_widget(subtitle, subtitle_area);
let editing = state.is_editing();
let editing_row = if editing { Some(state.cursor) } else { None };
let widget = BookmarkWidget::new(state, spinner_tick, bookmark_command, editing_row);
widget.render(content_area, frame.buffer_mut());
frame.render_widget(
bookmark_help_line(
bookmark_command.is_some(),
editing,
state.rows.get(state.cursor).map(|r| &r.state),
state
.rows
.get(state.cursor)
.map_or(0, |r| r.existing_bookmarks.len()),
state
.rows
.get(state.cursor)
.is_some_and(BookmarkRow::is_locked),
),
help_area,
);
}