Files
vulp/src/hub/modals.rs
T
dualfroz 0468c032c6 Phases 0-1 + Sprint A: module split, CLI layer, org-refresh fix, hygiene
- LICENSE (MIT) + README truth-pass; docs/cli.md CLI contract
- CLI subcommands (src/cli_cmds/) per "TUI for users, CLI for users+AI"
- Hub/detail refactor: keymap dispatch, modal split, action enum, BgMsg bus
- Fix: org repos were never refreshed (owner==username match); refresh now
  parallel — 49 repos in ~11s, surfaced 70 previously invisible PRs
- Fix: 3 date bugs in detail/git.rs (format-local, Local::now, 1y month math)
- Stash indicator + [m] actions, session persistence, gh CLI token fallback
- Legacy UI modules removed; plan/ docs (roadmap, audit, debate, handoff)
2026-07-06 14:42:52 +02:00

270 lines
14 KiB
Rust

//! Hub modal system: rendering (`render_modal`), per-keystroke editing
//! (`handle_modal_key`), and committing a modal once the user confirms
//! (`process_modal_confirm`). The actual confirm logic for each feature area
//! lives in the sibling `modals_confirm_*` files, chained together below;
//! background-thread launchers live in `modals_spawn`.
use crossterm::event::KeyCode;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use super::state::{HubState, Modal, ModalAction};
pub use super::modals_spawn::open_bulk_import_picker;
/// Expands a leading `~` or `~/...` to the user's home directory; returns
/// `s` unchanged (as a `PathBuf`) otherwise. Used by every modal that takes
/// a free-form filesystem path, since users naturally type `~/...` paths.
pub fn expand_tilde(s: &str) -> std::path::PathBuf {
let s = s.trim();
if s == "~" {
dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(s))
} else if s.starts_with("~/") {
dirs::home_dir().map(|h| h.join(&s[2..])).unwrap_or_else(|| std::path::PathBuf::from(s))
} else {
std::path::PathBuf::from(s)
}
}
/// Draws the currently active modal, dispatching on its variant. Each
/// variant gets a centered box sized to its content (`centered_rect`).
pub fn render_modal(f: &mut Frame, modal: &Modal, area: Rect) {
match modal {
Modal::Text { title, prompt, value, max_len, .. } => {
let rect = centered_rect(60, 8, area);
f.render_widget(Clear, rect);
let blk = Block::default().title(format!(" {} ", title)).borders(Borders::ALL).style(Style::default().bg(Color::Reset));
let inner = blk.inner(rect);
f.render_widget(blk, rect);
let counter_line = if let Some(max) = max_len {
let n = value.len();
let style = if n >= *max { Style::default().fg(Color::Red) }
else if n * 10 >= max * 7 { Style::default().fg(Color::Yellow) }
else { Style::default().fg(Color::DarkGray) };
Line::from(Span::styled(format!(" ({}/{})", n, max), style))
} else {
Line::from("")
};
let lines = vec![
Line::from(""),
Line::from(Span::styled(format!(" {}", prompt), Style::default().fg(Color::White))),
counter_line,
Line::from(vec![Span::styled(" > ", Style::default().fg(Color::Cyan)), Span::raw(value.clone()), Span::styled("█", Style::default().fg(Color::Cyan))]),
Line::from(""),
Line::from(Span::styled(" Enter = confirm Esc = cancel", Style::default().fg(Color::DarkGray))),
];
f.render_widget(Paragraph::new(lines).style(Style::default().bg(Color::Black)), inner);
}
Modal::Select { title, options, cursor, .. } => {
let h = (options.len() + 5).min(20) as u16;
let rect = centered_rect(60, h, area);
f.render_widget(Clear, rect);
let blk = Block::default().title(format!(" {} ", title)).borders(Borders::ALL);
let inner = blk.inner(rect);
f.render_widget(blk, rect);
let items: Vec<ListItem> = options.iter().enumerate().map(|(i, opt)| {
let on = i == *cursor;
ListItem::new(Line::from(vec![
Span::styled(if on { " ▶ " } else { " " }, Style::default().fg(Color::Cyan)),
Span::styled(opt.clone(), if on { Style::default().fg(Color::White).add_modifier(Modifier::BOLD) } else { Style::default() }),
])).style(if on { Style::default().bg(Color::DarkGray) } else { Style::default() })
}).collect();
f.render_widget(List::new(items).style(Style::default().bg(Color::Black)), inner);
}
Modal::Multi { title, options, checks, cursor, .. } => {
let h = (options.len() + 5).min(24) as u16;
let rect = centered_rect(70, h, area);
f.render_widget(Clear, rect);
let blk = Block::default().title(format!(" {} ", title)).borders(Borders::ALL);
let inner = blk.inner(rect);
f.render_widget(blk, rect);
let items: Vec<ListItem> = options.iter().enumerate().map(|(i, opt)| {
let on = i == *cursor;
let checked = checks[i];
ListItem::new(Line::from(vec![
Span::styled(if on { " ▶ " } else { " " }, Style::default().fg(Color::Cyan)),
Span::styled(if checked { "[✓] " } else { "[ ] " }, if checked { Style::default().fg(Color::Cyan) } else { Style::default().fg(Color::DarkGray) }),
Span::styled(opt.clone(), if on { Style::default().fg(Color::White).add_modifier(Modifier::BOLD) } else { Style::default() }),
])).style(if on { Style::default().bg(Color::DarkGray) } else { Style::default() })
}).collect();
let hint = ListItem::new(Line::from(Span::styled(" Space = toggle Enter = confirm Esc = cancel", Style::default().fg(Color::DarkGray))));
let mut all_items = items;
all_items.push(hint);
f.render_widget(List::new(all_items).style(Style::default().bg(Color::Black)), inner);
}
Modal::Confirm { message, .. } => {
let rect = centered_rect(50, 6, area);
f.render_widget(Clear, rect);
let blk = Block::default().title(" Confirm ").borders(Borders::ALL).style(Style::default().bg(Color::Black));
let inner = blk.inner(rect);
f.render_widget(blk, rect);
let lines = vec![
Line::from(""),
Line::from(Span::styled(format!(" {}", message), Style::default().fg(Color::White))),
Line::from(""),
Line::from(vec![
Span::styled(" [y] Yes ", Style::default().fg(Color::Green)),
Span::styled("[n/Esc] No", Style::default().fg(Color::Red)),
]),
];
f.render_widget(Paragraph::new(lines).style(Style::default().bg(Color::Black)), inner);
}
Modal::Info { title, lines, scroll } => {
let h = (lines.len() + 4).min(30) as u16;
let rect = centered_rect(70, h, area);
f.render_widget(Clear, rect);
let blk = Block::default().title(format!(" {} ", title)).borders(Borders::ALL);
let inner = blk.inner(rect);
f.render_widget(blk, rect);
let avail = inner.height.saturating_sub(1) as usize;
let items: Vec<ListItem> = lines.iter().skip(*scroll).take(avail)
.map(|l| ListItem::new(Line::from(Span::styled(format!(" {}", l), Style::default()))))
.collect();
let hint = ListItem::new(Line::from(Span::styled(" [↑↓] scroll [Esc/q] close", Style::default().fg(Color::DarkGray))));
let mut all = items;
all.push(hint);
f.render_widget(List::new(all).style(Style::default().bg(Color::Black)), inner);
}
Modal::RenamePreview { title, lines, scroll, .. } => {
let h = (lines.len() + 4).min(30) as u16;
let rect = centered_rect(76, h, area);
f.render_widget(Clear, rect);
let blk = Block::default().title(format!(" {} ", title)).borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner = blk.inner(rect);
f.render_widget(blk, rect);
let avail = inner.height.saturating_sub(1) as usize;
let items: Vec<ListItem> = lines.iter().skip(*scroll).take(avail)
.map(|l| ListItem::new(Line::from(Span::styled(format!(" {}", l), Style::default()))))
.collect();
let hint = ListItem::new(Line::from(vec![
Span::styled(" [↑↓] scroll ", Style::default().fg(Color::DarkGray)),
Span::styled("[y/Enter] apply ", Style::default().fg(Color::Green)),
Span::styled("[n/Esc] discard", Style::default().fg(Color::Red)),
]));
let mut all = items;
all.push(hint);
f.render_widget(List::new(all).style(Style::default().bg(Color::Black)), inner);
}
Modal::None => {}
}
}
// ── Project color palette ─────────────────────────────────────────────────────
const PALETTE: &[(&str, &str)] = &[
("#808080", "Gray (default)"),
("#3b82f6", "Blue"),
("#22c55e", "Green"),
("#ef4444", "Red"),
("#eab308", "Yellow"),
("#a855f7", "Purple"),
("#f97316", "Orange"),
("#ec4899", "Pink"),
("#06b6d4", "Cyan"),
("#14b8a6", "Teal"),
("#f43f5e", "Rose"),
];
/// Lists every palette entry as a "`hex Name`" display string, for the
/// project-color `Modal::Select` picker.
pub fn color_palette() -> Vec<String> {
PALETTE.iter().map(|(hex, name)| format!("{} {}", hex, name)).collect()
}
/// Looks up the hex string for a palette index, falling back to the default
/// gray if `cursor` is somehow out of range.
pub fn palette_hex(cursor: usize) -> String {
PALETTE.get(cursor).map(|(h, _)| h.to_string()).unwrap_or_else(|| "#808080".into())
}
/// Finds the palette index for a stored hex value, so re-opening the color
/// picker on an existing project starts with the right entry highlighted.
pub fn palette_cursor_for(hex: &str) -> usize {
PALETTE.iter().position(|(h, _)| *h == hex).unwrap_or(0)
}
/// Centers a `pct_x`%-wide, `height`-tall box inside `area`, clamping the
/// height so it never exceeds the available space.
pub fn centered_rect(pct_x: u16, height: u16, area: Rect) -> Rect {
let w = area.width * pct_x / 100;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, w, height.min(area.height))
}
/// Routes one key event to the active modal's per-character editing logic
/// (typing into a text field, moving a list cursor, toggling a checkbox) and
/// reports whether the modal should be confirmed, cancelled, or left open.
pub fn handle_modal_key(modal: &mut Modal, code: KeyCode) -> ModalAction {
match modal {
Modal::Text { value, max_len, .. } => match code {
KeyCode::Char(c) => {
if max_len.is_none_or(|m| value.len() < m) { value.push(c); }
ModalAction::Pending
}
KeyCode::Backspace => { value.pop(); ModalAction::Pending }
KeyCode::Enter => ModalAction::Confirm,
KeyCode::Esc => ModalAction::Cancel,
_ => ModalAction::Pending,
},
Modal::Select { options, cursor, .. } => match code {
KeyCode::Up | KeyCode::Char('k') => { *cursor = cursor.saturating_sub(1); ModalAction::Pending }
KeyCode::Down | KeyCode::Char('j') => { if *cursor + 1 < options.len() { *cursor += 1; } ModalAction::Pending }
KeyCode::Enter => ModalAction::Confirm,
KeyCode::Esc => ModalAction::Cancel,
_ => ModalAction::Pending,
},
Modal::Multi { options, checks, cursor, .. } => match code {
KeyCode::Up | KeyCode::Char('k') => { *cursor = cursor.saturating_sub(1); ModalAction::Pending }
KeyCode::Down | KeyCode::Char('j') => { if *cursor + 1 < options.len() { *cursor += 1; } ModalAction::Pending }
KeyCode::Char(' ') => { let c = *cursor; checks[c] = !checks[c]; ModalAction::Pending }
KeyCode::Enter => ModalAction::Confirm,
KeyCode::Esc => ModalAction::Cancel,
_ => ModalAction::Pending,
},
Modal::Confirm { .. } => match code {
KeyCode::Char('y') | KeyCode::Enter => ModalAction::Confirm,
KeyCode::Char('n') | KeyCode::Esc => ModalAction::Cancel,
_ => ModalAction::Pending,
},
Modal::Info { lines, scroll, .. } => match code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => ModalAction::Cancel,
KeyCode::Up | KeyCode::Char('k') => { *scroll = scroll.saturating_sub(1); ModalAction::Pending }
KeyCode::Down | KeyCode::Char('j') => {
if *scroll + 1 < lines.len() { *scroll += 1; }
ModalAction::Pending
}
_ => ModalAction::Pending,
},
Modal::RenamePreview { lines, scroll, .. } => match code {
KeyCode::Char('y') | KeyCode::Enter => ModalAction::Confirm,
KeyCode::Char('n') | KeyCode::Esc | KeyCode::Char('q') => ModalAction::Cancel,
KeyCode::Up | KeyCode::Char('k') => { *scroll = scroll.saturating_sub(1); ModalAction::Pending }
KeyCode::Down | KeyCode::Char('j') => {
if *scroll + 1 < lines.len() { *scroll += 1; }
ModalAction::Pending
}
_ => ModalAction::Pending,
},
Modal::None => ModalAction::Cancel,
}
}
/// Commits the currently active modal: takes it out of `state.modal`
/// (replacing with `Modal::None`) and routes it through each
/// `modals_confirm_*::try_confirm` in turn until one of them recognizes its
/// tag and handles it. A modal not recognized by any of them is silently
/// dropped, same as an unmatched arm in a single big `match` would be.
pub fn process_modal_confirm(state: &mut HubState) {
let modal = std::mem::replace(&mut state.modal, Modal::None);
let modal = match super::modals_confirm_project::try_confirm(state, modal) { Some(m) => m, None => return };
let modal = match super::modals_confirm_sync::try_confirm(state, modal) { Some(m) => m, None => return };
let modal = match super::modals_confirm_settings::try_confirm(state, modal) { Some(m) => m, None => return };
let _ = super::modals_confirm_repo::try_confirm(state, modal);
}