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::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table, TableState};
use ratatui::{Terminal, prelude::Backend};
use teravars::Engine;
use crate::cli::TuiArgs;
use crate::commands::ShokaContext;
use crate::commands::cd::emit_path;
use crate::config::{ResolvedConfig, ShokaConfig};
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 rows = build_rows(&shelf, &resolved, &args.tags)?;
if rows.is_empty() {
anyhow::bail!(
"no repos matched the tag filter ({} on the shelf total)",
shelf.len()
);
}
let mut app = App::new(rows);
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,
path: PathBuf,
tags_display: String,
}
fn build_rows(
shelf: &Shelf,
resolved: &ResolvedConfig,
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()))?;
out.push(DashRow {
slug: repo.slug(),
path,
tags_display: repo.tags.join(", "),
});
}
Ok(out)
}
fn has_all_tags(repo: &Repo, wanted: &[String]) -> bool {
wanted.iter().all(|w| repo.tags.iter().any(|t| t == w))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Normal,
Filter,
}
struct App {
rows: Vec<DashRow>,
filter: String,
matches: Vec<usize>,
cursor: usize,
mode: Mode,
table_state: TableState,
matcher: Matcher,
}
impl App {
fn new(rows: Vec<DashRow>) -> Self {
let matches = (0..rows.len()).collect();
let mut table_state = TableState::default();
table_state.select(if rows.is_empty() { None } else { Some(0) });
Self {
rows,
filter: String::new(),
matches,
cursor: 0,
mode: Mode::Normal,
table_state,
matcher: Matcher::default(),
}
}
fn refilter(&mut self) {
if self.filter.is_empty() {
self.matches = (0..self.rows.len()).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() {
buf.clear();
let haystack = nucleo::Utf32Str::new(&row.slug, &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 {
terminal
.draw(|f| ui(f, app))
.map_err(|e| anyhow::anyhow!("drawing frame: {e}"))?;
let evt = event::read().context("reading event")?;
let Event::Key(key) = evt else { continue };
if key.kind == KeyEventKind::Release {
continue;
}
match app.mode {
Mode::Normal => 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('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);
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
let total = app.rows.len();
let shown = app.matches.len();
let filter_part = if app.filter.is_empty() {
String::new()
} else {
format!(" /{}", app.filter)
};
let line = Line::from(format!("shoka — {shown}/{total} repo(s){filter_part}"))
.style(Style::default().add_modifier(Modifier::BOLD));
f.render_widget(Paragraph::new(line), area);
}
fn render_table(f: &mut Frame, area: Rect, app: &mut App) {
let header_row = Row::new(vec!["repo", "path", "tags"]).style(
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Yellow),
);
let rows: Vec<Row> = app
.matches
.iter()
.map(|&idx| {
let row = &app.rows[idx];
Row::new(vec![
row.slug.clone(),
row.path.display().to_string(),
row.tags_display.clone(),
])
})
.collect();
let widths = [
Constraint::Percentage(35),
Constraint::Percentage(50),
Constraint::Percentage(15),
];
let table = Table::new(rows, widths)
.header(header_row)
.row_highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_stateful_widget(table, area, &mut app.table_state);
}
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
let hint = match app.mode {
Mode::Normal => "j/k=move g/G=top/bot /=filter enter=cd q=quit",
Mode::Filter => "type to filter ⌫=delete enter=accept esc=clear",
};
f.render_widget(
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
area,
);
}
#[cfg(test)]
mod tests {
use super::*;
fn rows(slugs: &[&str]) -> Vec<DashRow> {
slugs
.iter()
.map(|s| DashRow {
slug: (*s).into(),
path: PathBuf::from("/tmp"),
tags_display: String::new(),
})
.collect()
}
#[test]
fn refilter_empty_query_keeps_shelf_order() {
let mut app = App::new(rows(&["alpha", "beta", "gamma"]));
app.refilter();
assert_eq!(app.matches, vec![0, 1, 2]);
}
#[test]
fn refilter_narrows_to_matches() {
let mut app = App::new(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::new(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_resets_cursor_to_top() {
let mut app = App::new(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::new(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::new(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::new(rows(&["zzz", "aaa"]));
app.filter = "aaa".into();
app.refilter();
assert_eq!(app.selected_row(), Some(1));
}
}