use std::io;
use std::path::PathBuf;
use anyhow::{Context, Result};
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use nucleo::Matcher;
use nucleo::pattern::{CaseMatching, Normalization, Pattern};
use ratatui::Frame;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Row, Table, TableState, Wrap};
use ratatui::{Terminal, prelude::Backend};
use teravars::Engine;
use crate::actions::{ActionKind, ActionOutcome, run_action};
use crate::cache::Cache;
use crate::cli::TuiArgs;
mod theme {
use ratatui::style::Color;
pub const MANTLE: Color = Color::Rgb(0x18, 0x18, 0x25);
pub const BASE: Color = Color::Rgb(0x1e, 0x1e, 0x2e);
pub const SURFACE0: Color = Color::Rgb(0x31, 0x32, 0x44);
pub const SURFACE1: Color = Color::Rgb(0x45, 0x47, 0x5a);
pub const OVERLAY: Color = Color::Rgb(0x6c, 0x70, 0x86);
pub const SUBTEXT: Color = Color::Rgb(0xba, 0xc2, 0xde);
pub const TEXT: Color = Color::Rgb(0xcd, 0xd6, 0xf4);
pub const LAVENDER: Color = Color::Rgb(0xb4, 0xbe, 0xfe);
pub const SKY: Color = Color::Rgb(0x89, 0xdc, 0xeb);
pub const TEAL: Color = Color::Rgb(0x94, 0xe2, 0xd5);
pub const GREEN: Color = Color::Rgb(0xa6, 0xe3, 0xa1);
pub const YELLOW: Color = Color::Rgb(0xf9, 0xe2, 0xaf);
pub const PEACH: Color = Color::Rgb(0xfa, 0xb3, 0x87);
pub const RED: Color = Color::Rgb(0xf3, 0x8b, 0xa8);
pub const MAUVE: Color = Color::Rgb(0xcb, 0xa6, 0xf7);
pub const PINK: Color = Color::Rgb(0xf5, 0xc2, 0xe7);
}
use crate::commands::ShokaContext;
use crate::commands::cd::emit_path;
use crate::config::{ResolvedConfig, ShokaConfig};
use crate::gh::{CiStatus, GhSnapshot};
use crate::git_status::GitStatusSnapshot;
use crate::state::{Repo, Shelf};
pub async fn run(ctx: &ShokaContext, args: TuiArgs) -> Result<()> {
let cfg = ShokaConfig::load(&ctx.paths)?;
let resolved = cfg.resolve(ctx.profile_override.as_deref())?;
let shelf = Shelf::load(&ctx.paths)?;
if shelf.is_empty() {
anyhow::bail!(
"shelf is empty — nothing to dashboard. `shoka clone <url>` \
or `shoka import <dir>` first"
);
}
let cache = match Cache::load(&ctx.paths) {
Ok(c) => c,
Err(e) => {
tracing::warn!(target: "shoka", "tui: cache load failed, falling back to no-status mode ({e:#})");
Cache::default()
}
};
let rows = build_rows(&shelf, &resolved, &cache, &args.tags)?;
if rows.is_empty() {
anyhow::bail!(
"no repos matched the tag filter ({} on the shelf total)",
shelf.len()
);
}
let own_owners = resolved.ui.own_owners.clone();
let mut app = App::new(rows, own_owners);
let selection = run_app(&mut app)?;
if let Some(idx) = selection {
emit_path(&app.rows[idx].path)?;
}
Ok(())
}
#[derive(Debug, Clone)]
struct DashRow {
slug: String,
owner: String,
path: PathBuf,
search_key: String,
tags_display: String,
status: Option<GitStatusSnapshot>,
gh: Option<GhSnapshot>,
}
fn build_rows(
shelf: &Shelf,
resolved: &ResolvedConfig,
cache: &Cache,
tag_filter: &[String],
) -> Result<Vec<DashRow>> {
let mut engine = Engine::new();
let mut out = Vec::with_capacity(shelf.len());
for repo in &shelf.repos {
if !tag_filter.is_empty() && !has_all_tags(repo, tag_filter) {
continue;
}
let path = resolved
.clone_path_for(repo, &mut engine)
.with_context(|| format!("resolving clone path for {}", repo.slug()))?;
let status = cache
.find(&repo.host, &repo.owner, &repo.name, repo.path.as_deref())
.and_then(|c| c.git_status.clone());
let gh = cache
.find_gh_by_triple(&repo.host, &repo.owner, &repo.name)
.cloned();
let slug = repo.slug();
let search_key = format!("{slug} {}", path.display());
out.push(DashRow {
slug,
owner: repo.owner.clone(),
path,
search_key,
tags_display: repo.tags.join(", "),
status,
gh,
});
}
Ok(out)
}
fn has_all_tags(repo: &Repo, wanted: &[String]) -> bool {
wanted.iter().all(|w| repo.tags.iter().any(|t| t == w))
}
fn owner_in(owners: &[String], candidate: &str) -> bool {
owners.iter().any(|o| o.eq_ignore_ascii_case(candidate))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Normal,
Filter,
}
struct App {
rows: Vec<DashRow>,
own_owners: Vec<String>,
mine_only: bool,
filter: String,
matches: Vec<usize>,
cursor: usize,
mode: Mode,
show_help: bool,
picker: Option<Picker>,
action_popup: Option<ActionPopup>,
status_message: Option<String>,
table_state: TableState,
matcher: Matcher,
}
#[derive(Debug, Clone)]
struct ActionPopup {
kind: ActionKind,
repo_label: String,
outcome: Option<ActionOutcome>,
error: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PickerKind {
Issues,
Prs,
}
impl PickerKind {
fn title(self) -> &'static str {
match self {
PickerKind::Issues => "Issues",
PickerKind::Prs => "Pull Requests",
}
}
}
struct Picker {
kind: PickerKind,
repo_label: String,
matcher: Matcher,
state: PickerState,
}
type PickerFetchResult = std::result::Result<Vec<crate::gh::PickerItem>, String>;
enum PickerState {
Loading {
started_at: std::time::Instant,
rx: tokio::sync::oneshot::Receiver<PickerFetchResult>,
abort: tokio::task::AbortHandle,
},
Loaded {
items: Vec<crate::gh::PickerItem>,
search_keys: Vec<String>,
filter: String,
matches: Vec<usize>,
cursor: usize,
},
Error(String),
}
impl Picker {
#[cfg(test)]
fn loaded(kind: PickerKind, repo_label: String, items: Vec<crate::gh::PickerItem>) -> Self {
let matches = (0..items.len()).collect();
let search_keys = items.iter().map(|i| i.search_key()).collect();
Self {
kind,
repo_label,
matcher: Matcher::default(),
state: PickerState::Loaded {
items,
search_keys,
filter: String::new(),
matches,
cursor: 0,
},
}
}
fn error(kind: PickerKind, repo_label: String, msg: impl Into<String>) -> Self {
Self {
kind,
repo_label,
matcher: Matcher::default(),
state: PickerState::Error(msg.into()),
}
}
fn is_loading(&self) -> bool {
matches!(self.state, PickerState::Loading { .. })
}
fn poll_fetch(&mut self) -> bool {
let PickerState::Loading { rx, .. } = &mut self.state else {
return false;
};
match rx.try_recv() {
Ok(Ok(items)) => {
let matches = (0..items.len()).collect();
let search_keys = items.iter().map(|i| i.search_key()).collect();
self.state = PickerState::Loaded {
items,
search_keys,
filter: String::new(),
matches,
cursor: 0,
};
true
}
Ok(Err(msg)) => {
self.state = PickerState::Error(msg);
true
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => false,
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
self.state = PickerState::Error("fetch task ended without a result".into());
true
}
}
}
fn abort_inflight(&self) {
if let PickerState::Loading { abort, .. } = &self.state {
abort.abort();
}
}
fn refilter(&mut self) {
let PickerState::Loaded {
items,
search_keys,
filter,
matches,
cursor,
} = &mut self.state
else {
return;
};
if filter.is_empty() {
*matches = (0..items.len()).collect();
} else {
let pattern = Pattern::parse(filter, CaseMatching::Smart, Normalization::Smart);
let mut scored: Vec<(usize, u32)> = Vec::new();
let mut buf: Vec<char> = Vec::new();
for (idx, key) in search_keys.iter().enumerate() {
buf.clear();
let haystack = nucleo::Utf32Str::new(key, &mut buf);
if let Some(score) = pattern.score(haystack, &mut self.matcher) {
scored.push((idx, score));
}
}
scored.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
*matches = scored.into_iter().map(|(idx, _)| idx).collect();
}
*cursor = 0;
}
fn move_down(&mut self) {
let PickerState::Loaded {
matches, cursor, ..
} = &mut self.state
else {
return;
};
if matches.is_empty() {
return;
}
*cursor = (*cursor + 1).min(matches.len() - 1);
}
fn move_up(&mut self) {
let PickerState::Loaded { cursor, .. } = &mut self.state else {
return;
};
*cursor = cursor.saturating_sub(1);
}
fn selected(&self) -> Option<&crate::gh::PickerItem> {
let PickerState::Loaded {
items,
matches,
cursor,
..
} = &self.state
else {
return None;
};
let idx = *matches.get(*cursor)?;
items.get(idx)
}
}
impl Drop for Picker {
fn drop(&mut self) {
self.abort_inflight();
}
}
impl App {
fn new(rows: Vec<DashRow>, own_owners: Vec<String>) -> Self {
let mine_only = !own_owners.is_empty();
let mut matches: Vec<usize> = (0..rows.len()).collect();
if mine_only {
matches.retain(|&i| owner_in(&own_owners, &rows[i].owner));
}
let mut table_state = TableState::default();
table_state.select(if matches.is_empty() { None } else { Some(0) });
Self {
rows,
own_owners,
mine_only,
filter: String::new(),
matches,
cursor: 0,
mode: Mode::Normal,
show_help: false,
picker: None,
action_popup: None,
status_message: None,
table_state,
matcher: Matcher::default(),
}
}
fn is_mine(&self, row: &DashRow) -> bool {
self.own_owners.is_empty() || owner_in(&self.own_owners, &row.owner)
}
fn toggle_mine_only(&mut self) {
if self.own_owners.is_empty() {
self.status_message =
Some("no `[ui].own_owners` configured — nothing to scope to".into());
return;
}
self.mine_only = !self.mine_only;
self.refilter();
self.status_message = Some(if self.mine_only {
"scope: mine".into()
} else {
"scope: all".into()
});
}
fn refilter(&mut self) {
if self.filter.is_empty() {
self.matches = (0..self.rows.len())
.filter(|&i| !self.mine_only || self.is_mine(&self.rows[i]))
.collect();
} else {
let pattern = Pattern::parse(&self.filter, CaseMatching::Smart, Normalization::Smart);
let mut scored: Vec<(usize, u32)> = Vec::new();
let mut buf: Vec<char> = Vec::new();
for (idx, row) in self.rows.iter().enumerate() {
if self.mine_only && !self.is_mine(row) {
continue;
}
buf.clear();
let haystack = nucleo::Utf32Str::new(&row.search_key, &mut buf);
if let Some(score) = pattern.score(haystack, &mut self.matcher) {
scored.push((idx, score));
}
}
scored.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
self.matches = scored.into_iter().map(|(idx, _)| idx).collect();
}
self.cursor = 0;
self.table_state.select(if self.matches.is_empty() {
None
} else {
Some(0)
});
}
fn move_down(&mut self) {
if self.matches.is_empty() {
return;
}
self.cursor = (self.cursor + 1).min(self.matches.len() - 1);
self.table_state.select(Some(self.cursor));
}
fn move_up(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
if !self.matches.is_empty() {
self.table_state.select(Some(self.cursor));
}
}
fn selected_row(&self) -> Option<usize> {
self.matches.get(self.cursor).copied()
}
}
struct TerminalGuard;
impl TerminalGuard {
fn new() -> Result<Self> {
enable_raw_mode().context("enabling terminal raw mode")?;
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)
.context("entering alt screen")?;
Ok(Self)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
crossterm::cursor::Show
);
}
}
fn run_app(app: &mut App) -> Result<Option<usize>> {
let _guard = TerminalGuard::new()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).context("constructing ratatui terminal")?;
event_loop(&mut terminal, app)
}
fn event_loop<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<Option<usize>> {
loop {
if let Some(picker) = app.picker.as_mut() {
picker.poll_fetch();
}
terminal
.draw(|f| ui(f, app))
.map_err(|e| anyhow::anyhow!("drawing frame: {e}"))?;
if app.picker.as_ref().is_some_and(|p| p.is_loading())
&& !event::poll(std::time::Duration::from_millis(SPINNER_FRAME_MS as u64))
.context("polling for event")?
{
continue;
}
let Event::Key(key) = event::read().context("reading event")? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
if app.action_popup.is_some() {
if key.code == KeyCode::Char('c')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
return Ok(None);
}
app.action_popup = None;
continue;
}
if app.picker.is_some() {
if key.code == KeyCode::Char('c')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
return Ok(None);
}
handle_picker_key(app, key);
continue;
}
if app.show_help {
if key.code == KeyCode::Char('c')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
return Ok(None);
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::F(1) => {
app.show_help = false;
}
_ => {}
}
continue;
}
match app.mode {
Mode::Normal => {
app.status_message = None;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
return Ok(None);
}
KeyCode::Char('?') | KeyCode::F(1) => {
app.show_help = true;
}
KeyCode::Char('i') => open_picker(app, PickerKind::Issues),
KeyCode::Char('p') => open_picker(app, PickerKind::Prs),
KeyCode::Char('f') => run_action_for_selected(app, ActionKind::Fetch),
KeyCode::Char('P') => run_action_for_selected(app, ActionKind::Push),
KeyCode::Char('y') => yank_selected_slug(app),
KeyCode::Char('o') => open_selected_repo_home(app),
KeyCode::Char('m') => app.toggle_mine_only(),
KeyCode::Char('j') | KeyCode::Down => app.move_down(),
KeyCode::Char('k') | KeyCode::Up => app.move_up(),
KeyCode::Char('g') => {
app.cursor = 0;
if !app.matches.is_empty() {
app.table_state.select(Some(0));
}
}
KeyCode::Char('G') if !app.matches.is_empty() => {
app.cursor = app.matches.len() - 1;
app.table_state.select(Some(app.cursor));
}
KeyCode::Char('/') => {
app.mode = Mode::Filter;
}
KeyCode::Enter => {
return Ok(app.selected_row());
}
_ => {}
}
}
Mode::Filter => match key.code {
KeyCode::Esc => {
app.filter.clear();
app.refilter();
app.mode = Mode::Normal;
}
KeyCode::Enter => {
app.mode = Mode::Normal;
}
KeyCode::Backspace => {
app.filter.pop();
app.refilter();
}
KeyCode::Char(c) => {
app.filter.push(c);
app.refilter();
}
_ => {}
},
}
}
}
fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(f.area());
render_header(f, chunks[0], app);
render_table(f, chunks[1], app);
render_footer(f, chunks[2], app);
if app.show_help {
render_help(f, f.area());
}
if let Some(picker) = &app.picker {
render_picker(f, f.area(), picker);
}
if let Some(popup) = &app.action_popup {
render_action_popup(f, f.area(), popup);
}
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
use ratatui::text::Span;
let total = app.rows.len();
let shown = app.matches.len();
let mut spans = vec![
Span::styled(" ✦ ", Style::default().fg(theme::MAUVE)),
Span::styled(
"shoka",
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" v{}", env!("CARGO_PKG_VERSION")),
Style::default().fg(theme::OVERLAY),
),
Span::styled(" 📚 ", Style::default().fg(theme::PEACH)),
Span::styled(
shown.to_string(),
Style::default()
.fg(theme::GREEN)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" / {total} repo(s)"),
Style::default().fg(theme::SUBTEXT),
),
];
if !app.own_owners.is_empty() {
spans.push(Span::styled(" ◇ ", Style::default().fg(theme::OVERLAY)));
spans.push(Span::styled(
if app.mine_only { "mine" } else { "all" },
Style::default()
.fg(if app.mine_only {
theme::TEAL
} else {
theme::PEACH
})
.add_modifier(Modifier::BOLD),
));
}
if !app.filter.is_empty() {
spans.push(Span::styled(
" ◇ /",
Style::default()
.fg(theme::PEACH)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
&app.filter,
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD),
));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::MANTLE)),
area,
);
}
fn render_table(f: &mut Frame, area: Rect, app: &mut App) {
use ratatui::text::Span;
use ratatui::widgets::Cell;
let header_row = Row::new(vec![
Cell::from(" repo "),
Cell::from(" branch "),
Cell::from(" ↑↓ "),
Cell::from(" ✓ "),
Cell::from(" PR "),
Cell::from(" CI "),
Cell::from(" path "),
Cell::from(" tags "),
])
.style(
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme::PINK)
.bg(theme::SURFACE0),
)
.height(1);
let rows: Vec<Row> = app
.matches
.iter()
.enumerate()
.map(|(visible_idx, &row_idx)| {
let row = &app.rows[row_idx];
let (branch, ahead_behind, dirty) = status_cells(row.status.as_ref());
let (pr, ci) = gh_cells(row.gh.as_ref());
let row_bg = if visible_idx % 2 == 0 {
theme::BASE
} else {
theme::MANTLE
};
let base_style = Style::default().fg(theme::TEXT).bg(row_bg);
Row::new(vec![
Cell::from(Line::from(vec![
Span::raw(" "),
Span::styled(&row.slug, Style::default().fg(theme::TEXT)),
])),
Cell::from(Span::styled(branch, Style::default().fg(theme::SKY))),
Cell::from(style_ahead_behind(&ahead_behind)),
Cell::from(style_dirty(&dirty)),
Cell::from(style_pr(&pr)),
Cell::from(style_ci(&ci)),
Cell::from(Span::styled(
row.path.to_string_lossy(),
Style::default().fg(theme::OVERLAY),
)),
Cell::from(Span::styled(
&row.tags_display,
Style::default().fg(theme::TEAL),
)),
])
.style(base_style)
})
.collect();
let widths = [
Constraint::Percentage(28), Constraint::Length(14), Constraint::Length(8), Constraint::Length(2), Constraint::Length(4), Constraint::Length(2), Constraint::Min(20), Constraint::Length(14), ];
let table = Table::new(rows, widths)
.header(header_row)
.row_highlight_style(
Style::default()
.bg(theme::LAVENDER)
.fg(theme::MANTLE)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ▶ ")
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::OVERLAY))
.style(Style::default().bg(theme::BASE)),
);
f.render_stateful_widget(table, area, &mut app.table_state);
}
fn style_ahead_behind(s: &str) -> ratatui::text::Span<'static> {
use ratatui::text::Span;
let color = if s == "=" {
theme::OVERLAY
} else if s.starts_with('↑') && s.contains('↓') {
theme::MAUVE
} else if s.starts_with('↑') {
theme::GREEN
} else if s.starts_with('↓') {
theme::YELLOW
} else {
theme::OVERLAY
};
Span::styled(s.to_string(), Style::default().fg(color))
}
fn style_dirty(s: &str) -> ratatui::text::Span<'static> {
use ratatui::text::Span;
let color = match s {
"✓" => theme::GREEN,
"●" => theme::PEACH,
_ => theme::OVERLAY,
};
Span::styled(s.to_string(), Style::default().fg(color))
}
fn style_pr(s: &str) -> ratatui::text::Span<'static> {
use ratatui::text::Span;
let style = if s == "0" || s == "-" {
Style::default().fg(theme::OVERLAY)
} else {
Style::default()
.fg(theme::PINK)
.add_modifier(Modifier::BOLD)
};
Span::styled(s.to_string(), style)
}
fn style_ci(s: &str) -> ratatui::text::Span<'static> {
use ratatui::text::Span;
let color = match s {
"✓" => theme::GREEN,
"✗" | "!" => theme::RED,
"◐" => theme::YELLOW,
"○" => theme::OVERLAY,
_ => theme::OVERLAY,
};
Span::styled(s.to_string(), Style::default().fg(color))
}
fn gh_cells(snap: Option<&GhSnapshot>) -> (String, String) {
let Some(snap) = snap else {
return ("-".into(), "-".into());
};
let pr = match snap.open_pr_count {
Some(n) if n >= 100 => "99+".into(),
Some(n) => n.to_string(),
None => "-".into(),
};
let ci = match snap.ci_status {
Some(CiStatus::Success) => "✓".into(),
Some(CiStatus::Failure) => "✗".into(),
Some(CiStatus::Pending) => "◐".into(),
Some(CiStatus::Skipped) => "○".into(),
Some(CiStatus::Other) => "!".into(),
None => "-".into(),
};
(pr, ci)
}
fn status_cells(status: Option<&GitStatusSnapshot>) -> (String, String, String) {
let Some(s) = status else {
return ("?".into(), "?".into(), "?".into());
};
let branch = s.branch.clone().unwrap_or_else(|| "-".into());
let ahead_behind = match (s.ahead, s.behind) {
(Some(0), Some(0)) => "=".to_string(),
(Some(a), Some(b)) if a > 0 && b > 0 => format!("↑{a} ↓{b}"),
(Some(a), Some(0)) if a > 0 => format!("↑{a}"),
(Some(0), Some(b)) if b > 0 => format!("↓{b}"),
_ => "-".into(),
};
let dirty = if s.dirty { "●".into() } else { "✓".into() };
(branch, ahead_behind, dirty)
}
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
use ratatui::text::Span;
if let Some(msg) = &app.status_message {
let line = Line::from(vec![
Span::styled(
" ✦ ",
Style::default()
.fg(theme::TEAL)
.add_modifier(Modifier::BOLD),
),
Span::styled(
msg,
Style::default()
.fg(theme::TEAL)
.add_modifier(Modifier::BOLD),
),
]);
f.render_widget(
Paragraph::new(line).style(Style::default().bg(theme::MANTLE)),
area,
);
return;
}
let line = match app.mode {
Mode::Normal => {
let mut pills: Vec<(&'static str, &'static str)> =
vec![("j/k", "move"), ("/", "filter"), ("⏎", "cd")];
if !app.own_owners.is_empty() {
pills.push(("m", if app.mine_only { "all" } else { "mine" }));
}
pills.extend_from_slice(&[
("i", "iss"),
("p", "PR"),
("f", "fetch"),
("P", "push"),
("y", "yank"),
("o", "open"),
("?", "help"),
("q", "quit"),
]);
Line::from(footer_pills(&pills))
}
Mode::Filter => Line::from(footer_pills(&[
("type", "filter"),
("⌫", "del"),
("⏎", "accept"),
("esc", "clear"),
])),
};
f.render_widget(
Paragraph::new(line).style(Style::default().bg(theme::MANTLE)),
area,
);
}
fn footer_pills(pairs: &[(&'static str, &'static str)]) -> Vec<ratatui::text::Span<'static>> {
use ratatui::text::Span;
let mut out: Vec<Span<'static>> = Vec::with_capacity(pairs.len() * 4);
for (key, label) in pairs {
out.push(Span::raw(" "));
out.push(Span::styled(
format!("[{key}]"),
Style::default()
.fg(theme::MAUVE)
.add_modifier(Modifier::BOLD),
));
out.push(Span::styled(*label, Style::default().fg(theme::SUBTEXT)));
}
out
}
fn render_help(f: &mut Frame, area: Rect) {
use ratatui::text::Span;
let popup = centered_rect(62, 78, area);
let sections: [(&str, &[(&str, &str)]); 4] = [
(
"Navigation",
&[
("j / ↓", "move down"),
("k / ↑", "move up"),
("g", "jump to top"),
("G", "jump to bottom"),
("/", "filter — type to narrow, esc to clear"),
("m", "toggle mine / all (needs [ui].own_owners)"),
("Enter", "select (emit path for the shell wrapper to cd)"),
],
),
(
"Pickers & Browser",
&[
("i", "open Issues for this repo in a fuzzy picker"),
("p", "open Pull Requests for this repo in a fuzzy picker"),
("o", "open repo home in browser"),
("y", "yank slug to clipboard"),
],
),
(
"Repo actions",
&[
("f", "fetch (jj git fetch / git fetch)"),
("P", "push (jj git push / git push)"),
],
),
(
"Quit",
&[
("? / F1", "toggle this help"),
("q / Esc", "quit"),
("Ctrl-C", "quit"),
],
),
];
let mut body: Vec<Line> = Vec::new();
for (i, (heading, entries)) in sections.iter().enumerate() {
if i > 0 {
body.push(Line::from(""));
}
body.push(Line::from(Span::styled(
format!(" ✦ {heading}"),
Style::default()
.fg(theme::MAUVE)
.add_modifier(Modifier::BOLD),
)));
let key_width = entries
.iter()
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(6);
for (key, desc) in entries.iter() {
body.push(Line::from(vec![
Span::styled(
format!(" {key:>w$} ", w = key_width),
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD),
),
Span::styled(*desc, Style::default().fg(theme::SUBTEXT)),
]));
}
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::MAUVE))
.title(Span::styled(
" 📚 shoka — keybinds ",
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::BASE));
let para = Paragraph::new(body).block(block);
f.render_widget(Clear, popup);
f.render_widget(para, popup);
}
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let pct_x = pct_x.min(100);
let pct_y = pct_y.min(100);
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.split(vert[1])[1]
}
fn open_picker(app: &mut App, kind: PickerKind) {
let Some(row_idx) = app.selected_row() else {
return;
};
let row = &app.rows[row_idx];
let parts: Vec<&str> = row.slug.splitn(3, '/').collect();
let &[host, owner, name] = parts.as_slice() else {
app.picker = Some(Picker::error(
kind,
row.slug.clone(),
format!("can't parse slug `{}`", row.slug),
));
return;
};
let repo_label = format!("{host}/{owner}/{name}");
if host != "github.com" {
app.picker = Some(Picker::error(
kind,
repo_label,
format!(
"{} are only available on github.com (this repo is on `{host}`)",
kind.title()
),
));
return;
}
let (tx, rx) = tokio::sync::oneshot::channel();
let owner = owner.to_string();
let name = name.to_string();
let join = tokio::spawn(async move {
let result: PickerFetchResult = async {
let Some(token) = crate::gh::resolve_token().await else {
return Err("no GITHUB_TOKEN — set the env var or run `gh auth login`".to_string());
};
let client = crate::gh::build_client(&token)
.map_err(|e| format!("gh client init failed: {e:#}"))?;
let fetched = match kind {
PickerKind::Issues => crate::gh::list_open_issues(&client, &owner, &name).await,
PickerKind::Prs => crate::gh::list_open_prs(&client, &owner, &name).await,
};
fetched.map_err(|e| format!("{e:#}"))
}
.await;
let _ = tx.send(result);
});
app.picker = Some(Picker {
kind,
repo_label,
matcher: Matcher::default(),
state: PickerState::Loading {
started_at: std::time::Instant::now(),
rx,
abort: join.abort_handle(),
},
});
}
fn handle_picker_key(app: &mut App, key: crossterm::event::KeyEvent) {
let Some(picker) = app.picker.as_mut() else {
return;
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.picker = None;
}
_ if picker.is_loading() => {
}
KeyCode::Char('j') | KeyCode::Down => picker.move_down(),
KeyCode::Char('k') | KeyCode::Up => picker.move_up(),
KeyCode::Enter => {
if let Some(item) = picker.selected() {
let url = item.html_url.clone();
if let Err(e) = open::that(&url) {
tracing::warn!(
target: "shoka",
"failed to open {url} in browser: {e:#}"
);
}
}
app.picker = None;
}
KeyCode::Backspace => {
if let PickerState::Loaded { filter, .. } = &mut picker.state {
filter.pop();
}
picker.refilter();
}
KeyCode::Char(c) => {
if let PickerState::Loaded { filter, .. } = &mut picker.state {
filter.push(c);
}
picker.refilter();
}
_ => {}
}
}
fn run_action_for_selected(app: &mut App, kind: ActionKind) {
let Some(row_idx) = app.selected_row() else {
return;
};
let row = &app.rows[row_idx];
let repo_label = row.slug.clone();
let path = row.path.clone();
let result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(run_action(&path, kind))
});
app.action_popup = Some(match result {
Ok(outcome) => ActionPopup {
kind,
repo_label,
outcome: Some(outcome),
error: String::new(),
},
Err(e) => ActionPopup {
kind,
repo_label,
outcome: None,
error: format!("{e:#}"),
},
});
}
fn yank_selected_slug(app: &mut App) {
let Some(row_idx) = app.selected_row() else {
return;
};
let slug = app.rows[row_idx].slug.clone();
match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(slug.clone())) {
Ok(()) => {
app.status_message = Some(format!("yanked: {slug}"));
}
Err(e) => {
app.status_message = Some(format!("yank failed: {e}"));
}
}
}
fn open_selected_repo_home(app: &mut App) {
let Some(row_idx) = app.selected_row() else {
return;
};
let slug = app.rows[row_idx].slug.clone();
if slug.starts_with("local/") {
app.status_message = Some("no repo home: local-only repo".into());
return;
}
let url = format!("https://{slug}");
match open::that(&url) {
Ok(()) => {
app.status_message = Some(format!("opened: {url}"));
}
Err(e) => {
app.status_message = Some(format!("open failed: {e}"));
}
}
}
fn render_action_popup(f: &mut Frame, area: Rect, popup: &ActionPopup) {
let rect = centered_rect(80, 60, area);
f.render_widget(Clear, rect);
let title = format!(" ⚙ {} — {} ", popup.kind.label(), popup.repo_label);
let border_color = match &popup.outcome {
Some(o) if o.success => theme::GREEN,
Some(_) => theme::RED,
None => theme::RED,
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(ratatui::text::Span::styled(
title,
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::BASE));
let inner = block.inner(rect);
f.render_widget(block, rect);
let mut lines: Vec<Line> = Vec::new();
if let Some(outcome) = &popup.outcome {
let status = if outcome.success {
"✓ success"
} else {
"✗ failed"
};
let status_color = if outcome.success {
theme::GREEN
} else {
theme::RED
};
lines.push(Line::from(vec![
ratatui::text::Span::styled(
format!(" {} ", outcome.vcs.label()),
Style::default()
.fg(theme::MAUVE)
.add_modifier(Modifier::BOLD),
),
ratatui::text::Span::styled(
format!("$ {}", outcome.command),
Style::default().fg(theme::TEXT),
),
]));
lines.push(Line::from(ratatui::text::Span::styled(
format!(" {status}"),
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
if !outcome.stdout.trim().is_empty() {
lines.push(Line::from(ratatui::text::Span::styled(
" stdout:",
Style::default().fg(theme::SKY).add_modifier(Modifier::BOLD),
)));
for line in outcome.stdout.lines() {
lines.push(Line::from(ratatui::text::Span::styled(
format!(" {line}"),
Style::default().fg(theme::SUBTEXT),
)));
}
}
if !outcome.stderr.trim().is_empty() {
if !outcome.stdout.trim().is_empty() {
lines.push(Line::from(""));
}
lines.push(Line::from(ratatui::text::Span::styled(
" stderr:",
Style::default()
.fg(theme::PINK)
.add_modifier(Modifier::BOLD),
)));
for line in outcome.stderr.lines() {
lines.push(Line::from(ratatui::text::Span::styled(
format!(" {line}"),
Style::default().fg(theme::SUBTEXT),
)));
}
}
if outcome.stdout.trim().is_empty() && outcome.stderr.trim().is_empty() {
lines.push(Line::from(ratatui::text::Span::styled(
" (no output)",
Style::default().fg(theme::OVERLAY),
)));
}
} else {
lines.push(Line::from(""));
lines.push(Line::from(ratatui::text::Span::styled(
format!(" ⚠ {}", popup.error),
Style::default().fg(theme::RED),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(ratatui::text::Span::styled(
" press any key to close",
Style::default().fg(theme::OVERLAY),
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const SPINNER_FRAME_MS: u128 = 80;
fn render_picker(f: &mut Frame, area: Rect, picker: &Picker) {
let popup = centered_rect(80, 80, area);
f.render_widget(Clear, popup);
let title_glyph = match picker.kind {
PickerKind::Issues => "🐛",
PickerKind::Prs => "🔀",
};
let title = format!(
" {title_glyph} {} — {} ",
picker.kind.title(),
picker.repo_label
);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::MAUVE))
.title(ratatui::text::Span::styled(
title,
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::BASE));
let inner = block.inner(popup);
f.render_widget(block, popup);
match &picker.state {
PickerState::Loading { started_at, .. } => {
let elapsed = started_at.elapsed().as_millis();
let frame = SPINNER_FRAMES
[((elapsed / SPINNER_FRAME_MS) % SPINNER_FRAMES.len() as u128) as usize];
let body = vec![
Line::from(""),
Line::from(vec![
ratatui::text::Span::styled(
format!(" {frame} "),
Style::default()
.fg(theme::PEACH)
.add_modifier(Modifier::BOLD),
),
ratatui::text::Span::styled(
format!("fetching {}…", picker.kind.title().to_lowercase()),
Style::default().fg(theme::TEXT),
),
]),
Line::from(""),
Line::from(ratatui::text::Span::styled(
" esc / q to cancel",
Style::default().fg(theme::OVERLAY),
)),
];
f.render_widget(Paragraph::new(body), inner);
}
PickerState::Error(err) => {
let body = vec![
Line::from(""),
Line::from(ratatui::text::Span::styled(
format!(" ⚠ {err}"),
Style::default().fg(theme::RED),
)),
Line::from(""),
Line::from(ratatui::text::Span::styled(
" esc / q to close",
Style::default().fg(theme::OVERLAY),
)),
];
f.render_widget(Paragraph::new(body), inner);
}
PickerState::Loaded {
items,
filter,
matches,
cursor,
..
} => {
render_picker_loaded(f, inner, items, filter, matches, *cursor);
}
}
}
fn render_picker_loaded(
f: &mut Frame,
inner: Rect,
items: &[crate::gh::PickerItem],
filter: &str,
matches: &[usize],
cursor: usize,
) {
use ratatui::text::Span;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(inner);
let filter_line = Line::from(vec![
Span::styled(
" / ",
Style::default()
.fg(theme::PEACH)
.add_modifier(Modifier::BOLD),
),
Span::styled(filter, Style::default().fg(theme::YELLOW)),
Span::styled(
"▌",
Style::default()
.fg(theme::PEACH)
.add_modifier(Modifier::BOLD),
),
]);
f.render_widget(Paragraph::new(filter_line), chunks[0]);
let visible_h = chunks[1].height as usize;
let total = matches.len();
let scroll_top = cursor.saturating_sub(visible_h.saturating_sub(1));
let mut lines: Vec<Line> = Vec::with_capacity(total.min(visible_h));
for (visual_idx, &item_idx) in matches.iter().enumerate().skip(scroll_top).take(visible_h) {
let item = &items[item_idx];
let is_cursor = visual_idx == cursor;
let prefix = if is_cursor { " ▶ " } else { " " };
let mut spans: Vec<Span> = vec![
Span::styled(
prefix,
Style::default()
.fg(theme::PEACH)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("#{:<5} ", item.number),
Style::default().fg(theme::MAUVE),
),
Span::styled(&item.title, Style::default().fg(theme::TEXT)),
];
if !item.labels.is_empty() {
spans.push(Span::styled(
format!(" [{}]", item.labels.join(",")),
Style::default().fg(theme::TEAL),
));
}
let line_style = if is_cursor {
Style::default()
.bg(theme::SURFACE1)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(spans).style(line_style));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
" (no matches)",
Style::default().fg(theme::OVERLAY),
)));
}
f.render_widget(Paragraph::new(lines), chunks[1]);
f.render_widget(
Paragraph::new(Line::from(footer_pills(&[
("j/k", "move"),
("type", "filter"),
("⏎", "open"),
("esc", "close"),
]))),
chunks[2],
);
}
#[cfg(test)]
mod tests {
use super::*;
fn owner_from_slug(slug: &str) -> String {
slug.split('/').nth(1).unwrap_or("").to_string()
}
fn rows(slugs: &[&str]) -> Vec<DashRow> {
slugs
.iter()
.map(|s| {
let path = PathBuf::from("/tmp");
let search_key = format!("{s} {}", path.display());
DashRow {
slug: (*s).into(),
owner: owner_from_slug(s),
path,
search_key,
tags_display: String::new(),
status: None,
gh: None,
}
})
.collect()
}
fn rows_with_paths(items: &[(&str, &str)]) -> Vec<DashRow> {
items
.iter()
.map(|(slug, path)| {
let path = PathBuf::from(path);
let search_key = format!("{slug} {}", path.display());
DashRow {
slug: (*slug).into(),
owner: owner_from_slug(slug),
path,
search_key,
tags_display: String::new(),
status: None,
gh: None,
}
})
.collect()
}
fn app(rows: Vec<DashRow>) -> App {
App::new(rows, Vec::new())
}
#[test]
fn build_rows_splits_status_per_path_but_shares_gh_by_triple() {
use crate::cache::Cache;
use crate::config::ShokaConfig;
use crate::gh::{CiStatus, GhSnapshot};
use crate::git_status::GitStatusSnapshot;
use crate::state::{Repo, Shelf};
let path_a = PathBuf::from("/home/u/a/shoka");
let path_b = PathBuf::from("/home/u/b/shoka");
let mut shelf = Shelf::default();
shelf
.add(Repo::new("github.com", "yukimemi", "shoka").with_path(path_a.clone()))
.unwrap();
shelf
.add(Repo::new("github.com", "yukimemi", "shoka").with_path(path_b.clone()))
.unwrap();
let status_a = GitStatusSnapshot {
branch: Some("feat/a".into()),
dirty: false,
ahead: Some(1),
behind: Some(0),
};
let status_b = GitStatusSnapshot {
branch: Some("feat/b".into()),
dirty: true,
ahead: Some(0),
behind: Some(2),
};
let shared_gh = GhSnapshot {
open_pr_count: Some(7),
ci_status: Some(CiStatus::Success),
};
let mut cache = Cache::default();
let row_a = cache.upsert(&shelf.repos[0]);
row_a.git_status = Some(status_a.clone());
row_a.gh = None;
let row_b = cache.upsert(&shelf.repos[1]);
row_b.git_status = Some(status_b.clone());
row_b.gh = Some(shared_gh.clone());
let mut cfg = ShokaConfig::default();
cfg.global.root = Some("/unused-for-pinned-rows".into());
let resolved = cfg.resolve(None).expect("resolve");
let rows = build_rows(&shelf, &resolved, &cache, &[]).expect("build rows");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].status.as_ref(), Some(&status_a));
assert_eq!(rows[1].status.as_ref(), Some(&status_b));
assert_eq!(rows[0].gh.as_ref(), Some(&shared_gh));
assert_eq!(rows[1].gh.as_ref(), Some(&shared_gh));
}
#[test]
fn gh_cells_renders_dashes_when_snapshot_missing() {
let (pr, ci) = gh_cells(None);
assert_eq!(pr, "-");
assert_eq!(ci, "-");
}
#[test]
fn gh_cells_renders_count_and_status_glyph() {
let snap = GhSnapshot {
open_pr_count: Some(5),
ci_status: Some(CiStatus::Success),
};
let (pr, ci) = gh_cells(Some(&snap));
assert_eq!(pr, "5");
assert_eq!(ci, "✓");
}
#[test]
fn gh_cells_clamps_pr_count_at_99_plus() {
let snap = GhSnapshot {
open_pr_count: Some(150),
ci_status: None,
};
let (pr, _) = gh_cells(Some(&snap));
assert_eq!(pr, "99+");
}
#[test]
fn gh_cells_distinguishes_zero_from_none() {
let zero = GhSnapshot {
open_pr_count: Some(0),
ci_status: None,
};
let (pr_zero, _) = gh_cells(Some(&zero));
let (pr_none, _) = gh_cells(None);
assert_eq!(pr_zero, "0");
assert_eq!(pr_none, "-");
assert_ne!(pr_zero, pr_none);
}
#[test]
fn gh_cells_renders_each_ci_status() {
for (status, expected) in [
(CiStatus::Success, "✓"),
(CiStatus::Failure, "✗"),
(CiStatus::Pending, "◐"),
(CiStatus::Skipped, "○"),
(CiStatus::Other, "!"),
] {
let snap = GhSnapshot {
open_pr_count: None,
ci_status: Some(status),
};
let (_, ci) = gh_cells(Some(&snap));
assert_eq!(ci, expected, "ci glyph for {status:?}");
}
}
fn snap(
branch: &str,
ahead: Option<usize>,
behind: Option<usize>,
dirty: bool,
) -> GitStatusSnapshot {
GitStatusSnapshot {
branch: Some(branch.into()),
dirty,
ahead,
behind,
}
}
#[test]
fn status_cells_renders_unknown_for_missing_snapshot() {
let (b, ab, d) = status_cells(None);
assert_eq!(b, "?");
assert_eq!(ab, "?");
assert_eq!(d, "?");
}
#[test]
fn status_cells_renders_clean_branch_with_equal_marker() {
let s = snap("main", Some(0), Some(0), false);
let (b, ab, d) = status_cells(Some(&s));
assert_eq!(b, "main");
assert_eq!(ab, "=");
assert_eq!(d, "✓");
}
#[test]
fn status_cells_renders_ahead_only_and_behind_only() {
let (_, ab_ahead, _) = status_cells(Some(&snap("x", Some(3), Some(0), false)));
assert_eq!(ab_ahead, "↑3");
let (_, ab_behind, _) = status_cells(Some(&snap("x", Some(0), Some(2), false)));
assert_eq!(ab_behind, "↓2");
}
#[test]
fn status_cells_renders_diverged_with_both_arrows() {
let (_, ab, _) = status_cells(Some(&snap("x", Some(2), Some(5), false)));
assert_eq!(ab, "↑2 ↓5");
}
#[test]
fn status_cells_renders_dash_when_upstream_unknown() {
let (_, ab, _) = status_cells(Some(&snap("x", None, None, false)));
assert_eq!(ab, "-");
}
#[test]
fn status_cells_renders_dirty_glyph() {
let (_, _, d) = status_cells(Some(&snap("x", Some(0), Some(0), true)));
assert_eq!(d, "●");
}
#[test]
fn refilter_empty_query_keeps_shelf_order() {
let mut app = app(rows(&["alpha", "beta", "gamma"]));
app.refilter();
assert_eq!(app.matches, vec![0, 1, 2]);
}
#[test]
fn refilter_narrows_to_matches() {
let mut app = app(rows(&["alpha", "beta", "gamma", "alphabeta"]));
app.filter = "alpha".into();
app.refilter();
let matched_slugs: Vec<&str> = app
.matches
.iter()
.map(|&i| app.rows[i].slug.as_str())
.collect();
assert!(
matched_slugs.contains(&"alpha"),
"missing alpha: {matched_slugs:?}"
);
assert!(
matched_slugs.contains(&"alphabeta"),
"missing alphabeta: {matched_slugs:?}"
);
assert!(
!matched_slugs.contains(&"beta"),
"beta shouldn't match: {matched_slugs:?}"
);
assert!(
!matched_slugs.contains(&"gamma"),
"gamma shouldn't match: {matched_slugs:?}"
);
}
#[test]
fn refilter_no_matches_leaves_empty() {
let mut app = app(rows(&["alpha", "beta"]));
app.filter = "zzzzz".into();
app.refilter();
assert!(app.matches.is_empty());
assert_eq!(app.cursor, 0);
assert!(app.table_state.selected().is_none());
}
#[test]
fn refilter_matches_against_path_for_multi_clone_disambiguation() {
let mut app = app(rows_with_paths(&[
(
"github.com/yukimemi/admintask",
"/home/u/src/DeviceManagement",
),
(
"github.com/yukimemi/admintask",
"/home/u/old/admintask-backup",
),
("github.com/yukimemi/shoka", "/home/u/src/shoka"),
]));
app.filter = "backup".into();
app.refilter();
assert_eq!(
app.matches.len(),
1,
"exactly one row should match `backup`: {:?}",
app.matches
.iter()
.map(|&i| &app.rows[i].path)
.collect::<Vec<_>>()
);
assert_eq!(
app.rows[app.matches[0]].path.to_string_lossy(),
"/home/u/old/admintask-backup"
);
}
#[test]
fn refilter_resets_cursor_to_top() {
let mut app = app(rows(&["a", "b", "c", "d"]));
app.cursor = 3;
app.refilter();
assert_eq!(app.cursor, 0);
}
#[test]
fn navigation_clamps_at_edges() {
let mut app = app(rows(&["a", "b", "c"]));
app.move_up();
assert_eq!(app.cursor, 0);
app.move_down();
app.move_down();
app.move_down(); assert_eq!(app.cursor, 2);
}
#[test]
fn navigation_on_empty_match_list_is_noop() {
let mut app = app(rows(&[]));
app.move_down();
app.move_up();
assert_eq!(app.cursor, 0);
assert!(app.selected_row().is_none());
}
#[test]
fn selected_row_returns_underlying_shelf_index() {
let mut app = app(rows(&["zzz", "aaa"]));
app.filter = "aaa".into();
app.refilter();
assert_eq!(app.selected_row(), Some(1));
}
#[test]
fn mine_only_starts_active_when_own_owners_configured() {
let rows = rows(&["github.com/yukimemi/shoka", "github.com/rust-lang/rust"]);
let app = App::new(rows, vec!["yukimemi".into()]);
assert!(app.mine_only, "should default to mine when own_owners set");
let matched_slugs: Vec<&str> = app
.matches
.iter()
.map(|&i| app.rows[i].slug.as_str())
.collect();
assert_eq!(matched_slugs, vec!["github.com/yukimemi/shoka"]);
}
#[test]
fn mine_only_disabled_when_own_owners_empty() {
let app = app(rows(&[
"github.com/yukimemi/shoka",
"github.com/rust-lang/rust",
]));
assert!(!app.mine_only);
assert_eq!(app.matches, vec![0, 1]);
}
#[test]
fn toggle_mine_only_flips_visible_set() {
let rows = rows(&["github.com/yukimemi/shoka", "github.com/rust-lang/rust"]);
let mut app = App::new(rows, vec!["yukimemi".into()]);
app.toggle_mine_only();
assert!(!app.mine_only);
assert_eq!(app.matches.len(), 2);
app.toggle_mine_only();
assert!(app.mine_only);
assert_eq!(app.matches.len(), 1);
}
#[test]
fn toggle_mine_only_is_noop_with_status_when_unconfigured() {
let mut app = app(rows(&["github.com/yukimemi/shoka"]));
app.toggle_mine_only();
assert!(!app.mine_only, "should stay off when nothing to scope to");
let msg = app.status_message.as_ref().expect("status banner set");
assert!(
msg.contains("own_owners"),
"banner should point at the config key, got: {msg}"
);
}
#[test]
fn mine_only_matches_owner_case_insensitively() {
let rows = rows(&["github.com/yukimemi/shoka", "github.com/RUST-LANG/rust"]);
let app = App::new(rows, vec!["YukimemI".into(), "rust-lang".into()]);
assert!(app.mine_only);
let matched: Vec<&str> = app
.matches
.iter()
.map(|&i| app.rows[i].slug.as_str())
.collect();
assert_eq!(matched.len(), 2, "both rows should match: {matched:?}");
assert!(matched.contains(&"github.com/yukimemi/shoka"));
assert!(matched.contains(&"github.com/RUST-LANG/rust"));
}
#[test]
fn owner_in_is_case_insensitive() {
let owners = vec!["yukimemi".into(), "Rust-Lang".into()];
assert!(owner_in(&owners, "yukimemi"));
assert!(owner_in(&owners, "YukimemI"));
assert!(owner_in(&owners, "rust-lang"));
assert!(owner_in(&owners, "RUST-LANG"));
assert!(!owner_in(&owners, "someone-else"));
assert!(!owner_in(&[], "anyone"));
}
#[test]
fn mine_only_composes_with_text_filter() {
let rows = rows(&[
"github.com/yukimemi/shoka",
"github.com/yukimemi/renri",
"github.com/rust-lang/rust",
]);
let mut app = App::new(rows, vec!["yukimemi".into()]);
app.filter = "rust".into();
app.refilter();
assert!(
app.matches.is_empty(),
"non-mine matches must be hidden: {:?}",
app.matches
.iter()
.map(|&i| app.rows[i].slug.as_str())
.collect::<Vec<_>>()
);
}
fn picker_items(items: &[(u64, &str, &[&str])]) -> Vec<crate::gh::PickerItem> {
items
.iter()
.map(|(n, t, ls)| crate::gh::PickerItem {
number: *n,
title: (*t).into(),
html_url: format!("https://github.com/x/y/issues/{n}"),
labels: ls.iter().map(|s| (*s).into()).collect(),
})
.collect()
}
#[test]
fn picker_refilter_empty_query_keeps_identity_order() {
let mut p = Picker::loaded(
PickerKind::Issues,
"x/y/z".into(),
picker_items(&[(1, "alpha", &[]), (2, "beta", &[]), (3, "gamma", &[])]),
);
p.refilter();
assert_eq!(picker_matches(&p), &[0, 1, 2]);
}
#[test]
fn picker_refilter_narrows_against_title_and_labels() {
let mut p = Picker::loaded(
PickerKind::Issues,
"x/y/z".into(),
picker_items(&[
(1, "broken thing", &["bug"]),
(2, "happy path", &["enhancement"]),
(3, "another bug-fix", &[]),
]),
);
if let PickerState::Loaded { filter, .. } = &mut p.state {
*filter = "bug".into();
}
p.refilter();
let PickerState::Loaded { matches, items, .. } = &p.state else {
panic!("expected loaded picker")
};
let matched: Vec<u64> = matches.iter().map(|&i| items[i].number).collect();
assert!(
matched.contains(&1) && matched.contains(&3),
"expected items 1 + 3 to match, got: {matched:?}"
);
assert!(
!matched.contains(&2),
"item 2 should not match `bug`, got: {matched:?}"
);
}
#[test]
fn picker_error_state_holds_message_and_blocks_selection() {
let p = Picker::error(PickerKind::Prs, "github.com/x/y".into(), "no GITHUB_TOKEN");
assert!(matches!(p.state, PickerState::Error(_)));
assert_eq!(picker_error_message(&p), "no GITHUB_TOKEN");
assert!(p.selected().is_none());
}
#[test]
fn picker_search_key_includes_number_title_and_labels() {
let item = crate::gh::PickerItem {
number: 42,
title: "fix the thing".into(),
html_url: "https://github.com/x/y/issues/42".into(),
labels: vec!["bug".into(), "p1".into()],
};
let key = item.search_key();
assert!(key.contains("42"), "number missing: {key}");
assert!(key.contains("fix the thing"), "title missing: {key}");
assert!(key.contains("bug"), "label missing: {key}");
assert!(key.contains("p1"), "label missing: {key}");
}
fn app_with_single_slug(slug: &str) -> App {
app(rows(&[slug]))
}
fn key(code: KeyCode) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
fn picker_error_message(picker: &Picker) -> &str {
match &picker.state {
PickerState::Error(msg) => msg.as_str(),
other => panic!(
"expected PickerState::Error, got {:?}",
std::mem::discriminant(other)
),
}
}
fn picker_filter(picker: &Picker) -> &str {
match &picker.state {
PickerState::Loaded { filter, .. } => filter.as_str(),
other => panic!(
"expected PickerState::Loaded, got {:?}",
std::mem::discriminant(other)
),
}
}
fn picker_matches(picker: &Picker) -> &[usize] {
match &picker.state {
PickerState::Loaded { matches, .. } => matches,
other => panic!(
"expected PickerState::Loaded, got {:?}",
std::mem::discriminant(other)
),
}
}
#[test]
fn open_picker_short_circuits_on_malformed_slug() {
let mut app = app_with_single_slug("no-slashes-here");
open_picker(&mut app, PickerKind::Issues);
let picker = app.picker.as_ref().expect("error picker installed");
let err = picker_error_message(picker);
assert!(
err.contains("no-slashes-here"),
"malformed-slug error should mention the slug, got: {err}"
);
}
#[test]
fn open_picker_short_circuits_on_non_github_host() {
let mut app = app_with_single_slug("gitlab.com/some/proj");
open_picker(&mut app, PickerKind::Prs);
let picker = app.picker.as_ref().expect("error picker installed");
let err = picker_error_message(picker);
assert!(
err.contains("github.com") && err.contains("gitlab.com"),
"non-github error should mention both the requirement and the actual host, got: {err}"
);
assert!(
err.contains(PickerKind::Prs.title()),
"error should name the kind so the user knows what they tried to open, got: {err}"
);
}
#[test]
fn handle_picker_key_esc_closes_picker() {
let mut app = app(rows(&[]));
app.picker = Some(Picker::loaded(
PickerKind::Issues,
"github.com/x/y".into(),
picker_items(&[(1, "alpha", &[])]),
));
handle_picker_key(&mut app, key(KeyCode::Esc));
assert!(app.picker.is_none(), "Esc should close the picker");
}
#[test]
fn handle_picker_key_q_closes_picker() {
let mut app = app(rows(&[]));
app.picker = Some(Picker::loaded(
PickerKind::Issues,
"github.com/x/y".into(),
picker_items(&[(1, "alpha", &[])]),
));
handle_picker_key(&mut app, key(KeyCode::Char('q')));
assert!(app.picker.is_none(), "q should close the picker");
}
#[test]
fn handle_picker_key_enter_on_empty_items_closes_without_browser_launch() {
let mut app = app(rows(&[]));
app.picker = Some(Picker::loaded(
PickerKind::Issues,
"github.com/x/y".into(),
vec![],
));
handle_picker_key(&mut app, key(KeyCode::Enter));
assert!(
app.picker.is_none(),
"Enter should close the picker even with nothing to open"
);
}
#[test]
fn handle_picker_key_char_appends_to_filter_and_refilters() {
let mut app = app(rows(&[]));
app.picker = Some(Picker::loaded(
PickerKind::Issues,
"github.com/x/y".into(),
picker_items(&[(1, "alpha", &[]), (2, "zeta", &[])]),
));
handle_picker_key(&mut app, key(KeyCode::Char('a')));
let picker = app.picker.as_ref().unwrap();
assert_eq!(picker_filter(picker), "a");
assert!(!picker_matches(picker).is_empty());
}
#[test]
fn handle_picker_key_backspace_pops_and_refilters() {
let mut app = app(rows(&[]));
let mut p = Picker::loaded(
PickerKind::Issues,
"github.com/x/y".into(),
picker_items(&[(1, "alpha", &[])]),
);
if let PickerState::Loaded { filter, .. } = &mut p.state {
*filter = "ab".into();
}
p.refilter();
app.picker = Some(p);
handle_picker_key(&mut app, key(KeyCode::Backspace));
let picker = app.picker.as_ref().unwrap();
assert_eq!(picker_filter(picker), "a");
}
#[test]
fn loading_state_swallows_navigation_until_fetch_lands() {
let (_tx, rx) = tokio::sync::oneshot::channel::<PickerFetchResult>();
let join = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.spawn(async {});
let picker = Picker {
kind: PickerKind::Issues,
repo_label: "github.com/x/y".into(),
matcher: Matcher::default(),
state: PickerState::Loading {
started_at: std::time::Instant::now(),
rx,
abort: join.abort_handle(),
},
};
let mut app = App::new(rows(&[]), Vec::new());
app.picker = Some(picker);
assert!(app.picker.as_ref().unwrap().is_loading());
for code in [
KeyCode::Char('j'),
KeyCode::Char('k'),
KeyCode::Enter,
KeyCode::Char('a'),
KeyCode::Backspace,
] {
handle_picker_key(&mut app, key(code));
let picker = app.picker.as_ref().expect("picker survives ignored key");
assert!(
picker.is_loading(),
"key {code:?} must not transition the picker out of Loading"
);
}
handle_picker_key(&mut app, key(KeyCode::Esc));
assert!(
app.picker.is_none(),
"Esc on Loading must still close the picker"
);
}
#[test]
fn poll_fetch_closed_channel_transitions_to_error() {
let (tx, rx) = tokio::sync::oneshot::channel::<PickerFetchResult>();
drop(tx);
let join = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.spawn(async {});
let mut picker = Picker {
kind: PickerKind::Issues,
repo_label: "github.com/x/y".into(),
matcher: Matcher::default(),
state: PickerState::Loading {
started_at: std::time::Instant::now(),
rx,
abort: join.abort_handle(),
},
};
assert!(picker.poll_fetch(), "closed channel must transition state");
assert!(matches!(picker.state, PickerState::Error(_)));
}
#[test]
fn run_action_for_selected_noop_when_no_selection() {
let mut app = app(rows(&[]));
run_action_for_selected(&mut app, ActionKind::Fetch);
assert!(
app.action_popup.is_none(),
"no row selected → no popup should be set"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_action_for_selected_records_error_when_no_vcs() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().to_path_buf();
let search_key = format!("local/test/repo {}", path.display());
let row = DashRow {
slug: "local/test/repo".into(),
owner: "test".into(),
path,
search_key,
tags_display: String::new(),
status: None,
gh: None,
};
let mut app = app(vec![row]);
run_action_for_selected(&mut app, ActionKind::Fetch);
let popup = app.action_popup.as_ref().expect("popup installed");
assert!(
popup.outcome.is_none(),
"no-VCS branch should leave `outcome` None to drive the error styling"
);
assert!(
!popup.error.is_empty(),
"error message must be populated so the user sees why the action failed"
);
assert_eq!(popup.kind, ActionKind::Fetch);
assert_eq!(popup.repo_label, "local/test/repo");
}
#[test]
fn yank_selected_slug_noop_when_no_selection() {
let mut app = app(rows(&[]));
yank_selected_slug(&mut app);
assert!(
app.status_message.is_none(),
"no row selected → no status message should be set"
);
}
#[test]
fn open_selected_repo_home_short_circuits_on_local_host() {
let mut app = app_with_single_slug("local/scratch/notes");
open_selected_repo_home(&mut app);
let msg = app
.status_message
.as_ref()
.expect("status banner installed");
assert!(
msg.contains("local-only"),
"local-host status banner should mention the cause, got: {msg}"
);
}
#[test]
fn open_selected_repo_home_noop_when_no_selection() {
let mut app = app(rows(&[]));
open_selected_repo_home(&mut app);
assert!(
app.status_message.is_none(),
"no row selected → no status message should be set"
);
}
}