use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
widgets::{Block, Borders, List, ListItem},
};
use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action;
use crate::components::Component;
use crate::repo_id::RepoId;
use super::*;
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct IndicatorColumns {
pub(super) stash: Option<(u16, u16)>,
pub(super) worktree: Option<(u16, u16)>,
}
pub(super) fn indicator_columns(
entry: &RepoEntry,
base_x: u16,
layout: &RowLayout,
) -> IndicatorColumns {
let mut out = IndicatorColumns::default();
let Some(status) = entry.status.as_ref() else {
return out;
};
let mut x = base_x.saturating_add(layout.attention_x());
for (text, kind) in attention_cells(status, false, false) {
let width = text.chars().count() as u16;
match kind {
AttentionCell::Stash => out.stash = Some((x, x + width)),
AttentionCell::Worktree => out.worktree = Some((x, x + width)),
_ => {}
}
x += width + 1;
}
out
}
impl Component for RepoList {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.action_tx = Some(tx);
Ok(())
}
fn init(&mut self) -> Result<()> {
Ok(())
}
fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.select_next();
Ok(self.emit_selection_action())
}
KeyCode::Char('k') | KeyCode::Up => {
self.select_prev();
Ok(self.emit_selection_action())
}
KeyCode::Char('w') => {
self.toggle_expand();
Ok(self.emit_selection_action())
}
KeyCode::Char('S') => {
self.toggle_stash_expand();
Ok(self.emit_selection_action())
}
KeyCode::Char('f') => {
self.focus_mode = !self.focus_mode;
Ok(None)
}
_ => Ok(None),
}
}
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
let content_y = self.render_area.y + 1;
if mouse.column >= self.render_area.x
&& mouse.column < self.render_area.x + self.render_area.width
&& mouse.row >= content_y
{
let visual_row = (mouse.row - content_y) as usize;
let idx = visual_row + self.state.offset();
if idx < self.display_rows.len() {
if self.state.selected() == Some(idx)
&& let Some(DisplayRow::Repo(i)) = self.display_rows.get(idx)
&& let Some(status) = self.repos[*i].status.as_ref()
{
let base_x = self.render_area.x + 1;
let inner_width = self.render_area.width.saturating_sub(2);
let layout = row_layout(&self.repos, &self.live_panes, inner_width);
let cols = indicator_columns(&self.repos[*i], base_x, &layout);
let clicked_stash = cols
.stash
.is_some_and(|(s, e)| mouse.column >= s && mouse.column < e);
let clicked_worktree = cols
.worktree
.is_some_and(|(s, e)| mouse.column >= s && mouse.column < e);
if clicked_stash {
self.toggle_stash_expand();
return Ok(self.emit_selection_action());
}
if clicked_worktree {
self.toggle_expand();
return Ok(self.emit_selection_action());
}
if !status.worktree_info.is_empty() {
self.toggle_expand();
return Ok(self.emit_selection_action());
}
if !status.stashes.is_empty() {
self.toggle_stash_expand();
return Ok(self.emit_selection_action());
}
}
self.state.select(Some(idx));
return Ok(self.emit_selection_action());
}
}
Ok(None)
}
MouseEventKind::Down(MouseButton::Right) => {
let content_y = self.render_area.y + 1;
if mouse.column >= self.render_area.x
&& mouse.column < self.render_area.x + self.render_area.width
&& mouse.row >= content_y
{
let visual_row = (mouse.row - content_y) as usize;
let idx = visual_row + self.state.offset();
if idx < self.display_rows.len() {
self.state.select(Some(idx));
let menu = match self.display_rows.get(idx) {
Some(DisplayRow::Repo(i)) => {
Some((RepoId(self.repos[*i].path.clone()), false))
}
Some(DisplayRow::Worktree(ri, wi)) => self.repos[*ri]
.status
.as_ref()
.and_then(|s| s.worktree_info.get(*wi))
.map(|wt| (RepoId(wt.path.clone()), true)),
_ => None,
};
if let Some((id, is_worktree)) = menu {
return Ok(Some(Action::ShowContextMenu {
id,
row: mouse.row,
col: mouse.column,
is_worktree,
}));
}
}
}
Ok(None)
}
MouseEventKind::ScrollUp => {
self.select_prev();
Ok(self.emit_selection_action())
}
MouseEventKind::ScrollDown => {
self.select_next();
Ok(self.emit_selection_action())
}
_ => Ok(None),
}
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::SelectNextRepo => {
self.select_next();
Ok(self.emit_selection_action())
}
Action::SelectPrevRepo => {
self.select_prev();
Ok(self.emit_selection_action())
}
Action::RepoStatusUpdated { ref id, ref status } => {
if let Some(idx) = self.resolve_index(id) {
self.update_status(idx, status.clone());
}
Ok(None)
}
_ => Ok(None),
}
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
self.render_area = area;
self.rebuild_display_rows();
let inner_width = area.width.saturating_sub(2);
let layout = row_layout(&self.repos, &self.live_panes, inner_width);
let selected_repo = self
.state
.selected()
.and_then(|s| self.display_rows.get(s))
.map(|row| match row {
DisplayRow::Repo(i) => *i,
DisplayRow::Worktree(ri, _) | DisplayRow::Stash(ri, _) => *ri,
});
let items: Vec<ListItem> = self
.display_rows
.iter()
.map(|row| {
let (item, repo_idx) = match row {
DisplayRow::Repo(i) => (self.render_repo_item(&self.repos[*i], &layout), *i),
DisplayRow::Worktree(ri, wi) => {
(self.render_worktree_item(&self.repos[*ri], *wi), *ri)
}
DisplayRow::Stash(ri, si) => {
(self.render_stash_item(&self.repos[*ri], *si), *ri)
}
};
if self.focus_mode && selected_repo.is_some_and(|s| s != repo_idx) {
item.style(Style::default().add_modifier(Modifier::DIM))
} else {
item
}
})
.collect();
let t = &self.theme.repo_list;
let border_color = if self.focused {
t.border_focused
} else {
t.border_unfocused
};
let list = List::new(items)
.block(
Block::default()
.title(" Repositories ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.highlight_style(
Style::default()
.bg(t.selection_bg)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, &mut self.state);
Ok(())
}
}