use std::path::PathBuf;
use std::cell::Cell;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{List, ListItem, Paragraph},
};
use crate::prelude::*;
use input::{Key, KeyEvent};
use operation::{BranchViewOp, Event, Operation};
use settings::Settings;
use views::View;
use widgets::input_field::{self, InputField};
#[derive(Debug, Clone)]
enum RowKind {
Branch {
name: String,
is_current: bool,
is_remote: bool,
},
}
#[derive(Debug)]
pub struct BranchView {
pub repo_path: PathBuf,
local: Vec<String>,
remote: Vec<String>,
current: String,
rows: Vec<RowKind>,
cursor: usize,
scroll: Cell<usize>,
last_height: Cell<usize>,
pub loading: bool,
pub new_branch_input: Option<InputField>,
pub pending_delete: Option<String>,
status_msg: Option<String>,
}
impl BranchView {
pub fn new(repo_path: PathBuf) -> Self {
Self {
repo_path,
local: Vec::new(),
remote: Vec::new(),
current: String::new(),
rows: Vec::new(),
cursor: 0,
scroll: Cell::new(0),
last_height: Cell::new(0),
loading: true,
new_branch_input: None,
pending_delete: None,
status_msg: None,
}
}
fn rebuild_rows(&mut self) {
let mut rows = Vec::new();
for name in &self.local {
rows.push(RowKind::Branch {
name: name.clone(),
is_current: name == &self.current,
is_remote: false,
});
}
for name in &self.remote {
rows.push(RowKind::Branch {
name: name.clone(),
is_current: name == &self.current,
is_remote: true,
});
}
self.rows = rows;
self.clamp_cursor_to_branch();
}
fn clamp_cursor_to_branch(&mut self) {
if matches!(self.rows.get(self.cursor), Some(RowKind::Branch { .. })) {
return;
}
for (i, row) in self.rows.iter().enumerate() {
if matches!(row, RowKind::Branch { .. }) {
self.cursor = i;
self.scroll.set(self.cursor); return;
}
}
}
fn move_up(&mut self) {
if self.cursor == 0 {
return;
}
let mut i = self.cursor - 1;
loop {
if matches!(self.rows.get(i), Some(RowKind::Branch { .. })) {
self.cursor = i;
if self.cursor < self.scroll.get() {
self.scroll.set(self.cursor);
}
return;
}
if i == 0 {
return;
}
i -= 1;
}
}
fn move_down(&mut self) {
let mut i = self.cursor + 1;
while i < self.rows.len() {
if matches!(self.rows.get(i), Some(RowKind::Branch { .. })) {
self.cursor = i;
let vh = self.last_height.get();
if vh > 0 && self.cursor >= self.scroll.get() + vh {
self.scroll.set(self.cursor + 1 - vh);
}
return;
}
i += 1;
}
}
pub fn selected_branch_name(&self) -> Option<&str> {
match self.rows.get(self.cursor) {
Some(RowKind::Branch { name, .. }) => Some(name.as_str()),
_ => None,
}
}
pub fn is_remote_selected(&self) -> bool {
matches!(
self.rows.get(self.cursor),
Some(RowKind::Branch { is_remote: true, .. })
)
}
pub fn new_branch_text(&self) -> Option<&str> {
self.new_branch_input.as_ref().map(|f| f.text())
}
}
impl View for BranchView {
const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
if self.new_branch_input.is_some() {
return match key.key {
Key::Enter => vec![Operation::BranchViewLocal(BranchViewOp::NewBranchConfirm)],
Key::Escape => vec![Operation::BranchViewLocal(BranchViewOp::NewBranchCancel)],
_ => {
if let Some(fop) = input_field::key_to_op(key) {
vec![Operation::BranchViewLocal(BranchViewOp::NewBranchInput(fop))]
} else {
vec![]
}
}
};
}
match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
Key::Enter => vec![Operation::BranchViewLocal(BranchViewOp::CheckoutBranch)],
Key::Char('n') => vec![Operation::BranchViewLocal(BranchViewOp::NewBranchOpen)],
Key::Delete => {
if let Some(sel) = self.selected_branch_name() {
if self.pending_delete.as_deref() == Some(sel) {
vec![Operation::BranchViewLocal(BranchViewOp::DeleteConfirmed)]
} else {
vec![Operation::BranchViewLocal(BranchViewOp::DeleteRequested)]
}
} else {
vec![]
}
}
Key::Escape => vec![Operation::Close],
_ => vec![],
}
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
match op {
Operation::NavigateUp => {
self.move_up();
Some(Event::applied("git_branches", op.clone()))
}
Operation::NavigateDown => {
self.move_down();
Some(Event::applied("git_branches", op.clone()))
}
Operation::NavigatePageUp => {
for _ in 0..10 {
self.move_up();
}
Some(Event::applied("git_branches", op.clone()))
}
Operation::NavigatePageDown => {
for _ in 0..10 {
self.move_down();
}
Some(Event::applied("git_branches", op.clone()))
}
Operation::BranchViewLocal(bv_op) => {
match bv_op {
BranchViewOp::BranchesLoaded { local, remote, current } => {
self.local = local.clone();
self.remote = remote.clone();
self.current = current.clone();
self.loading = false;
self.rebuild_rows();
for (i, row) in self.rows.iter().enumerate() {
match row {
RowKind::Branch { name, .. } if name == current => {
self.cursor = i;
break;
}
_ => {}
}
}
self.scroll.set(self.cursor);
}
BranchViewOp::LoadError(msg) => {
self.status_msg = Some(format!("Error: {msg}"));
self.loading = false;
}
BranchViewOp::CheckoutBranch | BranchViewOp::NewBranchConfirm => {}
BranchViewOp::CheckoutCompleted(branch) => {
self.current = branch.clone();
self.status_msg = Some(format!("Switched to '{branch}'"));
self.loading = false;
self.rebuild_rows();
}
BranchViewOp::CheckoutError(msg) => {
self.status_msg = Some(format!("Checkout failed: {msg}"));
self.loading = false;
}
BranchViewOp::NewBranchOpen => {
self.new_branch_input = Some(InputField::new("Branch name"));
self.status_msg = None;
}
BranchViewOp::NewBranchInput(fop) => {
if let Some(f) = &mut self.new_branch_input {
f.apply(fop);
}
}
BranchViewOp::NewBranchCancel => {
self.new_branch_input = None;
}
BranchViewOp::CreateCompleted(branch) => {
self.new_branch_input = None;
self.current = branch.clone();
self.status_msg = Some(format!("Created and switched to '{branch}'"));
self.loading = false;
self.rebuild_rows();
}
BranchViewOp::CreateError(msg) => {
self.new_branch_input = None;
self.status_msg = Some(format!("Create failed: {msg}"));
self.loading = false;
}
BranchViewOp::DeleteConfirmNeeded { branch, merged } => {
self.pending_delete = Some(branch.clone());
if *merged {
self.status_msg = Some(format!("Press Delete again to delete '{branch}' (merged)"));
} else {
self.status_msg = Some(format!("Press Delete again to delete '{branch}' (not merged)"));
}
}
BranchViewOp::DeleteCompleted(branch) => {
self.pending_delete = None;
self.local.retain(|b| b != branch);
self.remote.retain(|b| b != branch);
self.status_msg = Some(format!("Deleted '{branch}'"));
self.rebuild_rows();
}
BranchViewOp::DeleteError(msg) => {
self.pending_delete = None;
self.status_msg = Some(format!("Delete failed: {msg}"));
}
BranchViewOp::DeleteRequested | BranchViewOp::DeleteConfirmed => {
}
}
Some(Event::applied("git_branches", op.clone()))
}
_ => None,
}
}
fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
render_view(self, frame, area, theme);
}
fn status_bar(
&self,
_state: &crate::app_state::AppState,
bar: &mut crate::widgets::status_bar::StatusBarBuilder,
) {
bar.label("Git Branches");
if !self.current.is_empty() {
bar.label(format!(" {}", self.current));
}
}
}
fn render_view(view: &BranchView, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let _bg = theme.bg();
let inner = area;
let has_input = view.new_branch_input.is_some();
let input_height = if has_input { 3 } else { 0 };
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(input_height)])
.split(inner);
let list_area = rows[0];
let input_area = rows[1];
render_branch_list(view, frame, list_area, theme);
if has_input {
render_new_branch_input(view, frame, input_area, theme);
}
}
fn render_branch_list(
view: &BranchView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
let bg = theme.bg();
let fg = theme.fg();
let fg_dim = theme.fg_dim();
let sel_bg = theme.selection_bg();
let sel_fg = theme.selection_fg();
let accent = theme.accent();
if view.loading && view.rows.is_empty() {
frame.render_widget(
Paragraph::new(Span::styled("Loading…", Style::default().fg(fg_dim))),
area,
);
return;
}
let height = area.height as usize;
view.last_height.set(height);
let total = view.rows.len();
let mut scroll = if height == 0 { 0 } else { view.scroll.get() };
let max_scroll = total.saturating_sub(height);
if scroll > max_scroll { scroll = max_scroll; }
if height > 0 {
if view.cursor < scroll {
scroll = view.cursor;
} else if view.cursor >= scroll + height {
scroll = view.cursor + 1 - height;
if scroll > max_scroll { scroll = max_scroll; }
}
} else {
scroll = 0;
}
let items: Vec<ListItem> = view
.rows
.iter()
.enumerate()
.skip(scroll)
.take(if height == 0 { 0 } else { height })
.map(|(i, row)| {
let RowKind::Branch { name, is_current, is_remote } = row;
let selected = i == view.cursor;
let item_bg = if selected { sel_bg } else { bg };
let item_fg = if selected { sel_fg } else { fg };
let indicator = if *is_current { "● " } else { " " };
let indicator_style = Style::default()
.fg(if *is_current { accent } else { fg_dim })
.bg(item_bg);
let name_style = Style::default()
.fg(item_fg)
.bg(item_bg)
.add_modifier(if selected { Modifier::BOLD } else { Modifier::empty() });
let remote_label_style = Style::default().fg(fg_dim).bg(item_bg);
let mut spans = vec![
Span::styled(indicator, indicator_style),
Span::styled(name.clone(), name_style),
];
if *is_remote {
spans.push(Span::styled(" (remote)", remote_label_style));
}
if *is_current {
spans.push(Span::styled(
" (current)",
Style::default().fg(accent).bg(item_bg),
));
}
ListItem::new(Line::from(spans))
})
.collect();
if items.is_empty() {
frame.render_widget(
Paragraph::new(Span::styled(" (no branches)", Style::default().fg(fg_dim))),
area,
);
view.scroll.set(0);
return;
}
frame.render_widget(List::new(items).style(Style::default().bg(bg)), area);
view.scroll.set(scroll);
if let Some(msg) = &view.status_msg {
let max_w = area.width.saturating_sub(2) as usize;
let truncated: String = msg.chars().take(max_w).collect();
let msg_len = truncated.chars().count() as u16;
let msg_area = Rect {
x: area.x + area.width.saturating_sub(msg_len + 1),
y: area.y + area.height.saturating_sub(1),
width: msg_len + 1,
height: 1,
};
frame.render_widget(
Paragraph::new(Span::styled(
truncated,
Style::default()
.fg(theme.fg_dim())
.add_modifier(Modifier::ITALIC),
)),
msg_area,
);
}
}
fn render_new_branch_input(
view: &BranchView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
if let Some(input) = &view.new_branch_input {
input.render(frame, area, true, theme);
}
}